列表项一个个滑入屏幕:HarmonyOS6 PC 交错入场动画实战

张开发
2026/6/13 8:51:24 15 分钟阅读

分享文章

列表项一个个滑入屏幕:HarmonyOS6 PC 交错入场动画实战
用 HarmonyOS6 PC 开发做过列表页面的朋友应该都有这种感觉——一个列表如果所有项同时砰地弹出来看着就很廉价。但如果让每一项从侧面滑入彼此之间有个几十毫秒的延迟形成一种鱼贯而入的效果整个页面的质感立刻就上去了。这种交错入场动画Staggered Animation在 iOS 和 Android 上已经被用烂了但在 HarmonyOS 生态里很多人还不知道怎么做。今天就把这个效果从头到尾讲清楚。核心思路其实不复杂ForEach 渲染列表 animateTo 控制每项的动画状态 setTimeout 控制延迟。先看完整代码EntryComponentstruct ListItemAnimationDemo{StateitemStates:number[][0,0,0,0,0,0,0,0]StatehasAnimStarted:booleanfalsebuild(){Column(){Text(列表项入场动画).fontSize(18).fontWeight(FontWeight.Bold).margin({bottom:8})Column(){ForEach([0,1,2,3,4,5,6,7],(idx:number){Row(){Text(${idx1}).width(36).height(36).backgroundColor(this.getColor(idx%8)).borderRadius(18).fontSize(14).fontColor(#FFFFFF).textAlign(TextAlign.Center)Column(){Text(列表项${idx1}).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.hasAnimStartedtrue// 先重置所有项的状态for(leti0;i8;i){this.itemStates[i]0}// 然后依次触发每项的动画for(leti0;i8;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{constcolors[#FF6B6B,#FFA500,#FFD93D,#6BCB77,#4ECDC4,#4D96FF,#9B59B6,#FF6B9D]returncolors[index]}}代码拆解状态设计一个数组管所有项StateitemStates: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.hasAnimStartedtrue// 第一步重置for(leti0;i8;i){this.itemStates[i]0}// 第二步依次触发for(leti0;i8;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动画 250mssetTimeout((){animateTo({duration:250,curve:Curve.EaseOut},(){this.itemStates[i]1})},i*50)总耗时 7 * 50 250 600ms。适合数据刷新、搜索结果等快速呈现的场景。优雅从容风格// 间隔 150ms动画 500mssetTimeout((){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.8this.itemStates[idx]*0.2,y:0.8this.itemStates[idx]*0.2})从 80% 大小 透明 → 正常大小 不透明。没有位移更有原地弹出的感觉。适合网格布局的卡片入场。动态数据源的入场动画Demo 里用了固定的 8 个项。但真实项目里列表数据通常是从后端获取的数量不固定。这时候代码要做些调整StatedataList:string[][]StateitemStates:number[][]// 数据加载完成后调用onDataLoaded(data:string[]){this.dataListdata// 初始化动画状态数组跟数据等长this.itemStatesnewArray(data.length).fill(0)// 播放入场动画constmaxAnimatedItemsMath.min(data.length,15)// 最多给15项做动画for(leti0;imaxAnimatedItems;i){setTimeout((){animateTo({duration:350,curve:Curve.EaseOut},(){constnewState[...this.itemStates]newState[i]1this.itemStatesnewState})},i*100)}// 超过15项的直接设为可见状态不做动画if(data.length15){for(leti15;idata.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},(){constnewState[...this.itemStates]newState[idx]1this.itemStatesnewState})})})每个列表项在onAppear时触发动画。这样不管是首屏加载还是滚动后出现每个项都有自己的入场效果。但要注意——滚动很快的时候多个项几乎同时 onAppear可能造成大量并发动画。可以加个标记只对首次出现做动画。性能优化大量列表项的入场动画在 HarmonyOS6 PC 端列表可能很长尤其是大屏能显示更多项。如果一次要给 30 项做入场动画要注意性能。优化一减少 animateTo 的调用次数与其每项一个 animateTo不如分组处理// 每5项一组每组同时入场constgroupSize5for(letg0;gMath.ceil(data.length/groupSize);g){setTimeout((){animateTo({duration:350,curve:Curve.EaseOut},(){constnewState[...this.itemStates]for(letig*groupSize;i(g1)*groupSizeidata.length;i){newState[i]1}this.itemStatesnewState})},g*150)// 每组间隔 150ms}30 项分成 6 组每组 5 项同时入场组间间隔 150ms。总耗时只有 5 * 150 350 1100ms但 animateTo 只调用了 6 次而不是 30 次。优化二避免在动画期间操作数组每次this.itemStates newState都会触发 ArkUI 的 diff 算法。如果数组很大且频繁替换性能开销不可忽略。对于大型列表推荐用ObservedObjectLink的方式让每个列表项是独立的可观察对象ObservedclassListItemModel{title:stringanimationProgress:number0constructor(title:string){this.titletitle}}// 在组件中使用Stateitems:ListItemModel[]dataList.map(tnewListItemModel(t))// 动画触发时直接修改属性animateTo({duration:350},(){this.items[i].animationProgress1})每个项的动画状态是独立的修改一项不影响其他项也不需要替换整个数组。入场动画的时机选择入场动画什么时候播放其实也是个设计问题。页面首次加载时最常见的时机。数据渲染完毕后自动播放给用户页面正在加载完成的反馈。下拉刷新后刷新完成后新数据入场。但说实话刷新场景不建议做入场动画——用户只是想看看有什么新内容不想再看一遍动画。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 分钟加上这个入场效果用户体验提升真的立竿见影。

更多文章