鸿蒙ArkUI横向可滚动筛选标签列表实现详解


鸿蒙ArkUI横向可滚动筛选标签列表实现详解
一、引言
在移动端和桌面端应用开发中,筛选标签列表是一种极为常见的交互模式。无论是电商平台的商品分类筛选、新闻资讯的话题标签切换,还是社交应用的兴趣分类浏览,横向可滚动的筛选标签列表都能在有限的屏幕空间内高效地展示大量分类选项,并提供直观的选中/未选中视觉反馈,从而显著提升用户体验。
本文将以 HarmonyOS ArkUI(ArkTS)框架为基础,详细讲解如何实现一个功能完整、样式优雅的横向可滚动筛选标签列表,涵盖从设计思路、数据模型定义、自定义组件封装、状态管理,到资源文件配置、样式细节打磨,以及编译验证的全流程。全程不依赖第三方库,完全基于 ArkUI 原生能力实现,适合已在或即将进入鸿蒙生态的开发者参考。
二、技术背景与选型分析
2.1 为什么选择 ArkUI 而非 Flutter
在跨平台开发领域,Flutter 凭借其丰富的组件库和成熟的生态系统占据了重要地位。然而,随着 HarmonyOS 的快速发展,ArkUI 作为原生框架展现出了独特的优势:
- 系统级集成:ArkUI 深度集成于 HarmonyOS 系统,对系统 API 的调用无需桥接层,性能开销更低。
- 声明式语法:ArkTS 基于 TypeScript 扩展,采用声明式 UI 描述,与 Flutter 的声明式 Widget 在设计理念上高度相似,学习曲线平滑。
- 原生渲染:ArkUI 使用系统原生渲染引擎,避免了 Flutter 的自绘引擎带来的包体积增加问题。
- 资源管理:ArkUI 的
$r()资源引用系统支持多维度(颜色、尺寸、字符串)的集中管理,便于主题化和国际化。
2.2 Flutter 组件到 ArkUI 的映射
在本次实现中,我们完成了以下组件映射:
| Flutter 组件 | ArkUI 组件 | 对应关系说明 |
|---|---|---|
ListView(scrollDirection: Axis.horizontal) |
Scroll + Row |
Scroll 提供滚动容器,Row 管理子组件横向排列 |
ChoiceChip / FilterChip |
FilterTagItem(自定义 @Component) |
使用 Text 组件配合样式修饰实现标签样式 |
setState 驱动状态更新 |
@State selectedIndex |
两者均为响应式状态管理机制 |
Theme.of(context).colorScheme |
$r('app.color.xxx') |
资源引用系统管理主题色 |
EdgeInsets |
.padding() / .margin() |
链式调用设置内边距和外边距 |
2.3 设计目标
本次实现的横向可滚动筛选标签列表需要满足以下设计要求:
- 横向滚动:当标签数量超过屏幕宽度时,支持平滑横向滑动浏览。
- 选中态高亮:当前选中的标签使用品牌色填充,文字反白。
- 未选中态清晰:未选中的标签采用浅灰背景 + 深灰文字 + 细边框,视觉层次分明。
- 状态切换:点击任意标签即切换选中状态,并触发回调通知父组件。
- 资源集中管理:所有颜色、尺寸通过资源文件配置,便于后期维护和主题切换。
- 代码可复用:标签组件封装为独立
@Component,可在项目中多处复用。
三、项目结构概览
在开始编码之前,首先了解本项目的目录结构及本次修改涉及的文件:
a14/
├── entry/
│ └── src/
│ └── main/
│ ├── ets/
│ │ └── pages/
│ │ └── Index.ets ← 主页面,包含筛选标签实现
│ └── resources/
│ └── base/
│ └── element/
│ ├── color.json ← 颜色资源文件(已扩展)
│ └── float.json ← 尺寸资源文件(已扩展)
├── build-profile.json5
├── hvigorfile.ts
└── oh-package.json5
其中 Index.ets 是应用的入口页面,color.json 和 float.json 是系统资源文件,分别管理颜色和尺寸常量。
四、数据模型设计
4.1 FilterTag 接口
在 ArkTS 中,我们使用 interface 定义筛选标签的数据结构:
interface FilterTag {
label: string; // 标签显示文本
value: string; // 标签对应的值,用于筛选逻辑
}
这种设计将 UI 展示层与业务逻辑层解耦。label 用于界面渲染,value 用于筛选回调。当用户选择"美食"标签时,父组件通过 value 字段(如 'food')执行数据过滤,而非依赖显示文本,避免了因文案变更导致逻辑错误。
4.2 标签数据实例
在 Index 组件中,我们预定义了 12 个常用的分类标签,覆盖了典型的应用场景:
private tags: FilterTag[] = [
{ label: '全部', value: 'all' },
{ label: '美食', value: 'food' },
{ label: '旅游', value: 'travel' },
{ label: '电影', value: 'movie' },
{ label: '音乐', value: 'music' },
{ label: '阅读', value: 'reading' },
{ label: '运动', value: 'sports' },
{ label: '科技', value: 'tech' },
{ label: '时尚', value: 'fashion' },
{ label: '游戏', value: 'game' },
{ label: '摄影', value: 'photo' },
{ label: '宠物', value: 'pet' }
];
第一个标签"全部"(value: 'all')作为默认选中项,确保页面初始加载时展示全部内容。
五、资源文件配置
5.1 颜色资源(color.json)
ArkUI 的颜色资源文件使用 $r('app.color.xxx') 引用。我们将标签相关的所有颜色整理到 color.json 中,实现了颜色定义的集中管理:
{
"color": [
{ "name": "tag_selected_bg", "value": "#007AFF" },
{ "name": "tag_unselected_bg", "value": "#F5F5F5" },
{ "name": "tag_selected_text", "value": "#FFFFFF" },
{ "name": "tag_unselected_text", "value": "#666666" },
{ "name": "tag_unselected_border", "value": "#E0E0E0" },
{ "name": "page_bg", "value": "#F2F2F2" }
]
}
颜色取值说明:
| 资源名 | 色值 | 用途 |
|---|---|---|
tag_selected_bg |
#007AFF |
选中标签的填充色,经典 iOS 蓝色,视觉聚焦 |
tag_unselected_bg |
#F5F5F5 |
未选中标签的背景色,极浅灰,与页面色调融合 |
tag_selected_text |
#FFFFFF |
选中标签文字色,白色,在蓝色背景上高对比度 |
tag_unselected_text |
#666666 |
未选中标签文字色,中灰,视觉层次适中 |
tag_unselected_border |
#E0E0E0 |
未选中标签边框色,浅灰描边,清晰界定边界 |
page_bg |
#F2F2F2 |
页面整体背景色,略深于纯白,减少视觉疲劳 |
5.2 尺寸资源(float.json)
与颜色类似,我们将尺寸数值统一管理在 float.json 中:
{
"float": [
{ "name": "tag_font_size", "value": "14fp" },
{ "name": "tag_border_radius", "value": "16vp" },
{ "name": "tag_height", "value": "32vp" },
{ "name": "tag_padding_left_right", "value": "16vp" },
{ "name": "tag_margin_right", "value": "8vp" },
{ "name": "tag_list_padding", "value": "12vp" }
]
}
尺寸取值说明:
| 资源名 | 值 | 单位 | 说明 |
|---|---|---|---|
tag_font_size |
14 | fp | 字体大小,fp 跟随系统字体缩放 |
tag_border_radius |
16 | vp | 圆角半径,实现胶囊形标签 |
tag_height |
32 | vp | 标签高度,适合单行文字舒适展示 |
tag_padding_left_right |
16 | vp | 左右内边距,确保文字与边缘有足够间距 |
tag_margin_right |
8 | vp | 标签间距,避免标签之间紧贴 |
tag_list_padding |
12 | vp | 容器的左右安全边距 |
使用 fp(Font-size Proportional)作为字体单位,可以跟随系统字体缩放设置自动调整,满足无障碍访问需求。使用 vp(Virtual Pixel)作为布局单位,保证在不同屏幕密度下显示效果一致。
六、核心组件实现
6.1 FilterTagItem 组件
FilterTagItem 是本实现的核心自定义组件,它封装了单个标签的完整 UI 和行为逻辑:
@Component
struct FilterTagItem {
@Prop label: string = '';
@Prop isSelected: boolean = false;
onTagClick?: () => void;
build() {
Text(this.label)
.fontSize($r('app.float.tag_font_size'))
.fontColor(this.isSelected
? $r('app.color.tag_selected_text')
: $r('app.color.tag_unselected_text'))
.backgroundColor(this.isSelected
? $r('app.color.tag_selected_bg')
: $r('app.color.tag_unselected_bg'))
.borderRadius($r('app.float.tag_border_radius'))
.height($r('app.float.tag_height'))
.padding({
left: $r('app.float.tag_padding_left_right'),
right: $r('app.float.tag_padding_left_right')
})
.margin({ right: $r('app.float.tag_margin_right') })
.border({
width: this.isSelected ? 0 : 1,
color: $r('app.color.tag_unselected_border'),
style: BorderStyle.Solid
})
.onClick(() => {
this.onTagClick?.();
})
}
}
组件属性详解
@Prop label:标签的显示文本,由父组件传入。@Prop装饰器表示该属性支持从父组件单向数据绑定,当父组件重新渲染时更新子组件。@Prop isSelected:布尔值,控制标签的选中状态。@Prop属性一旦在子组件内部初始化后,后续由父组件驱动的更新会覆盖子组件内的局部赋值。onTagClick:点击回调函数,类型为可选函数() => void。这里需要注意属性命名不能使用onClick,因为onClick是 ArkUICommonAttribute内置的保留方法名,直接使用会导致编译错误。
选中/未选中样式对比
标签的样式完全由 isSelected 属性驱动,通过三元表达式选择不同的资源引用:
| 样式属性 | 选中态 | 未选中态 |
|---|---|---|
| 文字颜色 | #FFFFFF 白色 |
#666666 深灰 |
| 背景颜色 | #007AFF 蓝色 |
#F5F5F5 浅灰 |
| 边框 | 无边框(width: 0) |
#E0E0E0 1px 实线边框 |
这种设计遵循了 Material Design 的"选中填充、未选中描边"原则,用户通过视觉对比即可快速识别当前筛选状态。
6.2 父组件 Index
Index 是整个页面的入口组件,负责管理标签列表的数据、选中状态,以及布局结构:
@Entry
@Component
struct Index {
@State selectedIndex: number = 0;
private tags: FilterTag[] = [ /* 12个标签数据 */ ];
build() {
Column() {
// --- 横向滚动筛选标签列表 ---
Scroll() {
Row() {
ForEach(this.tags, (item: FilterTag, index: number) => {
FilterTagItem({
label: item.label,
isSelected: index === this.selectedIndex,
onTagClick: () => {
this.selectedIndex = index;
}
})
}, (item: FilterTag): string => item.value)
}
.padding({ left: $r('app.float.tag_list_padding'),
right: $r('app.float.tag_list_padding') })
.alignItems(VerticalAlign.Center)
.height('100%')
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.clip(false)
.height(48)
.width('100%')
// --- 分隔线 ---
Divider()
.strokeWidth(0.5)
.color('#E8E8E8')
.width('100%')
// --- 内容展示区域 ---
Column() {
Text('当前筛选: ' + this.tags[this.selectedIndex].label)
.fontSize(18)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
.textAlign(TextAlign.Center)
.width('100%')
.margin({ top: 60 })
Text('分类: ' + this.tags[this.selectedIndex].value)
.fontSize(14)
.fontColor('#999999')
.textAlign(TextAlign.Center)
.width('100%')
.margin({ top: 12 })
}
.width('100%')
.layoutWeight(1)
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.page_bg'))
}
}
状态管理机制
@State selectedIndex 是 ArkUI 的响应式状态变量。当用户点击某个标签时,onTagClick 回调将 this.selectedIndex 更新为当前点击的标签索引,ArkUI 框架自动检测到状态变化,重新渲染所有依赖该状态的组件——即所有 FilterTagItem 的 isSelected 属性都会重新计算并更新 UI。
状态流转图:
用户点击 → 触发 onTagClick(index) → this.selectedIndex = index
→ 框架检测到 @State 变化
→ 重新执行 build() 渲染
→ 每个 FilterTagItem 收到新的 isSelected 属性
→ 仅选中/未选中状态变化的标签更新 UI
ForEach 的 key 生成
ForEach(this.tags, ..., (item: FilterTag): string => item.value)
第三个参数是 keyGenerator 函数,返回每个项的唯一标识符。使用 item.value 而非 item.label 或默认索引,有以下优势:
- 稳定性:即使标签的显示文本发生变化,
value不变,框架能正确复用已有组件实例。 - 高效性:ArkUI 通过 key 对比新旧列表,最小化 DOM 操作,仅插入/删除/移动真正变化的项。
- 可预测性:当列表数据重新排序时,key 确保每个组件实例与正确的数据关联。
Scroll 容器配置详解
Scroll() {
Row() { ... }
}
.scrollable(ScrollDirection.Horizontal) // 启用水平滚动
.scrollBar(BarState.Off) // 隐藏滚动条,保持界面清爽
.clip(false) // 不裁剪溢出内容,允许边缘标签显示完整
.height(48) // 限制滚动区域高度
.width('100%') // 撑满父容器宽度
scrollable(ScrollDirection.Horizontal):指定滚动方向为水平,这是实现横向滚动的关键 API。scrollBar(BarState.Off):隐藏滚动条,使标签列表看起来更自然,用户通过滑动交互即可感知可滚动性。clip(false):默认情况下,Scroll会裁剪子组件溢出部分。设置为false后,当第一个标签或最后一个标签处于边缘时,其圆角部分不会被裁剪,视觉上更完整。.height(48):为滚动区域设置固定高度,避免Row自动撑高导致布局异常。
七、Divider 分隔线
在标签列表和内容区域之间,我们使用 Divider 组件添加一条细分隔线:
Divider()
.strokeWidth(0.5)
.color('#E8E8E8')
.width('100%')
strokeWidth: 0.5:极细的分隔线,不喧宾夺主。color: '#E8E8E8':浅灰色,与页面背景和标签样式和谐统一。width: '100%':撑满父容器宽度,形成视觉分割。
八、内容展示区域
为了演示筛选标签的交互效果,我们在分隔线下方添加了一个简单的展示区域:
Column() {
Text('当前筛选: ' + this.tags[this.selectedIndex].label)
.fontSize(18)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
.textAlign(TextAlign.Center)
.width('100%')
.margin({ top: 60 })
Text('分类: ' + this.tags[this.selectedIndex].value)
.fontSize(14)
.fontColor('#999999')
.textAlign(TextAlign.Center)
.width('100%')
.margin({ top: 12 })
}
.width('100%')
.layoutWeight(1)
- 第一行显示当前选中标签的中文名称(label),使用大号字体和深色。
- 第二行显示对应的英文标识(value),使用小号字体和浅色,形成信息层级。
.layoutWeight(1):让内容区域占据剩余空间,确保标签列表始终固定在顶部。
在实际项目中,此区域通常替换为 List 或 Grid 组件,根据 selectedIndex 或 tags[selectedIndex].value 动态加载对应分类的数据。
九、编译验证与问题排查
9.1 编译运行
在 DevEco Studio 中,项目通过以下命令进行编译验证:
hvigorw assembleHap --no-daemon
9.2 常见错误及解决方案
错误一:onClick 属性命名冲突
ERROR: Property 'onClick' in type 'FilterTagItem' is not assignable
to the same property in base type 'CustomComponent'.
原因:ArkUI 的 CustomComponent 基类已经定义了 onClick 属性(作为 CommonAttribute 的一部分),子组件 FilterTagItem 不能再声明同名属性。
解决方案:将自定义属性重命名为 onTagClick,避免与内置 API 冲突。
错误二:ForEach 缺少 keyGenerator
原因:ForEach 的前两个参数分别是数据源和内容生成器,若不提供第三个参数(keyGenerator),ArkUI 将使用默认索引作为 key,可能导致列表更新时的渲染异常。
解决方案:始终提供稳定的 keyGenerator 函数,优先使用数据的唯一标识字段。
错误三:@Prop 类型不匹配
原因:父组件传入 @Prop 的值类型与子组件声明不一致。
解决方案:确保 @Prop 声明的类型(包括默认值)与传入值类型完全一致。
9.3 编译结果验证
通过编译后的输出日志可以确认构建成功:
> hvigor Finished :entry:default@CompileArkTS... after 5 s 269 ms
> hvigor Finished :entry:default@PackageHap... after 384 ms
> hvigor BUILD SUCCESSFUL in 13 s 68 ms
其中 CompileArkTS 阶段耗时约 5 秒,进行了完整的 ArkTS 类型检查和代码生成。PackageHap 阶段将编译产物打包为 HAP 安装包,耗时约 384 毫秒。
十、性能优化与最佳实践
10.1 避免不必要的重复渲染
@State 变量变化会触发整个 build() 方法的重新执行。如果标签列表非常长(例如超过 50 个),可以考虑以下优化策略:
- 使用
LazyForEach:对于超长列表,使用LazyForEach替代ForEach,实现按需渲染,避免一次性创建所有组件实例。 - 拆分组件粒度:将内容展示区域与标签列表拆分为独立的
@Component,减少状态变化时的渲染范围。
10.2 资源引用的优势
使用 $r('app.color.xxx') 和 $r('app.float.xxx') 引用资源,而非硬编码数值,有以下好处:
- 主题切换:通过替换资源文件即可实现深色模式/主题切换,无需修改业务代码。
- 多设备适配:可以在不同设备上配置不同的
float.json,实现自适应布局。 - 集中维护:设计师调整色值或间距时,仅需修改资源文件,不影响代码逻辑。
10.3 无障碍访问
- 使用
fp单位确保字体随系统缩放设置自动调整。 - 为标签添加合适的
accessibilityText和accessibilityLevel属性,提升屏幕阅读器体验。 - 选中态与未选中态的色差对比度建议保持在 4.5:1 以上,符合 WCAG AA 标准。
10.4 扩展建议
多选模式
如果需要支持多选筛选(例如筛选多个标签),可以将选中状态管理从单一索引改为数组:
@State selectedIndices: number[] = [];
// 切换选中状态
toggleSelection(index: number) {
const idx = this.selectedIndices.indexOf(index);
if (idx >= 0) {
this.selectedIndices.splice(idx, 1);
} else {
this.selectedIndices.push(index);
}
}
标签动态加载
在实际业务中,标签数据通常来自网络请求。可以使用 @State 管理异步数据:
@State tags: FilterTag[] = [];
aboutToAppear() {
this.loadTags();
}
async loadTags() {
const response = await fetch('https://api.example.com/tags');
this.tags = response.data;
}
动画过渡
为标签切换添加动画效果,提升交互流畅度。可以在 FilterTagItem 中添加 .transition() 或使用 animateTo:
.onClick(() => {
animateTo({ duration: 200, curve: Curve.EaseInOut }, () => {
this.onTagClick?.();
});
})
十一、Flutter 开发者迁移指南
对于从 Flutter 迁移到 ArkUI 的开发者,以下对照表可以帮助快速上手:
| Flutter 概念 | ArkUI 对应概念 | 关键差异 |
|---|---|---|
Widget |
@Component |
都是可组合的 UI 单元,ArkUI 使用装饰器声明 |
StatefulWidget |
@State + @Prop |
ArkUI 的响应式状态管理更简洁,无需手动区分 Stateful/Stateless |
build() |
build() |
方法名相同,但 ArkUI 在 build() 中直接调用链式 API |
setState() |
自动触发 | ArkUI 的 @State 变量赋值后自动触发重建 |
Theme.of(context) |
$r('app.color.xxx') |
资源引用系统更接近原生 Android 的 Resource 机制 |
EdgeInsets.all(8) |
.padding(8) |
链式调用,更接近 SwiftUI 风格 |
ListView.builder |
ForEach / LazyForEach |
LazyForEach 对应懒加载,ForEach 对应全量渲染 |
Color(0xFF007AFF) |
$r('app.color.tag_selected_bg') |
推荐使用资源引用而非硬编码 |
BorderRadius.circular(16) |
.borderRadius(16) |
参数名类似,但 ArkUI 的 borderRadius 统一为属性 |
十二、完整代码清单
以下是最终完整的 Index.ets 文件内容:
// 筛选标签数据模型
interface FilterTag {
label: string;
value: string;
}
@Component
struct FilterTagItem {
@Prop label: string = '';
@Prop isSelected: boolean = false;
onTagClick?: () => void;
build() {
Text(this.label)
.fontSize($r('app.float.tag_font_size'))
.fontColor(this.isSelected
? $r('app.color.tag_selected_text')
: $r('app.color.tag_unselected_text'))
.backgroundColor(this.isSelected
? $r('app.color.tag_selected_bg')
: $r('app.color.tag_unselected_bg'))
.borderRadius($r('app.float.tag_border_radius'))
.height($r('app.float.tag_height'))
.padding({
left: $r('app.float.tag_padding_left_right'),
right: $r('app.float.tag_padding_left_right')
})
.margin({ right: $r('app.float.tag_margin_right') })
.border({
width: this.isSelected ? 0 : 1,
color: $r('app.color.tag_unselected_border'),
style: BorderStyle.Solid
})
.onClick(() => {
this.onTagClick?.();
})
}
}
@Entry
@Component
struct Index {
@State selectedIndex: number = 0;
private tags: FilterTag[] = [
{ label: '全部', value: 'all' },
{ label: '美食', value: 'food' },
{ label: '旅游', value: 'travel' },
{ label: '电影', value: 'movie' },
{ label: '音乐', value: 'music' },
{ label: '阅读', value: 'reading' },
{ label: '运动', value: 'sports' },
{ label: '科技', value: 'tech' },
{ label: '时尚', value: 'fashion' },
{ label: '游戏', value: 'game' },
{ label: '摄影', value: 'photo' },
{ label: '宠物', value: 'pet' }
];
build() {
Column() {
// --- 横向滚动筛选标签列表 ---
Scroll() {
Row() {
ForEach(this.tags, (item: FilterTag, index: number) => {
FilterTagItem({
label: item.label,
isSelected: index === this.selectedIndex,
onTagClick: () => {
this.selectedIndex = index;
}
})
}, (item: FilterTag): string => item.value)
}
.padding({
left: $r('app.float.tag_list_padding'),
right: $r('app.float.tag_list_padding')
})
.alignItems(VerticalAlign.Center)
.height('100%')
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.clip(false)
.height(48)
.width('100%')
// --- 分隔线 ---
Divider()
.strokeWidth(0.5)
.color('#E8E8E8')
.width('100%')
// --- 内容展示区域 ---
Column() {
Text('当前筛选: ' + this.tags[this.selectedIndex].label)
.fontSize(18)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
.textAlign(TextAlign.Center)
.width('100%')
.margin({ top: 60 })
Text('分类: ' + this.tags[this.selectedIndex].value)
.fontSize(14)
.fontColor('#999999')
.textAlign(TextAlign.Center)
.width('100%')
.margin({ top: 12 })
}
.width('100%')
.layoutWeight(1)
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.page_bg'))
}
}
总结
本文详细介绍了如何在 HarmonyOS ArkUI 框架中实现一个横向可滚动筛选标签列表。从数据模型设计、资源文件配置、自定义组件封装,到状态管理、布局编排和编译验证,涵盖了完整的开发流程。
核心要点回顾:
- 组件映射:Flutter 的
ListView+ChoiceChip对应 ArkUI 的Scroll+Row+ 自定义@Component。 - 状态管理:
@State selectedIndex驱动标签选中态,@Prop isSelected实现单向数据流。 - 资源管理:通过
color.json和float.json集中管理颜色和尺寸,便于维护和主题化。 - 命名规避:自定义属性避免使用
onClick等内置保留名称。 - 编译验证:使用
hvigorw assembleHap进行完整编译,确保类型安全和构建正确。
该实现方案已在 HarmonyOS 环境中完成编译验证,可作为生产级代码直接使用。开发者可根据实际业务需求,在本文基础上扩展多选模式、异步数据加载、动画过渡等高级功能,构建更丰富的筛选交互体验。
更多推荐




所有评论(0)