列表排序看起来简单——不就是数组元素换个位置吗?但加上动画和交互就有很多细节:拖拽时视觉反馈、松手后的位置动画、排序过程中的状态同步。这篇从手动排序到拖拽排序,把动画细节全说清楚。
数组重排算法
最基础的操作——把数组元素从位置A移到位置B:
privatemoveItem(from:number,to:number):void{letitem:DataItem=this.items[from];letnewItems:DataItem[]=[];// 先移除from位置的元素for(leti:number=0;i<this.items.length;i++){if(i!==from){newItems.push(this.items[i]);}}// 在to位置插入letresult:DataItem[]=[];for(leti:number=0;i<newItems.length;i++){if(i===to){result.push(item);}result.push(newItems[i]);}if(to>=newItems.length){result.push(item);}this.items=result;}关键:必须给this.items赋值新数组,不能直接splice修改。@State数组的变更检测依赖引用变化,直接修改原数组不会触发UI刷新。
更简洁的方式(如果运行时支持):
letitem:DataItem=this.items.splice(from,1)[0];this.items.splice(to,0,item);this.items=[...this.items];// 触发刷新但ArkTS不支持解构和展开运算符,所以用for循环构建新数组是最安全的做法。
上下移动按钮
最简单的排序UI——每个列表项旁边有↑↓按钮:
Row(){Text(item.title).layoutWeight(1)Button('↑').onClick(()=>{if(index>0){this.moveItem(index,index-1);}})Button('↓').onClick(()=>{if(index<this.items.length-1){this.moveItem(index,index+2);// +2因为移除后索引偏移}})}moveItem的to参数要注意:移除from元素后,目标位置的含义会变。上移一位是index-1,下移一位是index+2(因为当前元素被移除后,后面的元素都前移了一位)。
位置动画
排序后列表项位置变化,加animation让变化有过渡:
ListItem(){Row(){// 内容}.width('100%').padding(12).borderRadius(10).backgroundColor('#ffffff').animation({duration:200,curve:Curve.EaseInOut})}animation修饰器加在ListItem的内容容器上。当items数组重排后,ForEach根据key重新分配数据,组件位置变化,animation让位移产生过渡动画。
ForEach的key必须稳定——用item.id不要用index。index在排序后会变,导致ForEach错误地复用组件,动画就会错乱。
拖拽项视觉反馈
拖拽中的列表项需要视觉区分——背景色变化、阴影加深、轻微放大:
@StatedragIndex:number=-1;Row(){// 内容}.backgroundColor(this.dragIndex===index?'#e3f2fd':'#ffffff').shadow({radius:this.dragIndex===index?8:2,color:'#1a000000',offsetY:this.dragIndex===index?2:1}).scale({x:this.dragIndex===index?1.02:1,y:this.dragIndex===index?1.02:1}).animation({duration:150,curve:Curve.EaseOut})三个视觉变化同时发生:背景变蓝、阴影加深、轻微放大1.02倍。animation让变化有过渡。
dragIndex在拖拽开始时设为当前index,拖拽结束时恢复-1。
PanGesture拖拽排序
用PanGesture实现跟手拖拽:
Row(){Text('≡')// 拖拽手柄Text(item.title)}.gesture(PanGesture().onActionStart(()=>{this.dragIndex=index;}).onActionUpdate((event:GestureEvent)=>{// 根据event.offsetY计算当前应该在的位置letcurrentY:number=this.itemPositions[index]+event.offsetY;letnewIndex:number=this.calculateNewIndex(currentY);if(newIndex!==index&&newIndex!==this.targetIndex){this.targetIndex=newIndex;}}).onActionEnd(()=>{if(this.targetIndex>=0){this.moveItem(this.dragIndex,this.targetIndex);}this.dragIndex=-1;this.targetIndex=-1;}))PanGesture的onActionUpdate提供offsetY——手指从起始位置的偏移。根据偏移计算当前应该在哪个位置,实时预览排序效果。
但PanGesture有个问题:ListItem的PanGesture会跟List的滚动冲突。需要用priorityGesture或者用专门的拖拽手柄区域。
List内置拖拽API
List组件有onDragStart/onDrop等拖拽回调:
List(){ForEach(this.items,(item:DataItem,index:number)=>{ListItem(){Row(){Text(item.title)}}.onDragStart((event:DragEvent)=>{this.dragIndex=index;returnthis.dragBuilder(item);}).onDrop((event:DragEvent)=>{lettargetIndex:number=this.calculateDropIndex(event.y);this.moveItem(this.dragIndex,targetIndex);this.dragIndex=-1;})},(item:DataItem)=>item.id)}onDragStart返回一个@Builder作为拖拽预览图。onDrop在松手时触发,event.y可以计算放置位置。
但onDragStart/onDrop在List中的行为不够稳定——长按触发拖拽的灵敏度、跨ListItem的拖拽位置计算都有坑。实际项目中更常用按钮排序+动画过渡,而不是真正拖拽。
删除动画
列表项删除时的动画效果:
@StateremovingIndex:number=-1;Row(){Text(item.title)}.scale({x:this.removingIndex===index?0.8:1,y:this.removingIndex===index?0.8:1}).opacity(this.removingIndex===index?0:1).animation({duration:200,curve:Curve.EaseIn})// 触发删除removeItem(index:number):void{this.removingIndex=index;setTimeout(()=>{letnewItems:DataItem[]=[];for(leti:number=0;i<this.items.length;i++){if(i!==index){newItems.push(this.items[i]);}}this.items=newItems;this.removingIndex=-1;},200);}删除分两步:先动画(缩小+渐隐),200ms后真正移除数据。setTimeout延迟匹配animation时长。
插入动画
新项目插入时渐显+放大:
@StatenewItemIndex:number=-1;Row(){Text(item.title)}.scale({x:this.newItemIndex===index?1:0.5,y:this.newItemIndex===index?1:0.5}).opacity(this.newItemIndex===index?1:0).animation({duration:300,curve:Curve.EaseOut})// 插入后insertItem(item:DataItem,at:number):void{this.newItemIndex=at;letnewItems:DataItem[]=[];for(leti:number=0;i<this.items.length;i++){if(i===at){newItems.push(item);}newItems.push(this.items[i]);}if(at>=this.items.length){newItems.push(item);}this.items=newItems;setTimeout(()=>{this.newItemIndex=-1;},300);}初始scale 0.5和opacity 0,动画到1和1。300ms后清除标记。这样新插入的项有一个"出现"的动画。
列表动画的性能注意
动画越多性能开销越大。几个优化点:
- 只对可见项做动画:removingIndex/newItemIndex只影响一个项
- animation时长别太长:200ms够用,超过300ms用户会觉得卡
- 避免大范围重绘:排序时ForEach只更新变化项,key要稳定
- shadow开销大:大量列表项加shadow会掉帧,拖拽中临时加shadow,结束后去掉
- LazyForEach + cachedCount:大列表排序必须用LazyForEach,否则ForEach全量创建组件
交错动画
列表初始化时每项依次出现:
@StateitemVisible:boolean[]=[];aboutToAppear():void{for(leti:number=0;i<this.items.length;i++){setTimeout(()=>{this.itemVisible[i]=true;},i*50);}}Row(){Text(item.title)}.opacity(this.itemVisible[index]?1:0).translate({y:this.itemVisible[index]?0:20}).animation({duration:300,curve:Curve.EaseOut})每项延迟50ms出现,形成从上到下的波浪效果。50ms * N项 = 总延迟。10项500ms完成。
踩坑清单
| 问题 | 原因 | 解决 |
|---|---|---|
| 排序后UI没更新 | 直接修改数组没触发刷新 | 赋值新数组给this.items |
| 动画错乱 | ForEach用index作key | 改用item.id |
| PanGesture跟滚动冲突 | 两个手势互相消费 | 用priorityGesture或拖拽手柄 |
| 删除动画后列表跳 | 动画和数据移除不同步 | 动画完成后再移除数据 |
| 阴影导致掉帧 | 大量shadow组件 | 只在拖拽项加shadow |
| 移动位置计算错误 | 移除元素后索引偏移 | 先移除再计算目标位置 |
| 交错动画闪烁 | opacity初始值不对 | 初始化itemVisible为false |
| 按钮排序↑↓方向反 | moveItem的to参数理解错误 | 下移用index+2不是index+1 |
| 插入位置不准 | for循环的插入逻辑 | 在i===at时先push新项 |
| 动画中操作列表 | 动画未完成就改数据 | 加loading标记防重复操作 |
列表动画的原则是"少量项做动画,大量项保性能"。排序只影响两三个项的位置变化,用animation就够了。全列表出现动画只在初始化做一次,不要反复触发。