列表项一个个滑入屏幕:HarmonyOS6 PC 交错入场动画实战
用 HarmonyOS6 PC 开发做过列表页面的朋友应该都有这种感觉——一个列表如果所有项同时"砰"地弹出来,看着就很廉价。但如果让每一项从侧面滑入,彼此之间有个几十毫秒的延迟,形成一种"鱼贯而入"的效果,整个页面的质感立刻就上去了。
这种交错入场动画(Staggered Animation)在 iOS 和 Android 上已经被用烂了,但在 HarmonyOS 生态里,很多人还不知道怎么做。
今天就把这个效果从头到尾讲清楚。核心思路其实不复杂:ForEach 渲染列表 + animateTo 控制每项的动画状态 + setTimeout 控制延迟。

先看完整代码
@Entry
@Component
struct ListItemAnimationDemo {
@State itemStates: number[] = [0, 0, 0, 0, 0, 0, 0, 0]
@State hasAnimStarted: boolean = false
build() {
Column() {
Text('列表项入场动画')
.fontSize(18).fontWeight(FontWeight.Bold).margin({ bottom: 8 })
Column() {
ForEach([0, 1, 2, 3, 4, 5, 6, 7], (idx: number) => {
Row() {
Text(`${idx + 1}`)
.width(36).height(36)
.backgroundColor(this.getColor(idx % 8))
.borderRadius(18).fontSize(14).fontColor('#FFFFFF')
.textAlign(TextAlign.Center)
Column() {
Text(`列表项 ${idx + 1}`).fontSize(14).fontWeight(FontWeight.Medium)
Text('滑动进入的动画效果').fontSize(11).fontColor('#999999')
}
.alignItems(HorizontalAlign.Start).margin({ left: 12 }).layoutWeight(1)
}
.width('100%').padding(12)
.backgroundColor('#F8F9FA').borderRadius(8)
.opacity(this.itemStates[idx])
.translate({ x: (1 - this.itemStates[idx]) * 50 })
.animation({ duration: 350, curve: Curve.EaseOut })
.margin({ bottom: 6 })
})
Button(this.hasAnimStarted ? '重新播放' : '播放入场动画')
.width('100%').margin({ top: 12 })
.onClick(() => {
this.hasAnimStarted = true
// 先重置所有项的状态
for (let i = 0; i < 8; i++) { this.itemStates[i] = 0 }
// 然后依次触发每项的动画
for (let i = 0; i < 8; i++) {
setTimeout(() => {
animateTo({ duration: 350, curve: Curve.EaseOut }, () => {
this.itemStates[i] = 1
})
}, i * 100)
}
})
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12).padding(16)
}
.width('100%').height('100%').backgroundColor('#F5F6FA').padding(16)
}
getColor(index: number): string {
const colors = ['#FF6B6B', '#FFA500', '#FFD93D', '#6BCB77', '#4ECDC4', '#4D96FF', '#9B59B6', '#FF6B9D']
return colors[index]
}
}

代码拆解
状态设计:一个数组管所有项
@State itemStates: number[] = [0, 0, 0, 0, 0, 0, 0, 0]
8 个列表项,8 个动画状态值。0 代表"未入场"(透明 + 偏移),1 代表"已入场"(不透明 + 原位)。
用一个数组而不是 8 个独立变量,是因为列表项的数量可能不固定。后面讲到动态数据源的时候会展开说。
opacity + translateX 的组合
每个列表项绑定了两个动画属性:
.opacity(this.itemStates[idx])
.translate({ x: (1 - this.itemStates[idx]) * 50 })
当 itemStates[idx] 为 0 时:
- opacity = 0(完全透明)
- translateX = (1-0) * 50 = 50px(向右偏移 50px)
当 itemStates[idx] 为 1 时:
- opacity = 1(完全不透明)
- translateX = (1-1) * 50 = 0(回到原位)
所以每个列表项的入场效果是:从右侧 50px 的位置,伴随着透明度从 0 到 1,滑动到原位。
这个 translate 的公式 (1 - state) * offset 是个很好用的小技巧。state 从 0 到 1 线性变化,translateX 从 offset 到 0 线性变化,正好形成平滑的位移过渡。
.animation() 修饰器
.animation({ duration: 350, curve: Curve.EaseOut })
挂在每个 Row 上,意味着当 itemStates[idx] 变化时,opacity 和 translateX 的过渡动画自动执行。这里用 EaseOut 曲线是因为入场动画需要"快速启动、缓慢停下"的感觉。
350ms 的时长是个经过反复测试的值——太短(200ms)看不出滑动效果,太长(500ms+)会让整体入场太拖沓。
触发逻辑:重置 + 依次启动
.onClick(() => {
this.hasAnimStarted = true
// 第一步:重置
for (let i = 0; i < 8; i++) { this.itemStates[i] = 0 }
// 第二步:依次触发
for (let i = 0; i < 8; i++) {
setTimeout(() => {
animateTo({ duration: 350, curve: Curve.EaseOut }, () => {
this.itemStates[i] = 1
})
}, i * 100)
}
})
第一步的"重置"没有用 animateTo 包裹,所以是瞬间生效的——所有列表项立刻变成透明+偏移状态。
第二步用 for 循环 + setTimeout 实现交错延迟。第 0 项延迟 0ms,第 1 项延迟 100ms,第 2 项延迟 200ms……以此类推。
时间轴长这样:
时间线(ms): 0 ---100 ---200 ---300 ---400 ---500 ---600 ---700 ---800 ---900 ---1050
项1: |====350ms 动画====|
项2: |====350ms 动画====|
项3: |====350ms 动画====|
项4: |====350ms 动画====|
项5: |====350ms 动画====|
项6: |====350ms 动画====|
项7: |====350ms 动画====|
项8: |====350ms 动画====|
每项间隔 100ms,每项动画 350ms。这意味着相邻两项之间有 250ms 的重叠——前一项还在动的时候,后一项已经开始了。这种重叠是故意设计的,它让整个入场过程有连贯的流动感,而不是"一个完了一个再来"的断裂感。
从开始到最后一项动画结束,总耗时 = 7 * 100 + 350 = 1050ms。大约 1 秒,对于 8 个列表项来说是个很舒服的节奏。
调整参数:不同风格的入场效果
改变延迟间隔和动画时长,可以获得完全不同风格的入场效果:
快速紧凑风格
// 间隔 50ms,动画 250ms
setTimeout(() => {
animateTo({ duration: 250, curve: Curve.EaseOut }, () => {
this.itemStates[i] = 1
})
}, i * 50)
总耗时 = 7 * 50 + 250 = 600ms。适合数据刷新、搜索结果等"快速呈现"的场景。
优雅从容风格
// 间隔 150ms,动画 500ms
setTimeout(() => {
animateTo({ duration: 500, curve: Curve.EaseInOut }, () => {
this.itemStates[i] = 1
})
}, i * 150)
总耗时 = 7 * 150 + 500 = 1550ms。适合首页、品牌展示等"需要观赏性"的场景。
波浪弹性风格
// 间隔 80ms,动画 600ms,使用 Friction 曲线(有轻微回弹)
setTimeout(() => {
animateTo({ duration: 600, curve: Curve.Friction }, () => {
this.itemStates[i] = 1
})
}, i * 80)
Friction 曲线会给每个列表项一个轻微的"弹到位"的感觉,整体效果更有活力。
换一种入场方向
不一定非要从右边滑入。改一下 translate 的参数就行:
从下方滑入
.translate({ y: (1 - this.itemStates[idx]) * 30 })
每项从下方 30px 处向上滑入。这种效果在 HarmonyOS6 PC 端的长列表里特别好——用户的视线是从上往下扫的,从下方进入刚好引导视线流动。
从左侧滑入
.translate({ x: -(1 - this.itemStates[idx]) * 50 })
加个负号就行。从左侧滑入适合从右到左排列的元素(比如阿拉伯语布局),或者配合某个从左侧展开的交互。
纯缩放进入(无位移)
.opacity(this.itemStates[idx])
.scale({
x: 0.8 + this.itemStates[idx] * 0.2,
y: 0.8 + this.itemStates[idx] * 0.2
})
从 80% 大小 + 透明 → 正常大小 + 不透明。没有位移,更有"原地弹出"的感觉。适合网格布局的卡片入场。
动态数据源的入场动画
Demo 里用了固定的 8 个项。但真实项目里,列表数据通常是从后端获取的,数量不固定。
这时候代码要做些调整:
@State dataList: string[] = []
@State itemStates: number[] = []
// 数据加载完成后调用
onDataLoaded(data: string[]) {
this.dataList = data
// 初始化动画状态数组,跟数据等长
this.itemStates = new Array(data.length).fill(0)
// 播放入场动画
const maxAnimatedItems = Math.min(data.length, 15) // 最多给15项做动画
for (let i = 0; i < maxAnimatedItems; i++) {
setTimeout(() => {
animateTo({ duration: 350, curve: Curve.EaseOut }, () => {
const newState = [...this.itemStates]
newState[i] = 1
this.itemStates = newState
})
}, i * 100)
}
// 超过15项的直接设为可见状态,不做动画
if (data.length > 15) {
for (let i = 15; i < data.length; i++) {
this.itemStates[i] = 1
}
}
}
这里有几个关键点:
1. 限制做动画的项数
如果列表有 100 项,每项间隔 100ms,光动画就要播 10 秒——用户等不起。所以设一个上限(比如 15 项),超过的部分直接显示。
2. 用数组替换触发响应式更新
直接 this.itemStates[i] = 1 在某些 ArkUI 版本里可能不会触发 UI 更新。用 const newState = [...this.itemStates] 创建新数组再赋值更保险。
3. 在 build 中使用动态数组
ForEach(this.dataList, (item: string, idx: number) => {
Row() {
// 渲染列表项内容...
}
.opacity(this.itemStates[idx] ?? 1) // 兜底值为1,防止数组越界
.translate({ x: (1 - (this.itemStates[idx] ?? 1)) * 50 })
.animation({ duration: 350, curve: Curve.EaseOut })
})
兜底值 ?? 1 很重要——当数据比 itemStates 多(比如分页加载了更多数据),新项不会有对应的动画状态,用兜底值 1 让它们直接显示。
LazyForEach 与动画的兼容问题
如果你的列表用的是 LazyForEach(懒加载),那入场动画就有点麻烦了。
LazyForEach 的特性是只创建可视区域内的组件。滚动时新进入可视区域的组件会被即时创建。这就导致一个问题——当你触发入场动画时,只有当前屏幕内的几项会做动画,滚下去才出现的项不会做(因为它们是在滚动过程中才创建的)。
解决方案有两种:
方案一:入场动画只在首次加载时做
首次加载的数据量通常不会超出屏幕太多(一般首屏 5-10 项),入场动画做完后,后续滚动加载的项直接显示,不做动画。这个方案最简单,实际效果也还不错。
方案二:监听 onAppear 做单个项的入场
ForEach(this.dataList, (item: string, idx: number) => {
Row() { /* ... */ }
.opacity(this.itemStates[idx])
.translate({ x: (1 - this.itemStates[idx]) * 50 })
.animation({ duration: 350, curve: Curve.EaseOut })
.onAppear(() => {
// 每个项被创建时自动触发自己的入场动画
animateTo({ duration: 350, curve: Curve.EaseOut }, () => {
const newState = [...this.itemStates]
newState[idx] = 1
this.itemStates = newState
})
})
})
每个列表项在 onAppear 时触发动画。这样不管是首屏加载还是滚动后出现,每个项都有自己的入场效果。但要注意——滚动很快的时候,多个项几乎同时 onAppear,可能造成大量并发动画。可以加个标记,只对首次出现做动画。
性能优化:大量列表项的入场动画

在 HarmonyOS6 PC 端,列表可能很长(尤其是大屏能显示更多项)。如果一次要给 30+ 项做入场动画,要注意性能。
优化一:减少 animateTo 的调用次数
与其每项一个 animateTo,不如分组处理:
// 每5项一组,每组同时入场
const groupSize = 5
for (let g = 0; g < Math.ceil(data.length / groupSize); g++) {
setTimeout(() => {
animateTo({ duration: 350, curve: Curve.EaseOut }, () => {
const newState = [...this.itemStates]
for (let i = g * groupSize; i < (g + 1) * groupSize && i < data.length; i++) {
newState[i] = 1
}
this.itemStates = newState
})
}, g * 150) // 每组间隔 150ms
}
30 项分成 6 组,每组 5 项同时入场,组间间隔 150ms。总耗时只有 5 * 150 + 350 = 1100ms,但 animateTo 只调用了 6 次而不是 30 次。
优化二:避免在动画期间操作数组
每次 this.itemStates = newState 都会触发 ArkUI 的 diff 算法。如果数组很大且频繁替换,性能开销不可忽略。
对于大型列表,推荐用 @Observed + @ObjectLink 的方式,让每个列表项是独立的可观察对象:
@Observed
class ListItemModel {
title: string
animationProgress: number = 0
constructor(title: string) {
this.title = title
}
}
// 在组件中使用
@State items: ListItemModel[] = dataList.map(t => new ListItemModel(t))
// 动画触发时直接修改属性
animateTo({ duration: 350 }, () => {
this.items[i].animationProgress = 1
})
每个项的动画状态是独立的,修改一项不影响其他项,也不需要替换整个数组。
入场动画的时机选择
入场动画什么时候播放,其实也是个设计问题。
页面首次加载时:最常见的时机。数据渲染完毕后自动播放,给用户"页面正在加载完成"的反馈。
下拉刷新后:刷新完成后新数据入场。但说实话,刷新场景不建议做入场动画——用户只是想看看有什么新内容,不想再看一遍动画。
Tab 切换后:切换到某个 Tab 时该 Tab 下的列表入场。适合每个 Tab 内容差异较大的场景。
搜索/筛选后:搜索结果出来时做入场动画,可以让"搜索结果加载完成"这个事件更有感知。但同样注意,如果用户频繁搜索,每次都来一遍动画会很烦。可以加个标记——只在首次搜索时播放。
在 HarmonyOS6 PC 端,用户的操作频率比手机端高(鼠标点击快嘛),所以入场动画的触发条件要更谨慎。我的建议是:只在页面首次加载和重要的数据更新时播放入场动画,其他场景直接显示。
跟串联动画的关系
如果你看了之前关于串联动画的文章,会发现列表项入场动画本质上就是一种串联动画——只不过是"有重叠的串联"。
核心模式完全一样:
for 循环遍历 + setTimeout(i * interval) + animateTo()
区别在于串联动画是"前一个做完后一个再做"(间隔 >= 动画时长),而列表项入场是"前一个还没做完后一个就开始了"(间隔 < 动画时长)。后者的效果更连贯,也是实际项目中更常用的模式。
小结
列表项交错入场动画,说白了就是三个要素的组合:
- 每项绑定 opacity + translate:控制可见性和位置
- .animation() 修饰器:让属性变化自动过渡
- setTimeout(i * interval):控制每项的启动延迟
调参的关键在于 interval 和 duration 的比例。interval 小于 duration 产生重叠效果(推荐),interval 等于 duration 产生严格衔接效果,interval 大于 duration 产生"停顿-动-停顿"效果(通常不推荐)。
下次做列表页面的时候,花 5 分钟加上这个入场效果,用户体验提升真的立竿见影。
更多推荐



所有评论(0)