HarmonyOS APP<<古今职鉴定>>开源教程第5篇:列表与滚动:数据展示的核心
本篇学习 List、Grid、Swiper 等组件,实现职官词典列表页
·
本篇学习 List、Grid、Swiper 等组件,实现职官词典列表页
图:古今职鉴开源教程封面。本篇围绕「列表与滚动:数据展示的核心」展开。
学习目标
完成本篇后,你将能够:
- ✅ 掌握 List 列表组件的使用
- ✅ 掌握 Grid 网格组件的使用
- ✅ 掌握 Swiper 轮播组件的使用
- ✅ 理解 ForEach 的 key 生成函数
- ✅ 实现职官词典列表页
预计学习时间
约 90 分钟
---
实战一:创建基础列表页
第一步:创建 lesson05 目录
在 products/jiaocheng/src/main/ets/ 下创建 lesson05 文件夹。
第二步:创建 Lesson05Page.ets 文件
在 lesson05 目录下新建文件 Lesson05Page.ets,输入以下代码:
// 文件路径:products/jiaocheng/src/main/ets/lesson05/Lesson05Page.ets
// 【原理】定义数据接口
// ArkTS 要求所有对象必须有明确的类型,不能使用 any
interface PositionData {
id: number; // 唯一标识,ForEach 需要用它生成 key
name: string; // 官职名称
dynasty: string; // 朝代
description: string; // 描述
}
@Entry
@Component
struct Lesson05Page {
// 【原理】@State 声明响应式数据
// 当 positions 变化时,UI 会自动更新
@State positions: PositionData[] = [
{ id: 1, name: '丞相', dynasty: '秦', description: '百官之长,辅佐皇帝处理政务' },
{ id: 2, name: '太尉', dynasty: '秦', description: '掌管全国军事' },
{ id: 3, name: '御史大夫', dynasty: '秦', description: '监察百官,掌管图籍' },
{ id: 4, name: '大司马', dynasty: '汉', description: '最高军事长官' },
{ id: 5, name: '尚书令', dynasty: '唐', description: '尚书省长官' }
];
build() {
Column() {
// 页面标题
Text('职官词典')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#1e293b')
.padding(16)
.width('100%')
// 【核心】List 列表组件
// List 只渲染可见区域,性能比 Scroll + Column 好
List() {
// 【核心】ForEach 循环渲染
ForEach(this.positions, (item: PositionData) => {
// ListItem 是列表项容器,必须使用
ListItem() {
// 列表项内容
Row() {
// 左侧朝代标签
Text(item.dynasty)
.fontSize(12)
.fontColor(Color.White)
.backgroundColor('#c41e3a')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(4)
// 右侧信息
Column() {
Text(item.name)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#1e293b')
Text(item.description)
.fontSize(13)
.fontColor('#64748b')
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12 })
}
.width('100%')
.padding(16)
.backgroundColor(Color.White)
}
}, (item: PositionData) => item.id.toString())
// ↑【重要】第二个参数是 key 生成函数
// 用于标识每个列表项的唯一性,优化渲染性能
}
.width('100%')
.layoutWeight(1)
// 添加分割线
.divider({
strokeWidth: 1,
color: '#f0f0f0',
startMargin: 16,
endMargin: 16
})
}
.width('100%')
.height('100%')
.backgroundColor('#f8f6f5')
}
}
第三步:理解代码结构
List 组件的作用:
- 只渲染屏幕可见区域的列表项
- 滚动时动态创建/销毁列表项
- 比
Scroll + Column性能更好
ForEach 的两个参数:
ForEach(
数据数组,
(item) => { /* 渲染每一项 */ },
(item) => item.id.toString() // key 生成函数
)
为什么需要 key?
- 数据变化时,框架通过 key 判断哪些项需要更新
- 没有 key 或 key 重复会导致渲染异常
第四步:运行查看效果
hvigorw assembleHap --no-daemon
预期效果:
- 显示 5 条官职数据
- 每条左侧有红色朝代标签
- 列表项之间有分割线
- 可以上下滚动
---
实战二:添加轮播图
第一步:在文件顶部添加轮播数据接口
在 PositionData 接口下方添加:
// 轮播数据接口
interface BannerData {
id: number;
title: string;
subtitle: string;
color: string;
}
第二步:在组件中添加轮播数据
在 @State positions 下方添加:
// 轮播数据
private banners: BannerData[] = [
{ id: 1, title: '秦朝官制', subtitle: '三公九卿制度的起源', color: '#1a1a1a' },
{ id: 2, title: '汉代官制', subtitle: '中央集权的完善', color: '#c41e3a' },
{ id: 3, title: '唐朝官制', subtitle: '三省六部的巅峰', color: '#ffd700' }
];
第三步:添加轮播图 Builder
在 build() 方法之前添加:
// 轮播图组件
@Builder
BannerSection() {
Swiper() {
ForEach(this.banners, (item: BannerData) => {
// 每个轮播项
Stack() {
// 背景
Column()
.width('100%')
.height('100%')
.backgroundColor(item.color)
.borderRadius(12)
// 文字
Column() {
Text(item.title)
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White)
Text(item.subtitle)
.fontSize(14)
.fontColor('rgba(255, 255, 255, 0.8)')
.margin({ top: 8 })
}
.alignItems(HorizontalAlign.Start)
.padding(20)
}
.width('100%')
.height('100%')
.alignContent(Alignment.BottomStart)
})
}
.width('100%')
.height(160)
.padding(16)
.autoPlay(true) // 自动播放
.interval(3000) // 3秒切换
.loop(true) // 循环播放
.indicator( // 指示器样式
new DotIndicator()
.color('rgba(255, 255, 255, 0.5)')
.selectedColor(Color.White)
)
}
第四步:在 List 中使用轮播图
修改 build() 中的 List 部分,在 ForEach 之前添加轮播图:
List() {
// 轮播图作为第一个列表项
ListItem() {
this.BannerSection()
}
// 官职列表
ForEach(this.positions, (item: PositionData) => {
// ... 原有代码不变
})
}
第五步:理解 Swiper 组件
Swiper 常用属性:
| 属性 | 作用 | 示例值 |
|---|---|---|
| autoPlay | 是否自动播放 | true |
| interval | 播放间隔(毫秒) | 3000 |
| loop | 是否循环 | true |
| indicator | 指示器样式 | DotIndicator |
第六步:运行查看效果
hvigorw assembleHap --no-daemon
预期效果:
- 顶部显示轮播图,3秒自动切换
- 底部有白色圆点指示器
- 可以手动左右滑动切换
---
实战三:添加朝代筛选 Tab
第一步:添加筛选相关状态
在组件中添加:
// 当前选中的朝代
@State currentDynasty: string = '全部';
// 朝代列表
private dynastyTabs: string[] = ['全部', '秦', '汉', '唐', '宋', '明', '清'];
第二步:添加筛选 Tab Builder
在 BannerSection() 下方添加:
// 朝代筛选 Tab
@Builder
DynastyTabs() {
// 【原理】Scroll 包裹 Row 实现横向滚动
Scroll() {
Row() {
ForEach(this.dynastyTabs, (dynasty: string) => {
Text(dynasty)
.fontSize(14)
// 选中状态:白字红底,未选中:黑字灰底
.fontColor(this.currentDynasty === dynasty ? Color.White : '#1e293b')
.backgroundColor(this.currentDynasty === dynasty ? '#c41e3a' : '#f0f0f0')
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.borderRadius(16)
.margin({ right: 8 })
.onClick(() => {
// 点击切换选中状态
this.currentDynasty = dynasty;
})
})
}
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
}
.scrollable(ScrollDirection.Horizontal) // 横向滚动
.scrollBar(BarState.Off) // 隐藏滚动条
.width('100%')
.backgroundColor(Color.White)
}
第三步:添加数据筛选逻辑
在组件中添加 getter 计算属性:
// 【原理】getter 计算属性
// 当 currentDynasty 或 positions 变化时,自动重新计算
get filteredPositions(): PositionData[] {
if (this.currentDynasty === '全部') {
return this.positions;
}
return this.positions.filter(p => p.dynasty === this.currentDynasty);
}
第四步:修改 List 使用筛选后的数据
List() {
ListItem() {
this.BannerSection()
}
// 添加筛选 Tab
ListItem() {
this.DynastyTabs()
}
// 使用筛选后的数据
ForEach(this.filteredPositions, (item: PositionData) => {
ListItem() {
// ... 原有卡片代码
}
}, (item: PositionData) => item.id.toString())
}
第五步:运行查看效果
hvigorw assembleHap --no-daemon
预期效果:
- 轮播图下方显示朝代筛选 Tab
- Tab 可以横向滚动
- 点击不同朝代,列表自动筛选
- 选中的 Tab 显示红色背景
---
实战四:优化卡片样式
第一步:将卡片提取为 Builder
在组件中添加:
// 官职卡片
@Builder
PositionCard(item: PositionData) {
Row() {
// 左侧朝代标签
Column() {
Text(item.dynasty)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White)
}
.width(44)
.height(44)
.justifyContent(FlexAlign.Center)
.backgroundColor('#c41e3a')
.borderRadius(8)
// 中间信息
Column() {
Text(item.name)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#1e293b')
Text(item.description)
.fontSize(13)
.fontColor('#64748b')
.margin({ top: 4 })
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12 })
// 右侧箭头
Image($r('app.media.ic_chevron_right'))
.width(20)
.height(20)
.fillColor('#cccccc')
}
.width('100%')
.padding(16)
.margin({ left: 16, right: 16, bottom: 12 })
.backgroundColor(Color.White)
.borderRadius(12)
.shadow({
radius: 4,
color: 'rgba(0, 0, 0, 0.05)',
offsetX: 0,
offsetY: 2
})
.onClick(() => {
console.info(`点击了:${item.name}`);
})
}
第二步:在 ForEach 中使用
ForEach(this.filteredPositions, (item: PositionData) => {
ListItem() {
this.PositionCard(item)
}
}, (item: PositionData) => item.id.toString())
第三步:移除 List 的分割线
因为卡片已经有间距,不需要分割线了:
List() {
// ...
}
.width('100%')
.layoutWeight(1)
// 删除 .divider(...) 这一行
第四步:运行查看效果
hvigorw assembleHap --no-daemon
预期效果:
- 卡片有圆角和阴影
- 卡片之间有间距
- 点击卡片控制台输出日志
---
实战五:添加搜索功能
第一步:添加搜索状态
在组件中添加:
@State searchText: string = '';
第二步:创建搜索栏 Builder
在组件中添加:
@Builder
SearchBar() {
Row() {
// 返回按钮
Image($r('app.media.ic_back'))
.width(24)
.height(24)
.fillColor('#1e293b')
.onClick(() => {
console.log('返回');
})
// 搜索框
Row() {
Image($r('app.media.ic_search'))
.width(20)
.height(20)
.fillColor('#999999')
TextInput({ placeholder: '搜索官职名称', text: this.searchText })
.fontSize(14)
.backgroundColor(Color.Transparent)
.layoutWeight(1)
.onChange((value: string) => {
this.searchText = value;
})
}
.height(40)
.layoutWeight(1)
.margin({ left: 12 })
.padding({ left: 12, right: 12 })
.backgroundColor('#f0f0f0')
.borderRadius(20)
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
.backgroundColor(Color.White)
}
第三步:修改筛选逻辑
更新 filteredPositions getter:
get filteredPositions(): PositionData[] {
let result = this.positions;
// 朝代筛选
if (this.currentDynasty !== '全部') {
result = result.filter(p => p.dynasty === this.currentDynasty);
}
// 搜索筛选
if (this.searchText.length > 0) {
result = result.filter(p =>
p.name.includes(this.searchText) ||
p.description.includes(this.searchText)
);
}
return result;
}
第四步:在 build() 中添加搜索栏
build() {
Column() {
// 搜索栏(固定在顶部)
this.SearchBar()
// 列表
List() {
// ... 原有内容
}
.width('100%')
.layoutWeight(1)
}
.width('100%')
.height('100%')
.backgroundColor('#f8f6f5')
}
第五步:运行查看效果
hvigorw assembleHap --no-daemon
预期效果:
- 顶部显示搜索栏
- 输入文字后列表实时筛选
- 搜索和朝代筛选可以组合使用
---
完整代码
将以上所有实战整合后的完整代码:
// 文件路径:products/jiaocheng/src/main/ets/lesson05/Lesson05Page.ets
// ----- 数据接口定义 -----
interface PositionData {
id: number;
name: string;
dynasty: string;
rank: number;
category: string;
description: string;
}
interface BannerData {
id: number;
title: string;
subtitle: string;
color: string;
}
@Entry
@Component
struct Lesson05Page {
// ----- 状态变量 -----
@State currentDynasty: string = '全部';
@State searchText: string = '';
// ----- 轮播数据 -----
private banners: BannerData[] = [
{ id: 1, title: '秦朝官制', subtitle: '三公九卿制度的起源', color: '#1a1a1a' },
{ id: 2, title: '汉代官制', subtitle: '中央集权的完善', color: '#c41e3a' },
{ id: 3, title: '唐朝官制', subtitle: '三省六部的巅峰', color: '#ffd700' }
];
// ----- 朝代筛选数据 -----
private dynastyTabs: string[] = ['全部', '秦', '汉', '唐', '宋', '明', '清'];
// ----- 官职数据 -----
private allPositions: PositionData[] = [
{ id: 1, name: '丞相', dynasty: '秦', rank: 1, category: '文官', description: '百官之长,辅佐皇帝处理政务' },
{ id: 2, name: '太尉', dynasty: '秦', rank: 1, category: '武官', description: '掌管全国军事' },
{ id: 3, name: '御史大夫', dynasty: '秦', rank: 2, category: '文官', description: '监察百官,掌管图籍' },
{ id: 4, name: '廷尉', dynasty: '秦', rank: 3, category: '文官', description: '掌管司法刑狱' },
{ id: 5, name: '大司马', dynasty: '汉', rank: 1, category: '武官', description: '最高军事长官' },
{ id: 6, name: '大将军', dynasty: '汉', rank: 1, category: '武官', description: '统领全军' },
{ id: 7, name: '太常', dynasty: '汉', rank: 3, category: '文官', description: '掌管宗庙礼仪' },
{ id: 8, name: '尚书令', dynasty: '唐', rank: 2, category: '文官', description: '尚书省长官' },
{ id: 9, name: '中书令', dynasty: '唐', rank: 2, category: '文官', description: '中书省长官' },
{ id: 10, name: '门下侍中', dynasty: '唐', rank: 2, category: '文官', description: '门下省长官' }
];
// ----- 计算属性:筛选后的数据 -----
get filteredPositions(): PositionData[] {
let result = this.allPositions;
if (this.currentDynasty !== '全部') {
result = result.filter(p => p.dynasty === this.currentDynasty);
}
if (this.searchText.length > 0) {
result = result.filter(p =>
p.name.includes(this.searchText) ||
p.description.includes(this.searchText)
);
}
return result;
}
// ----- 构建 UI -----
build() {
Column() {
this.SearchBar()
List() {
ListItem() {
this.BannerSection()
}
ListItem() {
this.DynastyTabs()
}
ForEach(this.filteredPositions, (item: PositionData) => {
ListItem() {
this.PositionCard(item)
}
}, (item: PositionData) => item.id.toString())
}
.width('100%')
.layoutWeight(1)
}
.width('100%')
.height('100%')
.backgroundColor('#f8f6f5')
}
// ========== 搜索栏 ==========
@Builder
SearchBar() {
Row() {
Image($r('app.media.ic_back'))
.width(24)
.height(24)
.fillColor('#1e293b')
.onClick(() => {
console.log('返回');
})
Row() {
Image($r('app.media.ic_search'))
.width(20)
.height(20)
.fillColor('#999999')
TextInput({ placeholder: '搜索官职名称', text: this.searchText })
.fontSize(14)
.backgroundColor(Color.Transparent)
.layoutWeight(1)
.onChange((value: string) => {
this.searchText = value;
})
}
.height(40)
.layoutWeight(1)
.margin({ left: 12 })
.padding({ left: 12, right: 12 })
.backgroundColor('#f0f0f0')
.borderRadius(20)
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
.backgroundColor(Color.White)
}
// ========== 轮播图区域 ==========
@Builder
BannerSection() {
Swiper() {
ForEach(this.banners, (item: BannerData) => {
Stack() {
Column()
.width('100%')
.height('100%')
.backgroundColor(item.color)
.borderRadius(12)
Column() {
Text(item.title)
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White)
Text(item.subtitle)
.fontSize(14)
.fontColor('rgba(255, 255, 255, 0.8)')
.margin({ top: 8 })
}
.alignItems(HorizontalAlign.Start)
.padding(20)
}
.width('100%')
.height('100%')
.alignContent(Alignment.BottomStart)
})
}
.width('100%')
.height(160)
.padding(16)
.autoPlay(true)
.interval(4000)
.loop(true)
.indicator(
new DotIndicator()
.color('rgba(255, 255, 255, 0.5)')
.selectedColor(Color.White)
)
}
// ========== 朝代筛选 Tab ==========
@Builder
DynastyTabs() {
Scroll() {
Row() {
ForEach(this.dynastyTabs, (dynasty: string) => {
Text(dynasty)
.fontSize(14)
.fontColor(this.currentDynasty === dynasty ? Color.White : '#1e293b')
.backgroundColor(this.currentDynasty === dynasty ? '#c41e3a' : '#f0f0f0')
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.borderRadius(16)
.margin({ right: 8 })
.onClick(() => {
this.currentDynasty = dynasty;
})
})
}
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.backgroundColor(Color.White)
}
// ========== 官职卡片 ==========
@Builder
PositionCard(item: PositionData) {
Row() {
Column() {
Text(`${item.rank}品`)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White)
Text(item.category)
.fontSize(10)
.fontColor('rgba(255, 255, 255, 0.8)')
.margin({ top: 4 })
}
.width(50)
.height(50)
.justifyContent(FlexAlign.Center)
.backgroundColor(item.category === '文官' ? '#c41e3a' : '#4169e1')
.borderRadius(8)
Column() {
Row() {
Text(item.name)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#1e293b')
Text(item.dynasty)
.fontSize(12)
.fontColor('#64748b')
.backgroundColor('#f0f0f0')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4)
.margin({ left: 8 })
}
Text(item.description)
.fontSize(13)
.fontColor('#64748b')
.margin({ top: 6 })
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
.layoutWeight(1)
Image($r('app.media.ic_chevron_right'))
.width(20)
.height(20)
.fillColor('#cccccc')
}
.width('100%')
.padding(16)
.margin({ left: 16, right: 16, bottom: 12 })
.backgroundColor(Color.White)
.borderRadius(12)
.shadow({
radius: 4,
color: 'rgba(0, 0, 0, 0.05)',
offsetX: 0,
offsetY: 2
})
.onClick(() => {
console.log(`点击了:${item.name}`);
})
}
}
---
本课小结
核心知识点
| 组件 | 用途 | 关键属性 |
|---|---|---|
| List | 高性能列表 | divider, layoutWeight |
| ListItem | 列表项容器 | 必须包裹列表内容 |
| Swiper | 轮播图 | autoPlay, interval, loop, indicator |
| Scroll | 滚动容器 | scrollable, scrollBar |
| ForEach | 循环渲染 | 数据数组, 渲染函数, key函数 |
ForEach 的 key 函数
ForEach(
数据数组,
(item) => { /* 渲染 */ },
(item) => item.id.toString() // ← key 必须唯一
)
为什么重要:
- 数据变化时,框架通过 key 判断哪些项需要更新
- key 重复会导致渲染异常
- 推荐使用数据的唯一 ID
getter 计算属性
get filteredPositions(): PositionData[] {
// 依赖的状态变化时自动重新计算
return this.positions.filter(...);
}
优势:
- 自动响应依赖变化
- 代码更简洁
- 避免手动调用更新
---
课后练习
练习1:添加 Grid 网格布局
将官职列表改为 2 列网格布局:
Grid() {
ForEach(this.filteredPositions, (item: PositionData) => {
GridItem() {
// 卡片内容
}
})
}
.columnsTemplate('1fr 1fr') // 两列等宽
.rowsGap(12)
.columnsGap(12)
练习2:添加下拉刷新
为 List 添加下拉刷新功能:
Refresh({ refreshing: $$this.isRefreshing }) {
List() {
// ...
}
}
.onRefreshing(() => {
// 模拟加载数据
setTimeout(() => {
this.isRefreshing = false;
}, 1500);
})
练习3:添加空状态
当筛选结果为空时显示提示:
if (this.filteredPositions.length === 0) {
Column() {
Text('暂无数据')
.fontSize(16)
.fontColor('#999999')
}
.width('100%')
.height(200)
.justifyContent(FlexAlign.Center)
}
---
下一课预告
第6课我们将学习布局与样式,包括:
- Flex 布局深度解析
- @Styles 和 @Extend 样式复用
- @Builder 构建函数的高级用法
- 实现更复杂的卡片组件
项目开源地址
更多推荐


所有评论(0)