在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

SDK 版本:HarmonyOS NEXT 6.1.1(API 24)
开发语言:ArkTS(Ark TypeScript)
核心组件:Row + Scroll + @State + @Builder

一、引言

在移动应用开发中,标签栏(Tag Bar) 是最常见的 UI 模式之一。无论是新闻客户端的分类导航、电商应用的商品筛选、社交媒体的兴趣标签,还是开发工具中的技术分类,标签栏都扮演着核心的交互角色。在鸿蒙 HarmonyOS 的 ArkTS 框架中,Row 组件是实现标签栏的主力容器——它天然支持水平排列、对齐控制,通过配合 Scroll@State@Builder 等能力,几乎可以实现所有标签栏的场景需求。

本文将围绕一份完整的示例代码,从模型层的架构设计开始,逐步深入讲解六大标签栏场景的实现原理、核心技巧和踩坑要点。全文涉及的源文件包括:

  • model/RowTagBarModel.ets:模型层,集中管理接口定义、色彩常量和静态数据
  • pages/RowTagBarDemo.ets:UI 层,包含主页面、可复用组件和六大 @Builder 场景

二、架构设计:模型层(Model)与 UI 层分离

在写任何中大型 ArkTS 应用之前,我们强烈建议先做一件事——把数据模型(Model)从页面代码中分离出来。这不是一个"优雅"的问题,而是一个维护性问题。当你在一个 .ets 文件中堆了 800 行代码后,每条常量定义、每个接口声明都埋没在 UI 逻辑中,改一个颜色可能要翻三页。

2.1 为什么需要模型层

在这个标签栏示例中,我们把以下内容全部提取到了独立的 RowTagBarModel.ets

  1. 接口定义TagItem(标签数据模型)、CategoryTag(多彩标签模型)
  2. 色彩常量:17 个暗黑主题颜色常量
  3. 静态数据:6 组标签数据(基础标签、多彩标签、可滚动标签、可删除标签初始值、技术标签、生活标签)

提取的好处体现在三个方面:

  • 可读性:页面代码聚焦于 UI 布局和交互逻辑,数据从哪里来一目了然
  • 可复用性:同一个数据模型可以被多个组件引用
  • 可维护性:设计改色只需要改一个文件

2.2 标签数据模型设计

标签栏的核心数据模型非常简洁——一个标签只需要三个字段:

// RowTagBarModel.ets
export interface TagItem {
  id: number;         // 唯一标识,用于删除时的精确匹配
  label: string;      // 显示文本
  selected: boolean;  // 是否选中(初始值统一为 false)
}

对于多彩分类标签,我们额外需要一个 color 字段存储主题色:

export interface CategoryTag {
  label: string;
  color: string;
}

多彩标签与基础标签的核心区别在于:选中态的颜色是动态的,每个标签用自己的颜色高亮,而非统一使用蓝色

2.3 色彩常量体系

整个应用采用统一的暗黑主题色彩体系。色彩常量的命名遵循 用途_语义 的规范:

// 背景色
export const BG_DARK = '#0A0A1A';    // 页面主背景(深空色)
export const CARD_BG = '#1A1A2E';    // 卡片背景(稍亮)
export const CARD_BG2 = '#16213E';   // 卡片内部深色区

// 文字色
export const TEXT_WHITE = '#FFFFFF';
export const TEXT_GRAY = '#AAAAAA';
export const TEXT_DIM = '#666666';

// 强调色(每个场景一个颜色)
export const ACCENT_BLUE = '#4D96FF';
export const ACCENT_GREEN = '#6BCB77';
export const ACCENT_ORANGE = '#FFA94D';
export const ACCENT_RED = '#FF6B6B';
export const ACCENT_PURPLE = '#9B59B6';
export const ACCENT_CYAN = '#00D2FF';
export const ACCENT_PINK = '#FF6B9D';

// 标签专用
export const TAB_DARK = '#FFFFFF12';    // 标签默认背景(半透明白)
export const TAB_BORDER = '#FFFFFF18';  // 标签边框色

2.4 静态数据分类存放

六组静态标签数据按场景分类存放在模型层:

// 场景①:基础静态标签(10 个分类)
export const STATIC_TAGS: string[] = [
  '全部', '推荐', '科技', '生活', '游戏',
  '动漫', '音乐', '影视', '体育', '美食'
];

// 场景②:多彩分类标签(6 个分类,各带主题色)
export const CATEGORY_TAGS: CategoryTag[] = [
  { label: '🎨 设计', color: ACCENT_PURPLE },
  { label: '⚡ 开发', color: ACCENT_BLUE },
  { label: '📊 产品', color: ACCENT_GREEN },
  { label: '📈 运营', color: ACCENT_ORANGE },
  { label: '🎯 市场', color: ACCENT_RED },
  { label: '📝 内容', color: ACCENT_PINK }
];

// 场景③:可滚动标签栏(20 个,用于演示溢出)
export const SCROLL_TAGS: string[] = [
  '热门推荐', '最新发布', '最多点赞', '最多评论',
  '本月热榜', '本周精选', '今日新鲜', '编辑推荐',
  '关注的人', '好友动态', '同城内容', '分类精选',
  '技术前沿', '创意设计', '生活妙招', '旅行攻略',
  '美食探店', '数码评测', '运动健身', '影视娱乐'
];

// 场景⑤:可删除标签初始值(5 个)
export const INITIAL_EDITABLE_TAGS: TagItem[] = [
  { id: 1, label: 'TypeScript', selected: false },
  { id: 2, label: 'ArkTS', selected: false },
  { id: 3, label: 'HarmonyOS', selected: false },
  { id: 4, label: 'NEXT', selected: false },
  { id: 5, label: 'API 24', selected: false }
];

// 场景⑥:技术标签(8 个)
export const TECH_TAGS: string[] = [
  '全部', '前端', '后端', '移动端', 'AI/ML', '云原生', '数据库', '安全'
];

// 场景⑥:生活标签(7 个)
export const LIFE_TAGS: string[] = [
  '美食', '旅行', '摄影', '阅读', '运动', '手工', '宠物'
];

三、可复用子组件设计

在进入主页面之前,我们先设计两个可复用的子组件。良好的组件抽象能大幅减少 build() 中的重复代码。

3.1 TagChip:单个标签块

这是一个"老实本分"的组件——没有 @State,没有复杂的生命周期,只有四个 private 入参:

// RowTagBarDemo.ets
@Component
struct TagChip {
  private label: string = '';
  private selected: boolean = false;
  private accentColor: string = ACCENT_BLUE;
  private onClickAction: () => void = () => {};

  build() {
    Text(this.label)
      .fontSize(13)
      .fontColor(this.selected ? TEXT_WHITE : TEXT_GRAY)
      .fontWeight(this.selected ? FontWeight.Medium : FontWeight.Regular)
      .backgroundColor(this.selected ? this.accentColor : TAB_DARK)
      .padding({ left: 14, right: 14, top: 6, bottom: 6 })
      .borderRadius(16)
      .border({ width: this.selected ? 0 : 1, color: TAB_BORDER })
      .margin({ right: 8 })
      .onClick(() => { this.onClickAction(); })
  }
}

设计要点

  • onClickAction 使用回调函数:父组件传入点击逻辑,子组件不持有状态,保持无状态设计
  • selected 驱动视觉:选中时使用 accentColor 实心背景 + 白色文字,未选中时使用半透明白底 + 灰色文字 + 边框
  • border 在选中时消失:选中态去掉边框(width: 0),让颜色填充更饱满;未选中态保留 1vp 细边框增加轮廓感

3.2 DemoCard:演示卡片容器

这是一个 @BuilderParam 的经典用例。卡片框架是固定的(标题 + 描述 + 标签),唯一变化的是卡片内部的内容区:

@Component
struct DemoCard {
  private title: string = '';
  private desc: string = '';
  private tag: string = '';
  @BuilderParam content: () => void = this.emptyContent;

  @Builder
  emptyContent() {}

  build() {
    Column() {
      // 标题行(Row 内嵌标题与标签)
      Row() {
        Text(this.title)
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(TEXT_WHITE)

        Text(this.tag)
          .fontSize(10)
          .fontColor(ACCENT_CYAN)
          .backgroundColor(ACCENT_CYAN + '22')
          .padding({ left: 8, right: 8, top: 2, bottom: 2 })
          .borderRadius(8)
          .margin({ left: 8 })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
      .margin({ bottom: 6 })

      Text(this.desc)
        .fontSize(12)
        .fontColor(TEXT_GRAY)
        .width('100%')
        .margin({ bottom: 12 })

      this.content()
    }
    .width('100%')
    .padding(16)
    .backgroundColor(CARD_BG2)
    .borderRadius(14)
    .margin({ bottom: 12 })
  }
}

@BuilderParam 的原理:父组件传入一个 @Builder 函数给子组件的 content 参数,子组件在 build() 中通过 this.content() 调用。这相当于 React 的 children prop 或 Vue 的 <slot>


四、主页面结构与状态管理

4.1 @State 变量一览

主页面 RowTagBarDemo 结构体定义了 7 个 @State 变量(每个场景对应的选中索引或管理数据):

@Entry
@Component
struct RowTagBarDemo {
  // 场景①:基础静态标签 — 当前选中索引(默认选中「全部」= 索引 0)
  @State staticSelectedIdx: number = 0;

  // 场景②:多彩分类标签 — 当前选中索引(-1 表示未选中)
  @State categorySelectedIdx: number = -1;

  // 场景⑤:可删除标签 — @State 管理整个标签数组
  @State editableTags: TagItem[] = [...INITIAL_EDITABLE_TAGS];
  private tagIdCounter: number = 6;  // 自增 ID 计数器

  // 场景③:可滚动标签栏 — 选中索引
  @State scrollSelectedIdx: number = 2;

  // 场景⑥:交互式切换 — 分类模式 + 两个选中索引
  @State filterMode: string = 'tech';
  @State techSelectedIdx: number = 0;
  @State lifeSelectedIdx: number = -1;
}

@State 的核心约束:只有 @State 修饰的变量发生变化时,组件才会重新渲染。普通 private 变量变化不会触发 UI 更新。这是 ArkTS 声明式框架的基石——开发者不需要手动调用 setState() 或类似函数,框架自动追踪依赖。

4.2 build() 布局结构

主页面的 build() 采用三层嵌套结构:

Column(全屏、BG_DARK 背景)
├── Row(顶部标题栏:返回按钮 + 标题)
└── Scroll(可滚动内容区)
    └── Column(垂直排列的卡片流)
        ├── 概念说明卡片
        ├── DemoCard 场景①(基础标签栏)
        ├── DemoCard 场景②(多彩分类标签)
        ├── DemoCard 场景③(可滚动标签栏)
        ├── DemoCard 场景④(大小混合标签)
        ├── DemoCard 场景⑤(可删除与添加标签)
        ├── DemoCard 场景⑥(交互式标签切换)
        └── 布局要点总结卡片

为什么最外层用 Scroll:六大场景卡片加起来的高度远超屏幕可见区,所以必须用 Scroll 包裹。注意 Scroll 内部的 Column 设置了 alignItems(HorizontalAlign.Center) 让所有卡片居中显示。


五、六大场景逐项解读

场景①:基础标签栏

核心代码

@Builder
renderStaticTags() {
  Column() {
    Text('// Row() + ForEach → 标签列表,点击切换选中')
      .fontSize(11).fontColor(TEXT_DIM).fontFamily('Courier')
      .width('100%').margin({ bottom: 10 })

    Row() {
      ForEach(STATIC_TAGS, (tag: string, index: number) => {
        Text(tag)
          .fontSize(13)
          .fontColor(this.staticSelectedIdx === index ? TEXT_WHITE : TEXT_GRAY)
          .fontWeight(this.staticSelectedIdx === index ? FontWeight.Medium : FontWeight.Regular)
          .backgroundColor(this.staticSelectedIdx === index ? ACCENT_BLUE : TAB_DARK)
          .padding({ left: 16, right: 16, top: 7, bottom: 7 })
          .borderRadius(18)
          .border({ width: this.staticSelectedIdx === index ? 0 : 1, color: TAB_BORDER })
          .margin({ right: 8 })
          .onClick(() => { this.staticSelectedIdx = index; })
      }, (item: string) => item)
    }.width('100%').padding(0)

    Text('当前选中:「' + STATIC_TAGS[this.staticSelectedIdx] + '」')
      .fontSize(11).fontColor(ACCENT_BLUE).width('100%').margin({ top: 10 })
  }
}

技术要点

  1. ForEach 的 key 生成:第二个参数 (item: string) => item 是为每个标签生成唯一 key,ArkTS 用 key 来优化列表 diff。在 string[] 场景下直接用字符串本身作为 key 即可。

  2. 条件渲染选中态:通过 this.staticSelectedIdx === index 比较,为当前选中的标签应用完全不同的样式——背景色、文字颜色、字重、边框全部同步变化。

  3. padding(0) 消除 Row 的默认内边距:Row 默认有 4vp 左右的 padding(不同主题略有差异),显式设为 0 让标签贴边排列。

  4. 实心 borderRadius 的视觉技巧:选中态 borderRadius(18) + padding(16) 的组合产生"胶囊标签"的效果。borderRadius 的值设为标签高度的 50% 左右是最佳比例。


场景②:多彩分类标签

核心代码

@Builder
renderCategoryTags() {
  Column() {
    Text('// 每个标签拥有独立 accentColor,选中时高亮')
      .fontSize(11).fontColor(TEXT_DIM).fontFamily('Courier')
      .width('100%').margin({ bottom: 10 })

    Row() {
      ForEach(CATEGORY_TAGS, (item: CategoryTag, index: number) => {
        Text(item.label)
          .fontSize(13)
          .fontColor(this.categorySelectedIdx === index ? TEXT_WHITE : item.color)
          .fontWeight(this.categorySelectedIdx === index ? FontWeight.Medium : FontWeight.Regular)
          .backgroundColor(this.categorySelectedIdx === index ? item.color : item.color + '22')
          .padding({ left: 14, right: 14, top: 7, bottom: 7 })
          .borderRadius(16)
          .border({
            width: this.categorySelectedIdx === index ? 0 : 1,
            color: item.color + '44'
          })
          .margin({ right: 8 })
          .onClick(() => {
            this.categorySelectedIdx = (this.categorySelectedIdx === index) ? -1 : index;
          })
      }, (item: CategoryTag) => item.label + item.color)
    }.width('100%')

    Text(this.categorySelectedIdx === -1
      ? '💡 当前未选中任何标签'
      : '✅ 当前选中:「' + CATEGORY_TAGS[this.categorySelectedIdx].label + '」')
      .fontSize(11)
      .fontColor(this.categorySelectedIdx === -1 ? TEXT_DIM : CATEGORY_TAGS[this.categorySelectedIdx].color)
      .width('100%').margin({ top: 10 })
  }
}

与场景①的关键差异

差异点 基础标签栏 多彩分类标签
数据源 string[] CategoryTag[](含 color)
未选中背景色 统一 TAB_DARK item.color + '22'(对应色半透明)
未选中文字色 统一 TEXT_GRAY item.color(对应色)
未选中边框色 统一 TAB_BORDER item.color + '44'(对应色更浅半透明)
选中逻辑 单选 切换式(再次点击取消选中)

切换式选中(Toggle)的实现this.categorySelectedIdx = (this.categorySelectedIdx === index) ? -1 : index。当已选中的标签被再次点击时,索引重置为 -1(即取消选中状态)。

颜色半透明拼接技巧:ArkTS 中颜色字符串可以直接拼接 Alpha 通道值,如 '#9B59B6' + '22''#9B59B622'(约 13% 不透明度)。不同透明度层级:

  • '22'(13%):未选中背景色——只有淡淡的底色提示
  • '44'(27%):未选中边框色——隐约可见的轮廓
  • '66'(40%):hover 态增强色——本文未使用但推荐实现
  • 'FF'(100%):选中态实色

场景③:可滚动标签栏

核心代码

@Builder
renderScrollableTags() {
  Column() {
    Text('// Scroll() { Row() { 标签... } } → 水平滚动')
      .fontSize(11).fontColor(TEXT_DIM).fontFamily('Courier')
      .width('100%').margin({ bottom: 10 })

    Scroll() {
      Row() {
        ForEach(SCROLL_TAGS, (tag: string, index: number) => {
          Text(tag)
            .fontSize(13)
            .fontColor(this.scrollSelectedIdx === index ? TEXT_WHITE : TEXT_GRAY)
            .fontWeight(this.scrollSelectedIdx === index ? FontWeight.Medium : FontWeight.Regular)
            .backgroundColor(this.scrollSelectedIdx === index ? ACCENT_CYAN : TAB_DARK)
            .padding({ left: 16, right: 16, top: 7, bottom: 7 })
            .borderRadius(18)
            .border({ width: this.scrollSelectedIdx === index ? 0 : 1, color: TAB_BORDER })
            .margin({ right: 8 })
            .onClick(() => { this.scrollSelectedIdx = index; })
        }, (item: string) => item)
      }
      .width('auto')  // 关键:Row 自适应内容宽度
    }
    .width('100%')
    .scrollBar(BarState.Off)
    .scrollable(Axis.Horizontal)

    Text('💡 共有 ' + SCROLL_TAGS.length + ' 个标签,超出屏幕宽度时左右滑动查看')
      .fontSize(11).fontColor(TEXT_DIM).width('100%').margin({ top: 10 })
  }
}

可滚动的实现条件(严格满足三条)

  1. Scroll 包裹 RowScroll() 作为外层容器,Row() 作为内层
  2. Row 宽度设为 ‘auto’:这是最关键也最容易遗漏的一步。Row 的 width('auto') 让其宽度自适应所有子标签的总和,从而"撑开"超过父容器 Scroll 的宽度
  3. Scroll 限制宽度:Scroll 本身设置 width('100%') 固定在其父容器的宽度,当 Row 超宽时自动显示滚动交互

滚动方向配置.scrollable(Axis.Horizontal) 明确指定水平方向。不设置 scrollable 时 Scroll 默认只支持垂直滚动。

scrollBar(BarState.Off) 的取舍:在标签栏场景中,滚动条通常是视觉干扰,建议关闭。但在"首次使用需要发现可滚动性"的场景中,可以设为 BarState.Auto 仅在拖动时出现。


场景④:大小混合标签

核心代码

@Builder
renderMixedSizeTags() {
  Column() {
    Text('// 标签内边距不同,但 height 保持一致 → 视觉整齐')
      .fontSize(11).fontColor(TEXT_DIM).fontFamily('Courier')
      .width('100%').margin({ bottom: 10 })

    Row() {
      // 小号标签(紧凑)
      Text('小').fontSize(11)
        .backgroundColor(TAB_DARK).padding({ left: 8, right: 8, top: 4, bottom: 4 })
        .borderRadius(12).border({ width: 1, color: TAB_BORDER }).margin({ right: 6 })

      // 标准标签
      Text('标准').fontSize(13)
        .backgroundColor(TAB_DARK).padding({ left: 14, right: 14, top: 6, bottom: 6 })
        .borderRadius(16).border({ width: 1, color: TAB_BORDER }).margin({ right: 6 })

      // 大号标签(突出展示)
      Text('🔥 热门推荐').fontSize(14).fontWeight(FontWeight.Bold)
        .fontColor(TEXT_WHITE).backgroundColor(ACCENT_RED)
        .padding({ left: 18, right: 18, top: 8, bottom: 8 })
        .borderRadius(20).margin({ right: 6 })

      // 带 Emoji 的标签
      Text('⭐ 精选').fontSize(13).fontColor(TEXT_WHITE)
        .backgroundColor(ACCENT_ORANGE)
        .padding({ left: 14, right: 14, top: 6, bottom: 6 })
        .borderRadius(16).margin({ right: 6 })

      // 长文字标签
      Text('超长分类标签名称').fontSize(12)
        .backgroundColor(TAB_DARK).padding({ left: 12, right: 12, top: 6, bottom: 6 })
        .borderRadius(16).border({ width: 1, color: TAB_BORDER })
    }.width('100%')
  }
}

Row 如何处理不同高度的子项:在 Row 中,所有子项的高度会被 Row 的 alignItems 属性影响。Row 默认的 alignItems 是 VerticalAlign.Center(垂直居中对齐),所以即使标签大小不同,它们也会在垂直方向居中对齐,不会一个高一个低。

各标签的具体尺寸:

标签 fontSize padding(top/bottom) 理论高度 borderRadius
11 4 + 4 = 8 11×1.2 + 8 ≈ 21 12
标准 13 6 + 6 = 12 13×1.2 + 12 ≈ 28 16
热门推荐 14 8 + 8 = 16 14×1.2 + 16 ≈ 33 20

(注:height 为理论估算值,实际高度受字体行高影响,可通过 lineHeight 精确控制)

borderRadius 与 height 的比例关系:设计上推荐的 borderRadius 约为 height 的 50%~60%。小标签 21vp → 12vp(57%),标准标签 28vp → 16vp(57%),热门推荐 33vp → 20vp(61%),比例相近才能保持"胶囊感"的一致性。


场景⑤:可删除与添加标签

核心代码

@Builder
renderEditableTags() {
  Column() {
    Text('// 点击 × 删除标签 | 点击「+ 添加」按钮追加标签')
      .fontSize(11).fontColor(TEXT_DIM).fontFamily('Courier')
      .width('100%').margin({ bottom: 10 })

    Scroll() {
      Row() {
        ForEach(this.editableTags, (item: TagItem, index: number) => {
          Row() {
            Text(item.label).fontSize(13).fontColor(TEXT_WHITE).margin({ right: 4 })
            Text('×').fontSize(14).fontColor(TEXT_GRAY).fontWeight(FontWeight.Bold)
              .onClick(() => { this.removeTag(index); })
          }
          .padding({ left: 12, right: 8, top: 6, bottom: 6 })
          .backgroundColor(ACCENT_BLUE + '33')
          .borderRadius(16).border({ width: 1, color: ACCENT_BLUE + '55' })
          .margin({ right: 8 }).alignItems(VerticalAlign.Center)
        }, (item: TagItem) => item.id.toString())
      }.width('auto')
    }
    .width('100%').scrollBar(BarState.Off)
    .margin({ bottom: 10 })

    Row() {
      Button('+ 添加').height(32).fontSize(13).fontColor(TEXT_WHITE)
        .backgroundColor(ACCENT_GREEN).borderRadius(16)
        .padding({ left: 14, right: 14 })
        .onClick(() => { this.addRandomTag(); })
    }.width('100%')
  }
}

删除操作的核心逻辑

private removeTag(index: number): void {
  if (this.editableTags.length <= 1) {
    return;  // 保留至少一个标签
  }
  this.editableTags.splice(index, 1);
  this.editableTags = [...this.editableTags];  // 展开数组触发 @State 刷新
}

@State 装饰的数组有一个重要特性:必须通过重新赋值(而不是直接修改原数组)来触发 UI 更新.splice() 会修改原数组,但不会触发 ArkTS 的变更检测。所以需要 [...this.editableTags] 展开为一个新数组再赋回去。

添加操作的核心逻辑

private addRandomTag(): void {
  const existingLabels = new Set(this.editableTags.map(t => t.label));
  const available = TAG_POOL.filter(t => !existingLabels.has(t));
  if (available.length === 0) {
    return;
  }
  const newLabel = available[Math.floor(Math.random() * available.length)];
  this.editableTags = [
    ...this.editableTags,
    { id: this.tagIdCounter++, label: newLabel, selected: false }
  ];
}

防重复机制:先从 TAG_POOL 中过滤掉已经添加的标签,确保不会添加重复项。tagIdCounter 自增保证每个新标签的 id 唯一(即使在删除后重建)。

增删场景中的注意事项

  • ForEach 的 key 生成器使用 item.id.toString() 而不是 item.label,因为 label 在理论上可能重复
  • 保留至少一个标签(length <= 1 时禁止删除)
  • 添加按钮使用 Button 而非 Text,因为按钮自带 touch 反馈且可设 height

场景⑥:交互式标签切换

核心代码(切换控制部分):

@Builder
renderInteractiveTags() {
  Column() {
    Text('// 点击分类按钮 → 切换标签列表 → 选中标签')
      .fontSize(11).fontColor(TEXT_DIM).fontFamily('Courier')
      .width('100%').margin({ bottom: 10 })

    Row() {
      Button('💻 技术').height(36).layoutWeight(1).fontSize(14)
        .fontColor(this.filterMode === 'tech' ? TEXT_WHITE : TEXT_GRAY)
        .backgroundColor(this.filterMode === 'tech' ? ACCENT_BLUE : TAB_DARK)
        .borderRadius(8).margin({ right: 6 })
        .onClick(() => {
          this.filterMode = 'tech';
          this.techSelectedIdx = 0;
        })

      Button('🏠 生活').height(36).layoutWeight(1).fontSize(14)
        .fontColor(this.filterMode === 'life' ? TEXT_WHITE : TEXT_GRAY)
        .backgroundColor(this.filterMode === 'life' ? ACCENT_GREEN : TAB_DARK)
        .borderRadius(8).margin({ left: 6 })
        .onClick(() => {
          this.filterMode = 'life';
          this.lifeSelectedIdx = -1;
        })
    }.width('100%').margin({ bottom: 12 })

    if (this.filterMode === 'tech') {
      this.renderTechTags();
    } else {
      this.renderLifeTags();
    }
  }
}

条件渲染的核心if (this.filterMode === 'tech') 是 ArkTS 中的条件渲染语句。当 filterMode 变化时,框架自动销毁不再显示的 Builder、创建新显示的 Builder。

layoutWeight(1) 的使用:两个分类按钮各占 50% 宽度。layoutWeight 是 Row 中实现等宽布局最简洁的方式——它类似于 CSS Flexbox 中的 flex: 1

技术标签与生活标签的差异:两个标签列表的选中索引是独立的(techSelectedIdxlifeSelectedIdx),切换分类时不会互相干扰。

技术标签渲染:

@Builder
renderTechTags() {
  Row() {
    ForEach(TECH_TAGS, (tag: string, index: number) => {
      Text(tag)
        .fontSize(13)
        .fontColor(this.techSelectedIdx === index ? TEXT_WHITE : TEXT_GRAY)
        .fontWeight(this.techSelectedIdx === index ? FontWeight.Medium : FontWeight.Regular)
        .backgroundColor(this.techSelectedIdx === index ? ACCENT_BLUE : TAB_DARK)
        .padding({ left: 14, right: 14, top: 6, bottom: 6 })
        .borderRadius(16)
        .border({ width: this.techSelectedIdx === index ? 0 : 1, color: TAB_BORDER })
        .margin({ right: 8 })
        .onClick(() => { this.techSelectedIdx = index; })
    }, (item: string) => item)
  }.width('100%').margin({ bottom: 8 })

  Text('当前技术分类:「' + TECH_TAGS[this.techSelectedIdx] + '」')
    .fontSize(11).fontColor(ACCENT_BLUE).width('100%')
}

ArkTS 条件渲染 vs 显隐切换

  • if/else:DOM 级别的切换——组件被完全创建和销毁。适用于"两个差异较大的界面"
  • .visibility():CSS 级别的切换——组件保留在布局中但不可见。适用于"频繁切换且结构相同"的场景

在本例中,技术标签和生活标签结构相似但数据源不同,用 if/else 是合理的。


六、核心布局原则与最佳实践

通过六个场景的完整实现,可以总结出以下八条标签栏布局的核心原则:

原则一:Row 是标签栏的第一选择

Row 组件天然支持子项的水平排列,它的 alignItems 属性可以控制所有子项的垂直对齐方式(Start/Center/End/Stretch)。对于单行标签栏,Row 永远优于 Column + 横向布局——Row 的语义更清晰、代码更简洁。

原则二:padding 决定标签高度

标签的高度取决于 fontSize × lineHeight + paddingTop + paddingBottom。要保持多个标签高度一致,必须统一 padding 的 top/bottom 值。如果不同标签有不同的 padding,Row 会用 alignItems 来对齐它们——默认 Center 会导致所有标签垂直居中,高低不同的标签也会以此对齐。

原则三:borderRadius 与高度成比例

胶囊标签的美感关键在于 borderRadius 的值。经验法则:borderRadius 取标签总高度的 40%~50%

例如 padding(top/bottom) = 7 + 7 = 14,fontSize = 14,行高 ≈ 17,总高 ≈ 31,则最佳 borderRadius ≈ 15~16。

原则四:margin 产生间距而非 padding

标签之间的间距应该用 margin({ right: ... }) 来实现,而不是在 Row 上设置 space 属性。用 margin 的好处是:

  • 每个标签可以有自己的右间距
  • 最后一个标签的 margin 不影响 Row 的宽度计算
  • Scroll 场景中 margin 确保了标签滚动到末端时也有间距

原则五:选中态三要素

选中状态的视觉反馈至少应该包含三个维度的变化:

backgroundColor: 实色填充
    └── 从半透明/暗色 → 固体亮色
fontColor: 颜色变化
    └── 从灰色 → 白色(或从浅色 → 深色)
fontWeight: 字重
    └── 从 Regular → Medium/Bold

三个维度同步变化时,用户的感知最强烈、反馈最清晰。

原则六:Scroll + width(‘auto’) 实现滚动

这是标签栏最核心的组合技巧:

Scroll()             ← 固定宽度容器
  Row()
    .width('auto')   ← 自适应内容宽度
    ForEach { 标签... }

width('auto') 是 Row 的必备属性——没有它,Row 默认继承父容器宽度,永远不会溢出,Scroll 就没有滚动的必要了。

原则七:@State 数组需要展开赋值

// ❌ 错误:不会触发 UI 更新
this.editableTags.splice(index, 1);

// ✅ 正确:展开为新数组再赋值
this.editableTags.splice(index, 1);
this.editableTags = [...this.editableTags];

这是 ArkTS 中数组操作的黄金法则:永远用新数组替换旧数组

原则八:颜色半透明拼接

ArkTS 支持 '#FFFFFF' + '33''#FFFFFF33' 的字符串拼接方式,这在多彩标签场景中非常有用——不需要额外定义每个颜色的不同透明度版本,运行时动态生成即可。

常用的透明度对照表:

后缀 透明度 使用场景
'00' 0% 完全透明
'11' 6.7% 极淡背景提示
'22' 13.3% 标签未选中背景
'33' 20% 可删除标签背景
'44' 26.7% 未选中边框
'55' 33.3% 可删除标签边框
'66' 40% 悬停态增强
'AA' 66.7% 半透明覆盖层
'FF' 100% 实色

七、常见问题与排查方法

Q1:为什么我的标签不滚动?

检查三个条件是否全部满足:

☐ Scroll 包裹了 Row
☐ Row 设置了 .width('auto')
☐ 标签的总宽度确实超过了 Scroll 的宽度

最常见遗漏:Row 忘记 width('auto')

Q2:为什么点击标签后 UI 没有变化?

检查 @State 变量的类型:

  • 基本类型numberstringboolean):直接赋值即可触发更新
  • 数组(如 TagItem[]):必须用展开创建新对象再赋值
  • 对象:同样推荐用展开运算符创建新对象
// 数组正确用法
this.editableTags = [...this.editableTags, newItem];
// 或
this.editableTags = this.editableTags.concat([newItem]);

// 对象正确用法
this.myObject = { ...this.myObject, updatedField: newValue };

Q3:为什么 ForEach 中的标签没有正确 diff 更新?

检查 ForEach 的第二个参数——key 生成函数:

ForEach(
  dataArray,
  (item, index) => { /* 渲染 */ },
  (item) => item.id   // ← 这个 key 必须唯一且稳定
)

不同场景的 key 选择:

数据类型 推荐 key 原因
string[] item => item 字符串本身唯一
TagItem[] item => item.id.toString() id 唯一且不变
CategoryTag[] item => item.label + item.color 组合唯一性

Q4:如何让标签在点击后有更丰富的动画反馈?

在 ArkTS 中,可以使用 animateTo 为标签切换添加过渡动画:

.onClick(() => {
  animateTo({ duration: 200, curve: Curve.EaseOut }, () => {
    this.staticSelectedIdx = index;
  });
})

虽然本文示例为了保持代码简洁没有添加动画,但在生产环境中推荐至少使用 150~250ms 的 Curve.EaseOut 动画过渡。


八、模型层架构的扩展思考

8.1 数据驱动开发的好处

在这个示例中,模型层被设计为纯数据文件——只有接口、常量和静态数据,没有任何 UI 逻辑。这种设计带来了两个直接好处:

  1. 数据可以单独测试:静态数据可以在 IDE 中独立验证格式
  2. UI 层可以专注呈现:页面代码的职责被简化到"从模型取数据 → 渲染 → 响应交互"

8.2 从静态数据到动态数据

虽然本文示例使用的是静态数据,但在实际应用中,这些数据通常来自网络 API。模型层的设计模式可以平滑升级:

// 模型层只增加接口,不修改已有代码
export interface TagApiResponse {
  code: number;
  data: {
    tags: TagItem[];
    categories: CategoryTag[];
  }
  message: string;
}

// 页面层增加网络请求,已有 @Builder 代码不变
aboutToAppear() {
  fetchTagsFromServer().then((tags) => {
    this.editableTags = tags;
  });
}

8.3 复用组件库的抽象级别

设计中,TagChipDemoCard 是页面级别的可复用组件。如果项目有多个页面都需要标签栏,可以进一步提升抽象层级,将 renderScrollableTags() 这类 @Builder 抽取为独立的 CustomComponent:

@Component
struct ScrollableTagBar {
  private tags: string[] = [];
  @Link selectedIndex: number;
  // ... 标签栏的逻辑全部封装在此
}

九、完整代码清单

文件 1:model/RowTagBarModel.ets(108 行)

// ============================================================
// RowTagBarModel.ets
// 功能:Row 标签栏(Tag Bar)演示的数据模型与常量
// ============================================================

export interface TagItem {
  id: number;
  label: string;
  selected: boolean;
}

export interface CategoryTag {
  label: string;
  color: string;
}

// 色彩常量(暗黑主题)
export const BG_DARK = '#0A0A1A';
export const CARD_BG = '#1A1A2E';
export const CARD_BG2 = '#16213E';
export const TEXT_WHITE = '#FFFFFF';
export const TEXT_GRAY = '#AAAAAA';
export const TEXT_DIM = '#666666';
export const ACCENT_BLUE = '#4D96FF';
export const ACCENT_GREEN = '#6BCB77';
export const ACCENT_ORANGE = '#FFA94D';
export const ACCENT_RED = '#FF6B6B';
export const ACCENT_PURPLE = '#9B59B6';
export const ACCENT_CYAN = '#00D2FF';
export const ACCENT_PINK = '#FF6B9D';
export const ACCENT_YELLOW = '#FFD93D';
export const TAB_DARK = '#FFFFFF12';
export const TAB_BORDER = '#FFFFFF18';

// 静态标签数据
export const STATIC_TAGS: string[] = [
  '全部', '推荐', '科技', '生活', '游戏',
  '动漫', '音乐', '影视', '体育', '美食'
];

export const CATEGORY_TAGS: CategoryTag[] = [
  { label: '🎨 设计', color: ACCENT_PURPLE },
  { label: '⚡ 开发', color: ACCENT_BLUE },
  { label: '📊 产品', color: ACCENT_GREEN },
  { label: '📈 运营', color: ACCENT_ORANGE },
  { label: '🎯 市场', color: ACCENT_RED },
  { label: '📝 内容', color: ACCENT_PINK }
];

export const SCROLL_TAGS: string[] = [
  '热门推荐', '最新发布', '最多点赞', '最多评论',
  '本月热榜', '本周精选', '今日新鲜', '编辑推荐',
  '关注的人', '好友动态', '同城内容', '分类精选',
  '技术前沿', '创意设计', '生活妙招', '旅行攻略',
  '美食探店', '数码评测', '运动健身', '影视娱乐'
];

export const INITIAL_EDITABLE_TAGS: TagItem[] = [
  { id: 1, label: 'TypeScript', selected: false },
  { id: 2, label: 'ArkTS', selected: false },
  { id: 3, label: 'HarmonyOS', selected: false },
  { id: 4, label: 'NEXT', selected: false },
  { id: 5, label: 'API 24', selected: false }
];

export const TAG_POOL: string[] = [
  'JavaScript', 'Python', 'Rust', 'Go', 'Swift',
  'Kotlin', 'Java', 'C++', 'Ruby', 'PHP',
  'Dart', 'Flutter', 'React', 'Vue', 'Angular'
];

export const TECH_TAGS: string[] = [
  '全部', '前端', '后端', '移动端', 'AI/ML', '云原生', '数据库', '安全'
];

export const LIFE_TAGS: string[] = [
  '美食', '旅行', '摄影', '阅读', '运动', '手工', '宠物'
];

文件 2:pages/RowTagBarDemo.ets(757 行)

完整代码基于上述导入逻辑,包含:

  • TagChip 无状态子组件(20 行)
  • DemoCard 插槽式卡片容器(46 行)
  • RowTagBarDemo 主页面:
    • 7 个 @State 变量管理 6 个场景的交互状态
    • build() 全屏 Column + Scroll 布局
    • 6 个 @Builder 方法(每个场景一个)
    • 2 个 private 方法(removeTag / addRandomTag)

十、总结

本文从模型层设计、可复用子组件、@State 状态管理到六大标签栏场景,完整地展现了鸿蒙 ArkTS 中通过 Row 组件实现标签栏的全部技术细节。六个场景覆盖了标签栏在实际开发中的绝大多数需求:

场景 核心能力 适用场景
基础标签栏 Row + ForEach + 条件渲染 内容分类导航
多彩分类标签 动态颜色 + 切换式选中 筛选面板
可滚动标签栏 Scroll + width(‘auto’) 大量标签
大小混合标签 尺寸弹性布局 推荐/热门标签
可删除与添加标签 数组增删 + @State 展开 输入标签组件
交互式标签切换 条件渲染 + 数据驱动 动态筛选器

学到的最重要的三点

  1. Row 是标签栏的基石,配合 Scroll 解决溢出问题
  2. @State + 数组展开是状态管理的核心技巧
  3. 模型与 UI 分离让代码可维护、可扩展

代码已在 HarmonyOS NEXT 6.1.1(API 24)环境下通过编译验证,可直接在 DevEco Studio 中导入运行。希望这篇文章能帮助你在鸿蒙生态中写出更优雅、更高效的标签栏组件。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐