鸿蒙新特性:标签选择与内容偏好设置实战 — Chip 组件与 Flex 布局的完美结合
引言
如果你打开任意一个内容平台——今日头条、知乎、B站、小红书——你几乎总能在注册流程中遇到一个"选择兴趣标签"的页面。这个看似简单的交互模式背后,是一套完整的状态管理逻辑:多组标签的独立选择、已选标签的汇总展示、自定义标签的增删、以及基于标签集合的内容推荐。
标签选择器(Chip/Tag Selector)之所以被如此广泛地使用,是因为它在产品侧解决了"冷启动"问题——新用户没有行为数据,让用户主动选择偏好是获取初始画像最直接的方式。而在技术侧,标签选择器是一个绝佳的状态管理练习:多层级数据结构的不可变更新、跨组件索引查找、动态集合的增删操作。
本文将使用 HarmonyOS NEXT ArkUI 框架,从零构建一个功能完整的"内容偏好中心"页面。不同于直接调用某个现成 Chip 组件然后写一篇参数说明书,本文将标签选择器拆解为数据模型、交互逻辑、视觉反馈三个层次,用 Flex + Text 从底层构建 Chip 组件。这样做的好处是:你将完全掌控标签的行为和样式,而不是受限于某个特定 API 的边界——当你需要圆角标签、图标标签、可删除标签、多色标签等各种变体时,自定义方案的灵活性远胜于黑盒组件。
读完本文你将能够:
- 使用
Flex({ wrap: FlexWrap.Wrap })构建流式芯片布局 - 设计多层标签数据模型(分类 → 标签组 → 标签项)
- 实现 ArkTS 不可变状态更新模式(
slice()+ 新对象引用) - 构建"选择 → 汇总 → 推荐"三段式交互流程
- 处理自定义标签的输入校验、去重和删除
为什么不用现成的 Chip 组件?
ArkUI 确实提供了 Chip 和 ChipGroup 组件。但在实际项目中,产品经理和设计师往往对标签有各种定制需求:“选中的标签用品牌蓝底白字”、“自定义标签用橙色边框”、“每个标签前面加 emoji”、“已选标签显示在顶部可以点击删除”——这些需求用现成组件的配置项很难全部覆盖。
更实际的问题是:当你的标签数据是动态的、跨多个分类的、需要与外部系统(推荐引擎、搜索过滤)联动时,封装好的 Chip 组件往往成为限制而非帮助。自定义实现让你对每个像素和每次点击都有完全控制权。
本文的策略是:从 Flex 布局和 Text 组件出发,构建自己的 Chip 系统。这反而让本文的技术深度超越了"调用一个 API"的层面,成为真正的组件设计实践。
数据模型设计
在写任何 UI 代码之前,先设计数据模型。标签选择器的数据有三个层级:
TagItem — 单个标签
class TagItem {
label: string;
selected: boolean;
constructor(label: string, selected: boolean) {
this.label = label;
this.selected = selected;
}
}
TagItem 是最小的数据单元。它只有两个字段:标签文本 label 和选中状态 selected。为什么用类而非接口?因为 ArkTS 的 @State 装饰器要求清晰的对象引用追踪,类的实例化语义更适合 slice() + 新对象引用的更新模式。
TagCategory — 标签分类
class TagCategory {
name: string;
icon: string;
tags: TagItem[];
constructor(name: string, icon: string, tags: TagItem[]) {
this.name = name;
this.icon = icon;
this.tags = tags;
}
}
TagCategory 将一组相关的标签组织在一起。例如"内容主题"分类下包含科技、设计、商业等标签;"难度级别"分类下包含入门、初级、中级等标签。icon 字段存储 emoji 用于视觉识别(这里使用 emoji 而非 SymbolGlyph 是因为我们已经验证过可用的 SymbolGlyph 有限,emoji 更灵活)。
初始数据
@State categories: TagCategory[] = [
new TagCategory('内容主题', '📂', [
new TagItem('科技', false), new TagItem('设计', false),
new TagItem('商业', false), new TagItem('健康', false),
new TagItem('教育', false), new TagItem('娱乐', false),
new TagItem('体育', false), new TagItem('旅行', false),
]),
new TagCategory('难度级别', '📊', [
new TagItem('入门', false), new TagItem('初级', false),
new TagItem('中级', true), new TagItem('高级', false),
new TagItem('专家', false),
]),
new TagCategory('内容形式', '📝', [
new TagItem('文章', true), new TagItem('视频', false),
new TagItem('音频', false), new TagItem('图文', false),
new TagItem('直播', false),
]),
];
注意初始数据中有两个默认选中的标签——“中级"和"文章”。这让页面在首次加载时就有直观的选中效果,而不是一片空白。在真实产品中,这些默认值可能来自用户的历史偏好或运营配置。
自定义标签
除了预定义的分类标签,用户还可以输入自定义标签。自定义标签用更简单的 string[] 存储:
@State customTags: string[] = [];
将自定义标签与分类标签分开存储是一个重要的设计决策。分类标签的结构化数据(有分类层级、有 icon)与自定义标签的扁平数据(纯字符串)在渲染和操作上完全不同,强行统一只会增加复杂度。
核心交互一:标签选择与状态更新
渲染芯片
每个分类下的标签使用 Flex({ wrap: FlexWrap.Wrap }) 实现流式布局:
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(cat.tags, (tag: TagItem, tagIdx: number) => {
Text(tag.label)
.fontSize(13)
.fontColor(tag.selected ? '#FFFFFF' : '#555566')
.fontWeight(tag.selected ? FontWeight.Medium : FontWeight.Normal)
.padding({ top: 8, bottom: 8, left: 18, right: 18 })
.borderRadius(20)
.backgroundColor(tag.selected ? '#1677FF' : '#F5F5F5')
.margin({ right: 10, bottom: 10 })
.onClick(() => { this.toggleTag(catIdx, tagIdx); })
}, (tag: TagItem) => tag.label)
}
.width('100%')
每个标签都是一个 Text 组件,选中与否通过 fontColor 和 backgroundColor 的变化体现。选中时白字蓝底,未选中时深灰字浅灰底——这是一种经典的"高亮 vs 留白"对比模式,用户在视觉上可以瞬间区分。
FlexWrap.Wrap 确保当一行放不下时,标签自动折行。传入 ForEach 的第三个参数 (tag: TagItem) => tag.label 是 key 生成器——ArkUI 要求每个列表项的 key 唯一且稳定,用标签文本作为 key 符合这个要求。

不可变状态更新
toggleTag 是标签选择的核心方法:
toggleTag(catIdx: number, tagIdx: number): void {
let tags = this.categories[catIdx].tags;
let updated: TagItem[] = [];
for (let i = 0; i < tags.length; i++) {
if (i === tagIdx) {
updated.push(new TagItem(tags[i].label, !tags[i].selected));
} else {
updated.push(new TagItem(tags[i].label, tags[i].selected));
}
}
let cats: TagCategory[] = [];
for (let i = 0; i < this.categories.length; i++) {
if (i === catIdx) {
cats.push(new TagCategory(this.categories[i].name, this.categories[i].icon, updated));
} else {
cats.push(new TagCategory(this.categories[i].name, this.categories[i].icon, this.categories[i].tags));
}
}
this.categories = cats;
}
这个方法体现了 ArkTS 状态管理的核心原则:永远不要直接修改 @State 数组中的对象,而是创建全新的对象和数组。代码看起来比较冗长,但每一步的意图都很明确:
- 遍历目标分类的标签数组,替换被点击的标签(翻转
selected),其余标签保持不变 - 遍历分类数组,替换目标分类(使用新标签数组),其余分类保持不变
- 将全新的分类数组赋值给
this.categories
为什么不能用 tags[tagIdx].selected = !tags[tagIdx].selected?因为在 ArkTS 的状态管理机制中,直接修改 @State 数组内对象的属性不会触发 UI 更新。框架通过对象引用对比来判断状态是否变化——你修改了内部属性但引用没变,框架认为没有变化,UI 不刷新。只有赋值一个全新的数组(或全新的对象),框架才能检测到差异并触发重渲染。
这是从 React/Vue 过来的开发者最常踩的坑。React 有 setState 的不可变约定,Vue 有 Proxy-based 的响应式系统——ArkTS 采用的是类似 Flutter 的"值比较 + 引用追踪"模式,你需要用新的对象引用来驱动 UI 更新。
核心交互二:已选标签汇总与快速移除
汇总展示
页面顶部有一个"已选标签"区域,将所有已选标签(跨分类 + 自定义)汇总展示:
getAllSelectedLabels(): string[] {
let result: string[] = [];
for (let i = 0; i < this.categories.length; i++) {
let tags = this.categories[i].tags;
for (let j = 0; j < tags.length; j++) {
if (tags[j].selected) {
result.push(tags[j].label);
}
}
}
for (let k = 0; k < this.customTags.length; k++) {
result.push(this.customTags[k]);
}
return result;
}
这个方法是一个"跨分类扁平化"操作——遍历所有分类的所有标签,提取出 selected === true 的标签文本,再拼接上自定义标签,返回一个纯字符串数组。
快速移除
每个已选标签都可以点击移除:
deselectLabel(label: string): void {
// 先检查自定义标签
let found = false;
for (let i = 0; i < this.customTags.length; i++) {
if (this.customTags[i] === label) {
let updated = this.customTags.slice();
updated.splice(i, 1);
this.customTags = updated;
found = true;
break;
}
}
if (found) return;
// 再检查分类标签
for (let i = 0; i < this.categories.length; i++) {
let tags = this.categories[i].tags;
for (let j = 0; j < tags.length; j++) {
if (tags[j].label === label && tags[j].selected) {
this.toggleTag(i, j);
return;
}
}
}
}
deselectLabel 接受一个标签文本,反向查找它的来源并执行移除操作。查找逻辑是先查自定义标签(数据结构简单),再查分类标签(需要遍历二维数组)。找到后,自定义标签用 splice 删除,分类标签调用 toggleTag 反转选中状态。
这里有一个微妙的 UX 设计:已选标签区域中的标签被设计为"点击移除",而不是"点击跳转到对应分类"。前者的心理模型是"我不想要这个了",后者是"我想看看这个标签在哪"。在偏好设置的场景中,移除的意图更常见,因此选择了更直接的操作方式。
已选标签的视觉样式与分类中的芯片不同——使用蓝色边框而非实心填充,右侧带 ✕ 符号,暗示"可以点击删除":
Row() {
Text(label)
.fontSize(12)
.fontColor('#1677FF')
.margin({ right: 6 })
Text('✕')
.fontSize(10)
.fontColor('#1677FF88')
}
.padding({ top: 6, bottom: 6, left: 12, right: 12 })
.borderRadius(16)
.backgroundColor('#1677FF15')
.border({ width: 1, color: '#1677FF40' })
核心交互三:自定义标签输入
自定义标签是提升用户参与感的关键功能。预定义的分类标签能覆盖 80% 的需求,但那 20% 的边缘兴趣(“量子计算”、“声音设计”、“攀岩”)往往才是用户最在意的。
输入与校验
@State inputText: string = '';
addCustomTag(): void {
let trimmed = this.inputText.trim();
if (trimmed.length === 0) {
return;
}
if (trimmed.length > 8) {
this.showToastMessage('标签最多8个字');
return;
}
// 检查与分类标签的重复
for (let i = 0; i < this.categories.length; i++) {
let tags = this.categories[i].tags;
for (let j = 0; j < tags.length; j++) {
if (tags[j].selected && tags[j].label === trimmed) {
this.showToastMessage('标签已存在');
return;
}
}
}
// 检查与自定义标签的重复
for (let i = 0; i < this.customTags.length; i++) {
if (this.customTags[i] === trimmed) {
this.showToastMessage('标签已存在');
return;
}
}
let updated = this.customTags.slice();
updated.push(trimmed);
this.customTags = updated;
this.inputText = '';
}
校验分三步:
- 空值校验:空白输入直接忽略
- 长度校验:限制 8 个字,避免过长的标签破坏 UI 布局
- 重复校验:遍历分类标签(仅已选中的)和自定义标签,防止添加重复项
自定义标签在 UI 上使用橙色系(#FA8C16、#FFF7E6、#FFD591),与分类标签的蓝色形成视觉区分。这让用户一眼就能辨别"系统推荐的标签"和"我自己创建的标签"。
自定义标签的删除
自定义标签自带 ✕ 按钮,点击后执行局部删除:
Text('✕')
.fontSize(10)
.fontColor('#FA8C16')
.onClick(() => {
let updated = this.customTags.slice();
updated.splice(idx, 1);
this.customTags = updated;
})
这里用 .onClick() 而非外层 Row 的点击来实现删除,是因为外层点击已经被用于在"已选标签"区域中的反选逻辑。嵌套点击事件在 ArkUI 中通过事件冒泡机制正常工作,但需要确保语义清晰——外层是大操作(反选),内层是小操作(仅删除自定义标签)。
核心交互四:重置与保存
底部操作栏提供两个按钮——“重置偏好"和"保存设置”:
resetAll(): void {
let cats: TagCategory[] = [];
for (let i = 0; i < this.categories.length; i++) {
let tags: TagItem[] = [];
for (let j = 0; j < this.categories[i].tags.length; j++) {
tags.push(new TagItem(this.categories[i].tags[j].label, false));
}
cats.push(new TagCategory(this.categories[i].name, this.categories[i].icon, tags));
}
this.categories = cats;
this.customTags = [];
this.showToastMessage('已重置全部偏好');
}
resetAll 将所有标签的 selected 置为 false,并清空自定义标签数组。注意它依然遵循不可变更新模式——创建全新的 TagItem、TagCategory 数组。
"保存设置"按钮触发 Toast 提示,生产环境中这里会调用后端 API 将标签偏好同步到用户画像:
Button('保存设置')
.onClick(() => { this.showToastMessage('偏好设置已保存,推荐内容已更新'); })
Toast 的实现使用了 Stack + 条件渲染的轻量方案:
if (this.showToast) {
Text(this.toastMessage)
.fontSize(14)
.fontColor('#FFFFFF')
.padding({ top: 12, bottom: 12, left: 24, right: 24 })
.borderRadius(8)
.backgroundColor('#333333')
.position({ bottom: 80 })
.alignSelf(ItemAlign.Center)
}
showToast 通过 setTimeout 自动隐藏:
showToastMessage(msg: string): void {
this.toastMessage = msg;
this.showToast = true;
setTimeout(() => { this.showToast = false; }, 1500);
}
内容推荐预览:标签的"产出"
标签选择本身不是目的——基于标签的个性化推荐才是。本文 Demo 的底部展示了一个"推荐内容预览"区域,模拟基于已选标签的内容推荐效果:
this.previewCard(
'HarmonyOS NEXT 开发实战指南',
'从环境搭建到应用上架的全流程解析,涵盖 ArkUI、元服务、一次开发多端部署等核心主题',
'#722ED1'
)
每个推荐卡片由彩色竖线、标题和描述组成,使用 @Builder 方法封装:
@Builder
previewCard(title: string, desc: string, color: string) {
Row() {
Row()
.width(4)
.height(40)
.borderRadius(2)
.backgroundColor(color)
.margin({ right: 14 })
Column() {
Text(title)
.fontSize(14)
.fontColor('#1a1a2e')
.fontWeight(FontWeight.Medium)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(desc)
.fontSize(11)
.fontColor('#CCCCDD')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding({ top: 14, bottom: 14, left: 12, right: 12 })
.borderRadius(10)
.backgroundColor('#F8F9FA')
.margin({ bottom: 10 })
}
@Builder 是 ArkUI 中用于封装可复用 UI 片段的方法。与 @Component 不同,@Builder 不创建独立的作用域——它可以直接访问父组件的状态变量,适合"模板化"而非"组件化"的场景。这里用 @Builder 而非独立 @Component 的原因是:推荐卡片不需要独立的状态管理,它只是一个纯展示的模板。
每张卡片的彩色竖线和标题颜色一一对应(紫色、青色、粉色、橙色),形成视觉节奏。竖线是一种经典的"引用/高亮"设计模式,来自出版行业的 blockquote 传统,在数字界面中起到了"不增加背景色的条件下快速区分条目"的作用。
已选标签计数:一个动态徽章
页面标题栏右侧有一个数字徽章,实时显示已选标签总数:
getSelectedCount(): number {
let count = 0;
for (let i = 0; i < this.categories.length; i++) {
let tags = this.categories[i].tags;
for (let j = 0; j < tags.length; j++) {
if (tags[j].selected) count++;
}
}
return count + this.customTags.length;
}
这个数字驱动着整个页面的多个 UI 元素:
- 标题栏徽章:
Text(this.getSelectedCount().toString()) - 已选标签区域的条件渲染:
if (this.getSelectedCount() > 0) - 推荐预览的说明文字:
'基于你选择的 ' + this.getSelectedCount().toString() + ' 个标签'
注意这里没有用 @State selectedCount: number 额外存储计数,而是每次渲染时动态计算。这样做的好处是:计数永远不会与 categories 和 customTags 不同步。代价是每次 build() 执行时都要遍历一次——但标签数量很小(通常 < 30),性能影响可以忽略。
这是"派生状态(derived state)"的经典案例:如果某个值可以从其他 State 纯计算得到,就不要额外维护一个 State——多一个 State 就多一个可能不一致的地方。
完整页面结构
Stack(根容器,用于 Toast 覆盖层)
└── Column
├── Header(标题 + 副标题 + 已选计数徽章)
├── Scroll
│ ├── 已选标签汇总区(条件渲染,点击移除)
│ ├── 分类一:内容主题(8 个芯片)
│ ├── 分类二:难度级别(5 个芯片)
│ ├── 分类三:内容形式(5 个芯片)
│ ├── 自定义标签输入区(TextInput + 添加按钮 + 已有自定义标签列表)
│ └── 推荐内容预览(4 张模拟内容卡片)
└── Bottom Bar(重置偏好 + 保存设置)
FlexWrap 与响应式芯片布局
本文的芯片布局完全依赖 Flex({ wrap: FlexWrap.Wrap }) 实现自动换行。这是一个被低估的布局原语——它的行为类似于 CSS 的 flex-wrap: wrap:
- 每个芯片是 Flex 容器的一个子项
- 芯片按从左到右的顺序排列
- 当一个芯片的右边缘超出容器宽度时,自动换到下一行
- 芯片之间的间距由
.margin({ right: 10, bottom: 10 })控制
为什么不用 Grid?因为芯片的宽度是内容自适应的(不同标签的字数不同),Grid 的等宽列布局不适合这种场景。FlexWrap 是"内容驱动布局"——每个芯片占据它实际需要的空间。
在 HarmonyOS 中,Flex 组件同时承担了 CSS 中 display: flex 和 flex-wrap 两个职责。FlexWrap 枚举有三个值:
FlexWrap.NoWrap— 不换行(默认)FlexWrap.Wrap— 正常换行FlexWrap.WrapReverse— 反向换行
对于标签选择器场景,Wrap 是最自然的选择。
常见问题与解决方案
问题一:点击标签后 UI 不刷新
原因:直接修改了 @State 数组中对象的属性,如 tag.selected = !tag.selected。
解决:始终使用 slice() 创建新数组,使用 new TagItem() 创建新对象,然后整体赋值给 @State 变量。ArkTS 的状态管理依赖对象引用对比,修改内部属性不会触发对比差异。
问题二:Flex 中的芯片宽度不一致
原因:芯片没有设置固定宽度,由文本内容自适应决定。
解决:这是预期的行为。如果确实需要等宽芯片,可以设置 .constraintSize({ minWidth: 80 }) 或固定 .width(80)。但不建议——中文标签字数不一(“入门"2 字,” HarmonyOS 开发"6 字),等宽会导致短标签留白过多或长标签文字溢出。
问题三:自定义标签与分类标签重复
原因:添加自定义标签时没有检查已选中的分类标签。
解决:在 addCustomTag() 中遍历所有分类标签(包括未选中的),检查文本是否重复。本文 Demo 只检查已选中标签——这是有意为之:用户可能在"内容主题"分类中没有选中"科技",但想通过自定义标签手动添加。如果检查所有分类标签(包括未选中的),这种行为就会被阻止。
问题四:ForEach 的 key 冲突
原因:两个不同分类中有相同名称的标签,使用标签文本作为 key 会导致 key 重复。
解决:本文 Demo 的标签文本在全局是唯一的("科技"只出现在"内容主题"分类中,"入门"只出现在"难度级别"分类中)。如果确实有跨分类的同名标签,key 需要改为组合 key:cat.name + '_' + tag.label。
进阶扩展方向
本文构建的标签选择器可以沿以下方向进一步扩展:
1. 最大选择数限制
给特定分类设置最大可选标签数(如"内容形式"最多选 3 个),在 toggleTag 中添加计数判断:
if (!tag.selected && this.getCategorySelectedCount(catIdx) >= maxCount) {
this.showToastMessage('最多选择' + maxCount.toString() + '个');
return;
}
2. 标签搜索
当标签数量很多(如 50+),在分类上方添加搜索框,支持按拼音或关键词过滤标签。
3. 标签推荐
基于已选标签,向用户推荐可能感兴趣的标签(“选择了「科技」的用户也选择了「编程」”),增加标签覆盖率。
4. 持久化存储
使用 @ohos.data.preferences 将用户的标签偏好保存到本地,下次打开页面时自动恢复。
5. 动画过渡
给标签的选中/取消选中添加 animateTo 过渡效果,让状态切换更加流畅:
animateTo({ duration: 200, curve: Curve.EaseOut }, () => {
this.categories = cats;
});
总结
本文通过构建一个完整的"内容偏好中心"页面,深入讲解了 HarmonyOS ArkUI 中标签选择器的设计与实现。核心要点如下:
- 数据模型分层:TagItem → TagCategory → categories 三级结构,自定义标签独立存储
- 不可变状态更新:永远用新对象/新数组驱动 UI,不直接修改
@State内部属性 - FlexWrap 流式布局:
Flex({ wrap: FlexWrap.Wrap })是芯片布局的最佳选择,内容自适宽优于等宽 Grid - 跨分类聚合:
getAllSelectedLabels()和deselectLabel()实现跨分类的标签汇总与反向操作 - 自定义标签管理:输入校验(空值、长度、去重)保证数据质量
- 派生状态:计数等可从 State 计算得到的值,不额外维护 State
虽然 ArkUI 提供了现成的 Chip 和 ChipGroup 组件,但从 Flex 和 Text 出发构建自定义芯片系统,不仅让你对每个像素和每次点击有完全的控制权,更重要的是——你获得了一种在任何 UI 框架中构建"可选择标签列表"的通用思维模型。这种模型可以无缝迁移到你需要的任何场景:邮件标签、商品筛选、技能标签、兴趣画像,等等。
标签选择器的本质是一个"结构化偏好表达"工具——它将用户的模糊意图转化为结构化的数据,为后续的推荐、筛选、排序提供基础。理解了这个本质,你就能在任何需要"用户告诉系统自己是谁"的场景中,设计出优雅的标签交互。
更多推荐

所有评论(0)