跳到主要内容

第七章:列表与渲染控制

本章配套代码:code/Chapter7(ForEach + 条件渲染 Demo + 二级联动实战 + 仿抖音滑动案例) 前置要求:已完成状态管理与路由章节

7.1 学习目标

  • 掌握 ForEach 列表渲染与 key 生成函数
  • 掌握 Repeat 状态化列表渲染与对比选型
  • 掌握 LazyForEach 数据懒加载(大数据量列表)
  • 掌握 if/else 条件渲染
  • 理解数组更新触发刷新的正确方式
  • 了解 List 懒加载与分页加载
  • 掌握 List + ListItemGroup 实现二级联动(实战案例)
  • 掌握 PanGesture 实现上下滑动切换(仿抖音实战案例)

7.2 ForEach 列表渲染

7.2.1 基本用法

ForEach 遍历数组生成多个 UI 组件:

@Entry
@ComponentV2
struct ForEachExample {
@Local items: string[] = ['床前明月光', '疑是地上霜', '举头望明月']

build() {
Column() {
ForEach(this.items, (item: string, index: number) => {
Text(`${index + 1}. ${item}`)
.fontSize(16)
.padding(8)
})
}
}
}

语法:

ForEach(
数组, // ① 数据源
(item: T, index: number) => { }, // ② 生成 UI 的构建函数
(item: T) => string // ③ key 生成函数(可选)
)

7.2.2 key 生成函数

对象数组务必提供 key 生成函数,用于高效识别列表项:

interface Product {
id: number
name: string
price: number
}

@Entry
@ComponentV2
struct ProductList {
@Local products: Product[] = [
{ id: 1, name: '手机', price: 3999 },
{ id: 2, name: '耳机', price: 299 }
]

build() {
List() {
ForEach(
this.products,
(item: Product) => {
ListItem() {
Text(`${item.name}: ¥${item.price}`)
.padding(12)
}
},
(item: Product) => item.id.toString() // 用唯一 id 作 key
)
}
}
}

🔑 key 的作用:帮助框架最小化 DOM 更新。key 唯一稳定(用 id 而非 index),列表增删时性能最佳。

key 选择推荐度说明
唯一 id✅ 推荐item.id
index⚠️ 避免增删时 key 错位,复用混乱
拼接字段视情况需保证唯一

7.2.3 数组更新方式(重要)

V2 中数组更新必须重新赋值才触发刷新:

// ✅ 正确:重新赋值
this.items = [...this.items, newItem] // 添加
this.items = this.items.filter((_, i) => i !== idx) // 删除
this.items = this.items.map(p => p.id === id ? { ...p, price: p.price + 100 } : p) // 修改

// ❌ 错误:原地修改不触发 UI 刷新
this.items.push(newItem)
this.items.splice(idx, 1)

7.3 条件渲染

7.3.1 if / else

@Entry
@ComponentV2
struct ConditionalExample {
@Local isLoggedIn: boolean = false
@Local userName: string = '张三'

build() {
Column({ space: 16 }) {
if (this.isLoggedIn) {
Text(`欢迎, ${this.userName}`)
.fontSize(20)
Button('退出')
.onClick(() => { this.isLoggedIn = false })
} else {
Text('请登录')
.fontSize(20)
Button('登录')
.onClick(() => { this.isLoggedIn = true })
}
}
.padding(16)
}
}

7.3.2 多条件分支

if (this.userRole === 'admin') {
Text('管理员面板').fontColor(Color.Red)
} else if (this.userRole === 'user') {
Text('用户面板').fontColor(Color.Blue)
} else {
Text('访客面板').fontColor(Color.Gray)
}

7.4 List 懒加载

7.4.1 List 基础

List + ListItem 实现滚动列表,仅渲染可见项(懒加载),适合大量数据:

@Entry
@ComponentV2
struct ListExample {
@Local items: string[] = ['床前明月光', '疑是地上霜', '举头望明月', '低头思故乡', '海内存知己', '天涯若比邻', '欲穷千里目', '更上一层楼', '春眠不觉晓', '处处闻啼鸟'] // ① 数据源

build() {
List({ space: 8 }) { // space:列表项间距
ForEach(this.items, (item: string) => {
ListItem() { // 每个列表项
Text(item)
.height(60)
}
})
}
}
}
组件作用
List滚动容器(支持垂直/水平)
ListItem单个列表项
ForEach遍历数据生成列表项

7.4.2 分页加载(onReachEnd)

滚动到底部自动加载更多:

@Entry
@ComponentV2
struct PaginationExample {
@Local items: number[] = []
@Local currentPage: number = 1
@Local isLoading: boolean = false
@Local hasMore: boolean = true

aboutToAppear(): void {
this.loadData()
}

async loadData() {
if (this.isLoading || !this.hasMore) return
this.isLoading = true

await new Promise(resolve => setTimeout(resolve, 1000)) // 模拟请求

const newItems = Array.from(
{ length: 20 },
(_, i) => (this.currentPage - 1) * 20 + i + 1
)
this.items = [...this.items, ...newItems] // 重新赋值追加
this.currentPage++
this.hasMore = this.currentPage <= 5
this.isLoading = false
}

build() {
List() {
ForEach(this.items, (item: number) => {
ListItem() {
Text(`Item ${item}`)
.width('100%')
.height(60)
.textAlign(TextAlign.Center)
}
})

if (this.isLoading) {
ListItem() {
LoadingProgress()
.width(40).height(40)
}
}
}
.onReachEnd(() => { // 滚动到底触发
this.loadData()
})
}
}

💡 onReachEnd 是 List 触底回调,是无限滚动/分页的标配。

7.4.3 实战案例:二级联动(List + ListItemGroup)

本案例来自华为官方 Codelabs《基于List组件实现二级联动效果》,演示左侧导航与右侧内容的二级联动:

  • 切换左侧导航 → 右侧滚动到对应内容分组;
  • 滚动右侧内容 → 左侧切换对应导航的选中态。

相关概念:

组件 / API作用
List列表容器,scroller 参数绑定 Scroller 对象实现编程式滚动
ListItemGroup列表分组,header 设置分组头(配合 sticky 实现吸顶)
scrollToIndex()滚动到指定索引的列表项
onScrollIndex()监听当前滚动到的首项索引
StickyStyle.Header分组头吸顶,滚动时分组标题固定

代码结构:

entry/src/main/ets
├── common/constants/Constants.ets // 常量类
├── pages/IndexPage.ets // 二级联动页面入口
├── view
│ ├── ClassityItem.ets // 左侧导航项组件
│ └── CourseItem.ets // 右侧课程卡片组件
└── viewmodel
├── ClassifyModel.ets // 导航分类 Model
├── ClassifyViewModel.ets // 导航数据 ViewModel
├── CourseModel.ets // 课程 Model
└── LinkDataModel.ets // 原始数据 Model

联动核心逻辑(IndexPage.ets):

// 联动入口:isClassify 区分「点导航」还是「滚内容」
classifyChangeAction(index: number, isClassify: boolean): void {
if (this.currentClassify !== index) {
this.currentClassify = index
if (isClassify) {
this.scroller.scrollToIndex(index) // 点导航 → 滚动右侧内容
} else {
this.classifyScroller.scrollToIndex(index) // 滚内容 → 滚动左侧导航
}
}
}

build() {
Row() {
// 左侧导航 List(固定宽度)
List({ scroller: this.classifyScroller }) {
ForEach(this.classifyList, (item: ClassifyModel, index?: number) => {
ListItem() {
ClassifyItem({
classifyName: item.classifyName,
isSelected: this.currentClassify === index,
onClickAction: () => {
if (index !== undefined) {
this.classifyChangeAction(index, true)
}
}
})
}
}, (item: ClassifyModel) => item.classifyName.toString() + this.currentClassify)
}
.width($r('app.float.classify_item_width'))
.backgroundColor($r('app.color.classify_background'))
.scrollBar(BarState.Off)

// 右侧内容 List(ListItemGroup 分组 + 吸顶 header)
List({ scroller: this.scroller }) {
ForEach(this.classifyList, (classifyItem: ClassifyModel) => {
ListItemGroup({
header: this.ClassifyHeader(classifyItem.classifyName),
space: Constants.COURSE_ITEM_PADDING
}) {
ForEach(classifyItem.courseList, (courseItem: CourseModel) => {
ListItem() {
CourseItem({ itemStr: JSON.stringify(courseItem) })
}
}, (courseItem: CourseModel) => `${courseItem.courseId}`)
}
}, (item: ClassifyModel) => `${item.classifyId}`)
}
.sticky(StickyStyle.Header) // 分组头吸顶
.onScrollIndex((start: number) => this.classifyChangeAction(start, false))
.layoutWeight(1)
.edgeEffect(EdgeEffect.None)
}
}

联动原理:

  1. 点导航:点击左侧导航项 → classifyChangeAction(index, true)scroller.scrollToIndex(index) 让右侧滚动到对应分组,并更新导航选中态。
  2. 滚内容:滚动右侧列表 → onScrollIndex(start) 回调当前首项所在分组索引 → classifyChangeAction(start, false) 更新左侧选中态并滚动左侧导航。

运行效果:

二级联动:默认选中「热门课程」

二级联动:点击「HarmonyOS」后联动切换

💡 二级联动是 List + ListItemGroup + Scroller 编程式滚动的经典应用,常用于电商分类、课程目录、通讯录等场景。

7.5 Repeat 列表渲染

7.5.1 基本用法

RepeatV2 推荐的列表渲染方案,按 key 复用已有子组件(组件级渲染),数据频繁变化时性能优于 ForEach:

@Entry
@ComponentV2
struct RepeatExample {
@Local items: string[] = ['春眠不觉晓', '处处闻啼鸟', '夜来风雨声', '花落知多少'] // ① 数据源

build() {
List() {
Repeat(this.items, (item: string) => item) { // ② key 生成函数(第二参数)
(item: string, index: number) => { // ③ 构建函数
ListItem() {
Text(`${index}: ${item}`)
.width('100%')
.height(60)
.textAlign(TextAlign.Center)
}
}
}
}
}
}

语法:

Repeat(
数组, // ① 数据源
(item, index) => string, // ② key 生成函数(可选,默认按索引)
template? // ③ 虚拟滚动模板(实验特性,暂不用)
) {
(item: T, index: number) => { } // ④ 生成 UI 的构建函数
}

7.5.2 ForEach vs Repeat

维度ForEachRepeat
渲染方式函数式:按 key 重建子组件组件式:key 级复用已有子组件
子组件内部状态无状态,重建即丢失有状态,可保留内部状态
key 位置第三参数第二参数
性能中小数据量够用频繁增删改时更优
官方建议简单静态列表数据动态变化、需状态保持的列表

🔑 选型口诀:静态列表用 ForEach;动态增删改、需要组件状态复用用 Repeat

7.6 数据懒加载 LazyForEach

7.6.1 为什么需要 LazyForEach

List 只渲染可视区域的项(容器级懒加载),但 ForEach/Repeat 会为全部数据创建并持有 UI 描述,数据到万级时依然卡顿。

LazyForEach 由数据源按需提供数据,配合 List/Grid 等滚动容器,只在项进入可视区域时才创建组件,实现大数据量流畅渲染。

⚠️ LazyForEach 只能在滚动容器List/Grid/WaterFlow 等)内使用。

7.6.2 数据源(IDataSource)

LazyForEach 的第一个参数必须是实现了 IDataSource 的数据源对象:

import { IDataSource, DataChangeListener } from '@kit.ArkUI'

// ① 基础数据源:实现 IDataSource 四个方法
class BasicDataSource implements IDataSource {
private listeners: DataChangeListener[] = []

public totalCount(): number {
return 0
}

public getData(index: number): any {
return undefined
}

registerDataChangeListener(listener: DataChangeListener): void {
if (this.listeners.indexOf(listener) < 0) {
this.listeners.push(listener)
}
}

unregisterDataChangeListener(listener: DataChangeListener): void {
const pos = this.listeners.indexOf(listener)
if (pos >= 0) {
this.listeners.splice(pos, 1)
}
}

protected notifyDataAdd(index: number): void {
this.listeners.forEach(listener => listener.onDataAdded(index))
}
}

// ② 业务数据源:实现 totalCount / getData,增删时通知框架
class NumberDataSource extends BasicDataSource {
private numbers: number[] = []

constructor(count: number) {
super()
this.numbers = Array.from({ length: count }, (_, i) => i + 1)
}

public totalCount(): number {
return this.numbers.length
}

public getData(index: number): number {
return this.numbers[index]
}

addItem(): void {
this.numbers.push(this.numbers.length + 1)
this.notifyDataAdd(this.numbers.length - 1) // 通知框架在末尾新增一条
}
}

7.6.3 完整示例

@Entry
@ComponentV2
struct LazyForEachExample {
@Local dataSource: NumberDataSource = new NumberDataSource(10000) // ① 1 万条数据

build() {
Column({ space: 8 }) {
Button('新增一条')
.width('90%')
.onClick(() => this.dataSource.addItem())

List() {
LazyForEach(
this.dataSource, // ① 数据源(IDataSource 实现)
(item: number) => {
ListItem() {
Text(`Item ${item}`)
.width('100%')
.height(60)
.textAlign(TextAlign.Center)
}
},
(item: number) => item.toString() // ③ key(建议提供,保证唯一)
)
}
.layoutWeight(1)
}
.padding(16)
}
}

💡 新增数据后调用数据源的 notifyDataAdd(底层触发 onDataAdded)通知框架增量刷新,避免重建整个列表

7.6.4 三种渲染方案选型

方案数据量特点适用
ForEach中小简单直观、无状态静态/中小列表
Repeat中~较大组件复用、状态保持频繁增删改的列表
LazyForEach大(万级+)按需渲染、仅限滚动容器长列表/瀑布流

7.7 综合示例

对应 code/Chapter7Index.ets,包含三个区块:

  1. ForEach 增删:输入添加诗句,点击删除移除,序号自动更新
  2. key 对象列表:商品列表,点击 +100 动态涨价
  3. 条件渲染:未登录显示"登录"按钮,登录后显示欢迎语与退出
@Entry
@ComponentV2
struct Index {
@Local items: string[] = ['白日依山尽', '黄河入海流', '欲穷千里目', '更上一层楼', '桃花潭水深千尺']
@Local products: Product[] = [
{ id: 1, name: '手机', price: 3999 },
{ id: 2, name: '耳机', price: 299 }
]
@Local newItem: string = ''
@Local isLoggedIn: boolean = false

addItem() {
if (this.newItem) {
this.items = [...this.items, this.newItem] // 重新赋值
this.newItem = ''
}
}

removeItem(index: number) {
this.items = this.items.filter((_, i) => i !== index)
}

increasePrice(id: number) {
this.products = this.products.map(p =>
p.id === id ? { ...p, price: p.price + 100 } : p
)
}

build() {
Scroll() {
Column({ space: 16 }) {
Text('Chapter 7: List and Rendering')
.fontSize(24)
.fontWeight(FontWeight.Bold)

// ① ForEach 基本用法 + 增删
Column({ space: 12 }) {
Text('① ForEach 基本用法 + 增删')
.fontSize(18)
.fontWeight(FontWeight.Bold)

Row({ space: 8 }) {
TextInput({ placeholder: '输入一句诗词', text: this.newItem })
.layoutWeight(1)
.height(40)
.onChange((v: string) => { this.newItem = v })
Button('添加')
.height(40)
.onClick(() => this.addItem())
}
.width('100%')

ForEach(this.items, (item: string, index: number) => {
Row() {
Text(`${index + 1}. ${item}`)
.fontSize(16)
.layoutWeight(1)
Button('删除')
.fontSize(12)
.height(30)
.type(ButtonType.Normal)
.onClick(() => this.removeItem(index))
}
.width('100%')
.padding(12)
.backgroundColor(Color.White)
.borderRadius(8)
})
}
.width('100%')
.padding(16)
.backgroundColor('#eef4fb')
.borderRadius(12)

// ② ForEach key 生成函数(对象列表)
Column({ space: 12 }) {
Text('② ForEach key 生成函数(对象列表)')
.fontSize(18)
.fontWeight(FontWeight.Bold)

ForEach(this.products,
(product: Product) => {
Row() {
Text(product.name)
.fontSize(16)
.layoutWeight(1)
Text(`¥${product.price}`)
.fontSize(16)
.fontColor('#007DFF')
.layoutWeight(1)
Button('+100')
.fontSize(12)
.height(30)
.type(ButtonType.Normal)
.onClick(() => this.increasePrice(product.id))
}
.width('100%')
.padding(12)
.backgroundColor(Color.White)
.borderRadius(8)
},
(product: Product) => product.id.toString() // key 用唯一 id
)
}
.width('100%')
.padding(16)
.backgroundColor('#eefbf0')
.borderRadius(12)

// ③ 条件渲染 if/else
Column({ space: 12 }) {
Text('③ 条件渲染 if/else')
.fontSize(18)
.fontWeight(FontWeight.Bold)

if (this.isLoggedIn) {
Column({ space: 8 }) {
Text('欢迎回来,张三')
.fontSize(16)
Button('退出登录')
.onClick(() => { this.isLoggedIn = false })
}
} else {
Button('登录')
.width('100%')
.onClick(() => { this.isLoggedIn = true })
}
}
.width('100%')
.padding(16)
.backgroundColor('#fdf6ec')
.borderRadius(12)
}
.width('100%')
.padding(16)
}
.width('100%')
.height('100%')
.backgroundColor('#f5f5f5')
}
}

interface Product {
id: number
name: string
price: number
}

运行效果:

  • 输入诗句点"添加",列表实时新增
  • 点"删除"移除对应项,序号前移
  • 商品点"+100"价格递增
  • 登录状态在"登录按钮"与"欢迎+退出"间切换

运行效果截图:

Chapter7 综合示例总览

基础列表渲染

ForEach key 生成函数

条件渲染 if/else

ForEach 渲染诗词列表

ForEach 复杂用法与增删

7.8 仿抖音视频滑动案例

来源:鸿蒙学苑《案例知识点 · 仿抖音视频滑动案例》。核心在于解决上滑/下滑的监听识别,并配合状态管理实现点赞收藏。

7.8.1 需求分析

短视频类应用的典型交互:上下滑动切换视频,右侧点赞 / 收藏。技术点拆解:

  1. 上滑 / 下滑的识别:SwipeGesture 只能判断方向,不能区分上滑下滑,需改用 PanGesture 并限定垂直方向;
  2. 视频列表与当前索引:@Local currentIndex 记录当前播放的视频;
  3. 点赞收藏状态:@ObservedV2 + @Trace 实现对象属性级观测,点击后 UI 即时刷新。

7.8.2 核心实现

对应 code/Chapter7DouyinPage.ets

@ObservedV2
class DouyinVideo {
title: string
emoji: string
bgColor: string
@Trace isLike: boolean = false // 点赞状态(属性级观测)
@Trace isFavorited: boolean = false // 收藏状态

constructor(title: string, emoji: string, bgColor: string) {
this.title = title
this.emoji = emoji
this.bgColor = bgColor
}
}

@Entry
@ComponentV2
struct DouyinPage {
@Local videoArr: DouyinVideo[] = [
new DouyinVideo('星河', '🌌', '#1a1a2e'),
new DouyinVideo('山海', '🏔️', '#16213e'),
// ... 共 8 条
]
@Local currentIndex: number = 0

build() {
Stack({ alignContent: Alignment.End }) {
// 视频画面(真实项目替换为 Video 组件)
Column({ space: 12 }) {
Text(this.videoArr[this.currentIndex].emoji).fontSize(80)
Text(this.videoArr[this.currentIndex].title)
.fontSize(40).fontColor(Color.White).fontWeight(FontWeight.Bold)
Text(`${this.currentIndex + 1} / ${this.videoArr.length} · 上下滑动切换`)
.fontSize(14).fontColor('rgba(255,255,255,0.7)')
}
.width('100%').height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor(this.videoArr[this.currentIndex].bgColor)
// 关键:拖拽手势限定垂直方向,识别上滑/下滑
.gesture(
PanGesture({ direction: PanDirection.Vertical, distance: 2 })
.onActionEnd((e: GestureEvent) => {
if (e.offsetY < 0) {
// 上滑:下一个视频
this.currentIndex++
if (this.currentIndex === this.videoArr.length) {
this.currentIndex = 0
}
} else {
// 下滑:上一个视频
this.currentIndex--
if (this.currentIndex <= -1) {
this.currentIndex = this.videoArr.length - 1
}
}
})
)

// 右侧点赞 / 收藏
Column({ space: 20 }) {
Column({ space: 4 }) {
Text(this.videoArr[this.currentIndex].isLike ? '❤️' : '🤍').fontSize(36)
Text(this.videoArr[this.currentIndex].isLike ? '已赞' : '点赞')
.fontSize(14).fontColor(Color.White)
}
.width(72).height(72).justifyContent(FlexAlign.Center)
.onClick(() => {
this.videoArr[this.currentIndex].isLike = !this.videoArr[this.currentIndex].isLike
})

Column({ space: 4 }) {
Text(this.videoArr[this.currentIndex].isFavorited ? '⭐' : '☆').fontSize(36)
Text(this.videoArr[this.currentIndex].isFavorited ? '已收藏' : '收藏')
.fontSize(14).fontColor(Color.White)
}
.width(72).height(72).justifyContent(FlexAlign.Center)
.onClick(() => {
this.videoArr[this.currentIndex].isFavorited = !this.videoArr[this.currentIndex].isFavorited
})
}
.margin({ right: 16, bottom: 120 })
}
.width('100%').height('100%').backgroundColor(Color.Black)
}
}

7.8.3 运行效果

初始状态(第 1 个视频「星河」):

仿抖音初始

上滑切换到下一个视频(第 2 个「山海」):

仿抖音上滑切换

点击点赞后状态更新(🤍 → ❤️ 已赞):

仿抖音点赞

7.8.4 关键点总结

  • 上滑/下滑识别SwipeGesture 无法区分方向,用 PanGesture({ direction: PanDirection.Vertical }) 并判断 e.offsetY 正负;
  • 边界循环:切换时对 currentIndex 做首尾循环,模拟无限滑动;
  • 对象状态@ObservedV2 + @Trace 让点赞/收藏的状态变化精确刷新对应 UI,而非整页重建。

7.9 常见问题

Q:ForEach 更新列表后 UI 没变化? 检查是否重新赋值数组(this.items = [...]),而非 push()/splice()

Q:key 用 index 有什么问题? 增删时 key 错位,框架会错误复用组件,导致状态串位。对象列表务必用唯一 id。

Q:如何实现下拉刷新?Refresh 组件包裹 List:

Refresh({ refreshing: this.isRefreshing }) {
List() { ... }
}
.onRefresh(() => { this.refreshData() })

Q:ForEach / Repeat / LazyForEach 怎么选? 静态中小列表用 ForEach;数据频繁增删改、需要组件状态复用用 Repeat;万级以上大数据量必须用 LazyForEach(详见 7.6.4 选型表)。

Q:LazyForEach 的数据源有什么要求? 必须实现 IDataSourcetotalCount/getData/registerDataChangeListener/unregisterDataChangeListener),并在数据变化时通知监听者;建议提供唯一 key 生成函数。

Q:数据量大用什么? List 本身懒加载可视区域。万级以上配合 LazyForEach 数据源按需渲染;或使用 onReachEnd 分页加载。

7.10 本章小结

知识点说明
ForEach数组遍历渲染(静态中小列表)
Repeat组件复用、状态保持的列表渲染
LazyForEach大数据量按需渲染(需 IDataSource)
key 生成函数提升更新性能
数组更新必须重新赋值
if/else条件渲染
List + onReachEnd懒加载 + 分页
PanGesture 垂直滑动上滑/下滑识别(仿抖音)
@ObservedV2/@Trace对象属性级观测(点赞收藏)

7.11 课后练习

  1. 实现带筛选的待办列表(全部/进行中/已完成 三个 tab)
  2. Repeat 重写待办列表,验证组件状态复用
  3. LazyForEach 渲染 1 万条数据列表,滚动验证流畅度
  4. 设计分页加载的新闻列表(触底加载)
  5. 创建支持搜索的联系人列表(输入时实时过滤)
  6. ForEach 实现九宫格菜单
  7. 在仿抖音案例基础上增加「评论 / 转发」按钮,并用 @Trace 记录状态
  8. 将练习整合进 Chapter7/Index.ets

7.12 参考资料

评论

加载中…
加载中...