鸿蒙 ArkTS 布局精讲——Row 实现标签栏(Tag Bar)的完整实践



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:
- 接口定义:
TagItem(标签数据模型)、CategoryTag(多彩标签模型) - 色彩常量:17 个暗黑主题颜色常量
- 静态数据: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 })
}
}
技术要点:
-
ForEach的 key 生成:第二个参数(item: string) => item是为每个标签生成唯一 key,ArkTS 用 key 来优化列表 diff。在 string[] 场景下直接用字符串本身作为 key 即可。 -
条件渲染选中态:通过
this.staticSelectedIdx === index比较,为当前选中的标签应用完全不同的样式——背景色、文字颜色、字重、边框全部同步变化。 -
padding(0)消除 Row 的默认内边距:Row 默认有 4vp 左右的 padding(不同主题略有差异),显式设为 0 让标签贴边排列。 -
实心 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 })
}
}
可滚动的实现条件(严格满足三条):
- Scroll 包裹 Row:
Scroll()作为外层容器,Row()作为内层 - Row 宽度设为 ‘auto’:这是最关键也最容易遗漏的一步。Row 的
width('auto')让其宽度自适应所有子标签的总和,从而"撑开"超过父容器 Scroll 的宽度 - 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。
技术标签与生活标签的差异:两个标签列表的选中索引是独立的(techSelectedIdx 和 lifeSelectedIdx),切换分类时不会互相干扰。
技术标签渲染:
@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 变量的类型:
- 基本类型(
number、string、boolean):直接赋值即可触发更新 - 数组(如
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 逻辑。这种设计带来了两个直接好处:
- 数据可以单独测试:静态数据可以在 IDE 中独立验证格式
- 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 复用组件库的抽象级别
设计中,TagChip 和 DemoCard 是页面级别的可复用组件。如果项目有多个页面都需要标签栏,可以进一步提升抽象层级,将 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 展开 | 输入标签组件 |
| 交互式标签切换 | 条件渲染 + 数据驱动 | 动态筛选器 |
学到的最重要的三点:
- Row 是标签栏的基石,配合
Scroll解决溢出问题 - @State + 数组展开是状态管理的核心技巧
- 模型与 UI 分离让代码可维护、可扩展
代码已在 HarmonyOS NEXT 6.1.1(API 24)环境下通过编译验证,可直接在 DevEco Studio 中导入运行。希望这篇文章能帮助你在鸿蒙生态中写出更优雅、更高效的标签栏组件。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)