引言

如果你打开任意一个内容平台——今日头条、知乎、B站、小红书——你几乎总能在注册流程中遇到一个"选择兴趣标签"的页面。这个看似简单的交互模式背后,是一套完整的状态管理逻辑:多组标签的独立选择、已选标签的汇总展示、自定义标签的增删、以及基于标签集合的内容推荐。

标签选择器(Chip/Tag Selector)之所以被如此广泛地使用,是因为它在产品侧解决了"冷启动"问题——新用户没有行为数据,让用户主动选择偏好是获取初始画像最直接的方式。而在技术侧,标签选择器是一个绝佳的状态管理练习:多层级数据结构的不可变更新、跨组件索引查找、动态集合的增删操作。

本文将使用 HarmonyOS NEXT ArkUI 框架,从零构建一个功能完整的"内容偏好中心"页面。不同于直接调用某个现成 Chip 组件然后写一篇参数说明书,本文将标签选择器拆解为数据模型、交互逻辑、视觉反馈三个层次,用 Flex + Text 从底层构建 Chip 组件。这样做的好处是:你将完全掌控标签的行为和样式,而不是受限于某个特定 API 的边界——当你需要圆角标签、图标标签、可删除标签、多色标签等各种变体时,自定义方案的灵活性远胜于黑盒组件。

读完本文你将能够:

  • 使用 Flex({ wrap: FlexWrap.Wrap }) 构建流式芯片布局
  • 设计多层标签数据模型(分类 → 标签组 → 标签项)
  • 实现 ArkTS 不可变状态更新模式(slice() + 新对象引用)
  • 构建"选择 → 汇总 → 推荐"三段式交互流程
  • 处理自定义标签的输入校验、去重和删除

为什么不用现成的 Chip 组件?

ArkUI 确实提供了 ChipChipGroup 组件。但在实际项目中,产品经理和设计师往往对标签有各种定制需求:“选中的标签用品牌蓝底白字”、“自定义标签用橙色边框”、“每个标签前面加 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 组件,选中与否通过 fontColorbackgroundColor 的变化体现。选中时白字蓝底,未选中时深灰字浅灰底——这是一种经典的"高亮 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 数组中的对象,而是创建全新的对象和数组。代码看起来比较冗长,但每一步的意图都很明确:

  1. 遍历目标分类的标签数组,替换被点击的标签(翻转 selected),其余标签保持不变
  2. 遍历分类数组,替换目标分类(使用新标签数组),其余分类保持不变
  3. 将全新的分类数组赋值给 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 = '';
}

校验分三步:

  1. 空值校验:空白输入直接忽略
  2. 长度校验:限制 8 个字,避免过长的标签破坏 UI 布局
  3. 重复校验:遍历分类标签(仅已选中的)和自定义标签,防止添加重复项

自定义标签在 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 额外存储计数,而是每次渲染时动态计算。这样做的好处是:计数永远不会与 categoriescustomTags 不同步。代价是每次 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: flexflex-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 中标签选择器的设计与实现。核心要点如下:

  1. 数据模型分层:TagItem → TagCategory → categories 三级结构,自定义标签独立存储
  2. 不可变状态更新:永远用新对象/新数组驱动 UI,不直接修改 @State 内部属性
  3. FlexWrap 流式布局Flex({ wrap: FlexWrap.Wrap }) 是芯片布局的最佳选择,内容自适宽优于等宽 Grid
  4. 跨分类聚合getAllSelectedLabels()deselectLabel() 实现跨分类的标签汇总与反向操作
  5. 自定义标签管理:输入校验(空值、长度、去重)保证数据质量
  6. 派生状态:计数等可从 State 计算得到的值,不额外维护 State

虽然 ArkUI 提供了现成的 ChipChipGroup 组件,但从 FlexText 出发构建自定义芯片系统,不仅让你对每个像素和每次点击有完全的控制权,更重要的是——你获得了一种在任何 UI 框架中构建"可选择标签列表"的通用思维模型。这种模型可以无缝迁移到你需要的任何场景:邮件标签、商品筛选、技能标签、兴趣画像,等等。

标签选择器的本质是一个"结构化偏好表达"工具——它将用户的模糊意图转化为结构化的数据,为后续的推荐、筛选、排序提供基础。理解了这个本质,你就能在任何需要"用户告诉系统自己是谁"的场景中,设计出优雅的标签交互。


Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐