Cocos Creator拖拽排序列表实现指南:核心逻辑、避坑技巧与性能优化
简介这是一份基于 Cocos Creator 的拖拽排序列表实现示例面向游戏前端开发者或 Cocos Creator 学习者解决列表项拖拽重排、存储及界面刷新的界面交互开发问题。资源共 16 个文件以 ts 逻辑脚本、json 项目配置文件、meta 资源元数据为主整体压缩包仅 243KB轻量易用。包内包含完整的项目配置与 creator.d.ts 类型声明便于在编辑器中直接打开参考。已有 114 人学习下载可作为拖拽排序功能的入门到进阶范例。通过阅读代码可掌握触摸事件监听、拖拽位置计算、数组顺序存储与节点坐标更新的核心流程并了解如何兼顾频繁重绘时的性能优化思路。这套示例涵盖拖拽移动、排序存储与状态更新三个核心环节适合希望快速集成可交互列表功能的开发者借鉴。1. 拖拽排序列表每个 Cocos Creator 项目迟早要踩的坑做游戏或工具型 App 的界面时「可拖拽排序的列表」几乎是绕不开的硬需求背包里的道具整理、关卡列表的自定义排序、任务队列的优先级调整甚至编辑器内部的资源面板。很多开发者一开始觉得这东西简单——不就是监听触摸事件改一下节点位置吗真正动手才发现光是把拖拽中的节点正确插到两个列表项中间就有不下五种写法而每种写法在边界条件下的表现都不一样。更麻烦的是Cocos Creator 的 UI 事件系统、节点层级和触摸优先级三者一旦配合不好列表就会出现各种诡异的乱序、抖动和穿透问题。写这篇文章的原因很实际——我见过太多人在社区里问同样的问题而靠谱的答案总是零散分布在各处。与其让读者在碎片信息里反复试错不如把一套我在多个项目里验证过的实现思路完整讲清楚从 starter 项目的搭建、拖拽核心逻辑的拆分到避坑要点、移动端适配和性能优化一步步推演下来。这篇文章的目标读者是会用 Cocos Creator 基本组件、写过简单 UI 交互但没系统实现过拖拽列表的开发者如果你已经写过一版但总在某个边界场景翻车后半部分的排查清单应该能直接帮到你。先说结论实现拖拽排序列表的关键不在「拖」而在「排序」——也就是拖拽过程中如何稳定、直观地判断目标插入位置并在松手后平滑地完成重排。想清楚这点代码怎么写都不会跑偏。2. 实现方案选型为什么不推荐改动节点层级以及三种主流做法的对比在动手写代码之前先把方案选型说清楚。因为很多一开始觉得「这样写没问题」的思路做到一半才发现是坑回头重构的成本远高于最开始多花十分钟做对比。2.1 方案一边拖边改节点层级——最简单也最容易翻车最容易想到的方案是拖拽时直接修改被拖节点的 siblingIndex让它实时穿插到对应位置。这样拖拽效果非常直接——节点真的在层级上移动了视觉上没有任何延迟。但这么做有两个硬伤。第一层级操作是有成本的每改一次 siblingIndexCocos Creator 底层都会重排渲染顺序如果你一次拖拽在快速滑动过程中产生了 20 次插入操作性能差一点的低端机上就会出现肉眼可见的掉帧。第二视觉反馈非常生硬节点在插入瞬间是一跳一跳过去的没有任何过渡动画列表整体的「流动感」很差——你会发现这种实现做出的效果随手一拖就像在操作一个没做平滑的旧版桌面端表格。这个方案只适合一种场景列表极短比如不超过 5 项、且目标平台是低交互要求的内部工具。否则我不推荐用。2.2 方案二数据驱动 拖拽代理——推荐的核心设计思想第二种也是我推荐绝大多数项目采用的思路拖拽时不动原列表的节点层级而是让「被拖节点」复制一份作为拖拽代理跟随手指移动原列表保持静态只通过数据计算判断「当前应该插入到哪个 index」再在松手时一次性完成数据重排和节点刷新。这个方案的好处视觉稳定拖拽过程中原列表不乱动用户看到的是一个「提起来」的节点在移动——这符合物理直觉。性能可控拖拽过程中没有任何层级操作只有代理节点的位置变化渲染成本极低。逻辑清晰数据是一个数组排序就是改数组的 indexUI 只是数据的投影重排时按新顺序刷新所有列表项位置即可。缺点也有如果你想在拖拽过程中让原列表的其他项实时让位即「挤开」效果需要额外做动画补偿。我会在第 3 章给出让位的实现方式——其实不复杂。2.3 方案三ScrollView 自动滚动——需要时才引入的增强第三种是锦上添花的方案当列表超过可视区域高度时拖拽到边缘需要触发布局滚动否则你根本拖不到超出屏幕的列表项。实现上是在拖拽代理的位置判断中加入一个距离检测拖拽点靠近可视区域上边缘触发 ScrollView 向上滚靠近下边缘触发向下滚拖拽点离开边缘区域停止滚动。我把第三章的代码按这个方案做了扩展调用一个_updateScrollByDrag()方法即可。后面会讲到具体怎么做。所以最终推荐的结构是用数据数组存储列表顺序用拖拽代理跟随手指用 index 计算寻找插入目标松手后统一刷新并播放归位动画。接下来第三章直接给代码。3. 实现一个最小可用的拖拽排序列表核心 Data 结构与拖动代理这一章是你能直接抄走的部分。我尽量把逻辑拆成独立的小块方便你按需摘取。整个实现跑在纯 TypeScript Cocos Creator 2.x/3.x 的 UI 系统上不依赖任何第三方库。3.1 定义列表数据的底层结构为什么用一个数组承载全部状态拖拽排序的本质是「数组重排」而不是「节点移动」。所以第一步是把展示数据抽象出来。假设我们要排一个字符串列表或对象列表代码如下// DraggableListData.ts export interface IDraggableItem { id: string; // 每个条目的唯一标识用于刷新时比对 name: string; // 展示文本 // 你可以扩展任意业务字段 } export class DraggableListModel { private _items: IDraggableItem[] []; // 获取当前全部数据只读 public get items(): ReadonlyArrayIDraggableItem { return this._items; } // 初始化/重置数据 public setup(initialItems: IDraggableItem[]): void { this._items initialItems.slice(); } // 将条目从 fromIndex 移动到 toIndex public move(fromIndex: number, toIndex: number): void { if (fromIndex toIndex) return; const item this._items.splice(fromIndex, 1)[0]; this._items.splice(toIndex, 0, item); } // 通过 id 查找当前所在索引找不到返回 -1 public indexOf(id: string): number { return this._items.findIndex(it it.id id); } }逻辑很简单但有几个值得说明的细节move()用了 splice 先删后插不要直接用数组交换因为交换在视觉上会导致中间跨过的所有项位置逻辑错乱。setup()里slice()复制数组避免外部调用者直接持有内部数组的引用。用id而不是 index 查找条目是因为拖拽过程中被拖项的索引会变化而用 id 引用对象更稳定。3.2 搭建列表 UI 与注册触摸事件一种基于触摸阶段的判断方法接下来是列表项节点的构建。这里我用一个容器节点content必要时包在 ScrollView 里每个列表项是一个预制体ListItem。列表项上挂一个组件负责响应触摸。// ListItem.ts import { _decorator, Component, Node, EventTouch, UITransform, Vec3 } from cc; const { ccclass, property } _decorator; ccclass(ListItem) export class ListItem extends Component { property(Node) public iconNode: Node null!; // 图标/视觉节点拖拽时让它放大 property(Node) public bgNode: Node null!; // 背景节点用于显示占位 private _model: DraggableListModel null!; private _onDragStart: (itemId: string, touchPos: Vec3) void null!; private _onDragMove: (itemId: string, touchPos: Vec3) void null!; private _onDragEnd: (itemId: string) void null!; public init(model: DraggableListModel, callbacks: { onDragStart: (itemId: string, touchPos: Vec3) void; onDragMove: (itemId: string, touchPos: Vec3) void; onDragEnd: (itemId: string) void; }, itemId: string) { this._model model; this._onDragStart callbacks.onDragStart; this._onDragMove callbacks.onDragMove; this._onDragEnd callbacks.onDragEnd; this.node.name itemId; } protected onEnable(): void { this.node.on(Node.EventType.TOUCH_START, this._onTouchStart, this); this.node.on(Node.EventType.TOUCH_MOVE, this._onTouchMove, this); this.node.on(Node.EventType.TOUCH_END, this._onTouchEnd, this); this.node.on(Node.EventType.TOUCH_CANCEL, this._onTouchEnd, this); } protected onDisable(): void { this.node.off(Node.EventType.TOUCH_START, this._onTouchStart, this); this.node.off(Node.EventType.TOUCH_MOVE, this._onTouchMove, this); this.node.off(Node.EventType.TOUCH_END, this._onTouchEnd, this); this.node.off(Node.EventType.TOUCH_CANCEL, this._onTouchEnd, this); } private _onTouchStart(event: EventTouch): void { // 把触点坐标转到列表容器空间方便后续计算 const uiTransform this.node.parent!.getComponent(UITransform)!; const localPos uiTransform.convertToNodeSpaceAR(event.getUILocation()); this._onDragStart(this.node.name, new Vec3(localPos.x, localPos.y, 0)); // 阻止事件冒泡避免 ScrollView 同时滚动 event.propagationStopped true; } private _onTouchMove(event: EventTouch): void { const uiTransform this.node.parent!.getComponent(UITransform)!; const localPos uiTransform.convertToNodeSpaceAR(event.getUILocation()); this._onDragMove(this.node.name, new Vec3(localPos.x, localPos.y, 0)); event.propagationStopped true; } private _onTouchEnd(event: EventTouch): void { this._onDragEnd(this.node.name); event.propagationStopped true; } }几个重要参数说明event.propagationStopped true这行极为关键。如果不设拖拽时底层 ScrollView 会同时收到触摸事件出现你拖着列表项、整个列表跟着滚的现象。convertToNodeSpaceAR把触点坐标从屏幕空间转换到content节点的本地空间这样后面算目标 index 时可以直接跟每个列表项的position比较。我这里在_onTouchStart阶段就立刻取坐标传给外部处理外部随即创建代理节点。所以原列表项本身不需要移动它只是事件入口。3.3 拖拽代理与目标位置高亮让用户看得到「会插到哪」进入核心逻辑。列表管理器DraggableListController负责创建代理、计算插入位置、重排数据。先看代理节点的处理// DraggableListController.ts节选 export class DraggableListController extends Component { property(Node) public content: Node null!; // 列表容器列表项的直接父节点 property(Prefab) public listItemPrefab: Prefab null!; // 列表项预制体 property(Node) public dragLayer: Node null!; // 代理节点挂载层一般放 Canvas 下 private _model new DraggableListModel(); private _itemNodes: Node[] []; private _dragProxy: Node null!; private _currentDragId: string ; private _currentDragIndex: number -1; private _currentTargetIndex: number -1; // 默认列表项尺寸按你的预制体实际尺寸设 private readonly ITEM_HEIGHT 120; /** 拖拽开始时创建代理节点 */ private _onDragStart(itemId: string, touchPos: Vec3): void { const fromIndex this._model.indexOf(itemId); if (fromIndex 0) return; this._currentDragId itemId; this._currentDragIndex fromIndex; this._currentTargetIndex fromIndex; // 从原节点复制一份作为拖拽代理 const originalNode this._itemNodes[fromIndex]; this._dragProxy instantiate(originalNode); this._dragProxy.name originalNode.name _proxy; // 代理节点挂到 dragLayer且不参与实际列表的布局 this.dragLayer.addChild(this._dragProxy); this._dragProxy.setSiblingIndex(9999); // 代理节点位置跟随手 this._dragProxy.setPosition(touchPos); this._dragProxy.setScale(1.05); // 轻微放大视觉上「提起来」 // 原列表项进入「被拖拽」状态降低透明度高亮背景 originalNode.getComponent(ListItem)!.setDraggingVisual(true); } /** 拖拽移动中更新代理位置并计算目标插入下标 */ private _onDragMove(itemId: string, touchPos: Vec3): void { if (this._currentDragId ! itemId || !this._dragProxy) return; // 代理节点跟随触点移动 this._dragProxy.setPosition(touchPos); // 计算当前应该插入到哪个 index const targetIndex this._calcTargetIndex(touchPos); this._currentTargetIndex targetIndex; // 更新原列表中其他项的占位状态让位动画会在 3.4 节实现 this._updatePlaceholder(targetIndex); } /** 计算触点所在位置对应的目标插入 index */ private _calcTargetIndex(touchPos: Vec3): number { const itemCount this._model.items.length; if (itemCount 0) return 0; // 将触点坐标与各列表项中心点比较 for (let i 0; i itemCount; i) { const node this._itemNodes[i]; // 如果是被拖节点本身跳过比较 if (node.name this._currentDragId) continue; const pos node.position; // 垂直列表只比较 y横向列表则比较 x if (touchPos.y pos.y - this.ITEM_HEIGHT / 2) { return i; } } // 默认插到末尾 return itemCount - 1; } /** 拖拽结束统一更新数据与重刷列表 */ private _onDragEnd(itemId: string): void { if (this._currentDragId ! itemId) return; // 先算最终移动 const fromIndex this._currentDragIndex; const toIndex this._currentTargetIndex; // 更新数据数组 this._model.move(fromIndex, toIndex); // 移除代理节点 this._dragProxy.destroy(); this._dragProxy null!; // 重新构建全部列表项并播放归位动画 this._rebuildList(); // 清空拖拽状态 this._currentDragId ; this._currentDragIndex -1; this._currentTargetIndex -1; } }这里最关键的是_calcTargetIndex的判断逻辑它遍历所有列表项找到第一个「触点位置在它中心点之上」的项返回这个项的 index 作为插入目标。这样用户把节点拖到哪个位置系统就知道该插入到哪个 gap 里——不需要用户精确对准间隙只要拖过一半高度就判定为「插入到该项之前」符合直觉。3.4 让位动画与松手后的归位动画使用 tween 让视觉跟上逻辑数据重排与 UI 刷新之间必须有动画衔接否则拖拽结束后节点会直接瞬移体验非常生硬。这里用 Cocos Creator 自带的tween系统做平滑移动import { tween, Vec3 } from cc; /** 把列表项移动到指定位置带过渡动画 */ private _tweenMoveTo(node: Node, targetPos: Vec3, duration: number 0.15): void { tween(node) .to(duration, { position: targetPos }, { easing: quadOut }) .start(); } /** 拖拽结束时统一重建列表按新数据顺序逐个归位 */ private _rebuildList(): void { const items this._model.items; const nodeCount this._itemNodes.length; for (let i 0; i nodeCount; i) { const node this._itemNodes[i]; const itemId node.name; const newIndex items.findIndex(it it.id itemId); if (newIndex 0) { node.destroy(); this._itemNodes.splice(i, 1); i--; continue; } const targetPos this._calcNodePosition(newIndex); // 重置透明度状态并播放归位动画 node.getComponent(ListItem)!.setDraggingVisual(false); this._tweenMoveTo(node, targetPos, 0.2); } // 按新顺序重排 _itemNodes 数组保证后续索引计算正确 this._itemNodes.sort((a, b) { const aId a.name; const bId b.name; const aIdx items.findIndex(it it.id aId); const bIdx items.findIndex(it it.id bId); return aIdx - bIdx; }); } /** 根据 index 计算节点目标坐标 */ private _calcNodePosition(index: number): Vec3 { const spacing 10; const startY this.content.getComponent(UITransform)!.height / 2; const y startY - index * (this.ITEM_HEIGHT spacing) - this.ITEM_HEIGHT / 2; return new Vec3(0, y, 0); }参数说明duration控制在 0.15~0.25 秒之间太短生硬太长拖沓。easing 用quadOut即「快出慢停」视觉上是起步干脆、落地稳稳停住符合物理直觉不要用linear会感觉很机械。_rebuildList里并非真正重新创建节点而是复用已有节点、移动位置。这样避免销毁/实例化开销也避免触摸监听重新绑定。一个注意点列表项名字不能重复。代码里大量用node.name作为条目 id如果列表项名字重复排查错乱时你会被折磨很久。建议在初始化时强制检查唯一性。到此最小可用的拖拽排序已经把「数据重排、代理拖拽、插入判定、动画归位」串完整了。第五章会针对边界交互做扩展但核心已经闭环。先按下不表下一章先解决「代码能跑但体验稀碎」的四大高频坑。4. 拖拽排序的四大高频问题现象、原因与解决办法排查清单这部分我直接按「问题现象 → 根因 → 处理方式」的格式写。每个问题都是真实开发中最常遇到的——不是理论推导是血泪经验。4.1 拖拽时列表跟着滚动ScrollView 抢走触摸事件现象手指按住列表项下拉列表本身开始上下滚动被拖节点完全没有表现。根因ScrollView 的触摸响应优先级高于列表项。如果你的列表项预制体里有个背景节点覆盖了列表项整体区域事件会先被 ScrollView 消费掉。更常见的原因是你没阻止事件冒泡——TOUCH_START之后事件会继续向上传播ScrollView 在下层监听到触摸并开始滚动判断。处理在_onTouchStart里调用event.propagationStopped true;代码里已写。确保列表项根节点上挂了UITransform组件且尺寸覆盖整块可点击区域。如果某项不需要拖动比如头部的 header直接不给它挂 ListItem 组件就行。还有一个隐性坑Cocos Creator 3.x 中Node.EventType.TOUCH_*事件的propagationStopped是event上的属性不是touch。别写错成event.touch.propagationStopped——那样完全无效。4.2 拖到列表边缘时手指移出可视区列表不滚动现象列表有 20 项当前只显示 6 项拖拽第 2 项想拖到第 15 项的位置——但拖到屏幕边缘就停住了下面的项看不到。根因整个列表位于 ScrollView 的可视裁剪区域内拖拽代理虽然跟随手指移动可以越界但 ScrollView 本身没有收到滚动指令。这本质上是 ScrollView 的滚动与列表项拖拽的冲突问题上一节我们阻止了冒泡来解决滚动抢事件现在又需要额外的滚动逻辑来解决「列表不滚」。处理实现一个拖动时的边缘滚动检测// 在 _onDragMove 中追加调用 private _updateScrollByDrag(touchPos: Vec3): void { const scrollView this.node.getComponent(ScrollView)!; const viewHeight scrollView.node.getComponent(UITransform)!.height; const edgeThreshold 80; // 触发边缘滚动的距离像素 const scrollSpeed 400; // 滚动速度像素/秒 if (touchPos.y viewHeight / 2 - edgeThreshold) { // 手指靠近上边缘 → 向上滚即 content 向下移动 const delta (viewHeight / 2 - touchPos.y edgeThreshold) / edgeThreshold; scrollView.scrollToOffset( scrollView.getScrollOffset().add(new Vec2(0, -delta * scrollSpeed * dt)), 0.05 ); } else if (touchPos.y -viewHeight / 2 edgeThreshold) { // 手指靠近下边缘 → 向下滚 const delta (touchPos.y viewHeight / 2 edgeThreshold) / edgeThreshold; scrollView.scrollToOffset( scrollView.getScrollOffset().add(new Vec2(0, delta * scrollSpeed * dt)), 0.05 ); } }这里dt是帧间隔用this.scheduleOnce或直接读取director.getDeltaTime()。滚动方向容易搞反建议真机验证编辑器里如果开了模拟器也要注意坐标系。4.3 松手后节点瞬间跳回原位再动画移动到新位置——感觉用户操作的是「鬼影」现象拖拽过程一切正常松手后所有节点先瞬间回到旧位置然后才播放归位动画到新位置。用户看起来像是自己拖的东西「飞走了」。根因因为_onDragEnd里先调用了_model.move紧接着_rebuildList。而_rebuildList第一件事是遍历节点、计算新位置如果此时节点还停留在拖动过程中「假位置」它会先被一个隐形逻辑重置到旧坐标或者被 list 布局管理器刷新然后tween从旧坐标开始动画。没有「从当前位置出发」的插值起始坐标。处理在_rebuildList开始前先记录每个节点的当前position作为动画起点private _rebuildList(): void { const items this._model.items; // 记录每个节点当前位置作为动画起点 const startPosMap new Mapstring, Vec3(); for (const node of this._itemNodes) { startPosMap.set(node.name, node.position.clone()); } for (let i 0; i this._itemNodes.length; i) { const node this._itemNodes[i]; const itemId node.name; const newIndex items.findIndex(it it.id itemId); const targetPos this._calcNodePosition(newIndex); const startPos startPosMap.get(itemId)!; node.setPosition(startPos); this._tweenMoveTo(node, targetPos, 0.2); } }关键就一行node.setPosition(startPos)——手动把起点定住再启动 tween。不加这一行tween 会从节点当前的「真实位置」出发而那个位置在这一帧还没更新于是节点先闪回旧位再动画。4.4 快速拖动时列表项出现抖动/重叠现象手指滑得很快时列表项在拖拽过程中疯狂抖动甚至两三项重叠在一起。根因_calcTargetIndex的判断逻辑只在触点划过列表项中心线的瞬间切换目标 index。快速滑动时帧率跟不上触点一帧跳过了好几个列表项index 频繁变化每次变化都要触发_updatePlaceholder去挪动大量节点——动画还没播完又被打断视觉上就是抖动。处理两个方向配合一是把插入判定改为「触点与列表项中心点距离小于阈值」而不是「触点在中心点上方」这种区域判定private _calcTargetIndex(touchPos: Vec3): number { const itemCount this._model.items.length; let closestIndex itemCount - 1; let closestDist Number.MAX_VALUE; for (let i 0; i itemCount; i) { const node this._itemNodes[i]; if (node.name this._currentDragId) continue; const pos node.position; const dist Math.abs(touchPos.y - pos.y); if (dist closestDist) { closestDist dist; closestIndex i; } } return closestIndex; }二是让位动画加入防抖如果当前正在播放的让位动画还没结束不打断它等它完成再启动新的。实现上可以用tween的stop()前判断记录每个节点是否在动画中或者用tween(node).stop();然后立即设置一个极短的过渡时间。我的做法是——先stop()再重新.to()模拟器上效果稳定真机上抖动概率也大幅降低。以上四条是拖拽排序列表从「能跑」到「能交付」之间最常踩的坑。每一类我都见过真实项目栽进去。如果你遇到的问题不在这个清单里大概率是节点层级、透明度混合或 ScrollView 的具体配置问题可以再针对性排查。5. 从竖直到横排、从单列到网格列表布局变体的扩展思路很多列表不只是单列垂直的。游戏里常见的「背包」是 5 列网格设置页可能是横向滚动的标签栏。拖拽排序逻辑的核心「数据重排 代理拖拽 插入判定」可以直接复用但有几个点要分开处理插入方向的判定、网格翻页与跨行判断、以及自动化测试的稳定性。5.1 横排列表把 y 比较换成 x 比较垂直列表的_calcTargetIndex比较的是touchPos.y和各节点 y 坐标。横向列表只需private _calcTargetIndexHorizontal(touchPos: Vec3): number { const distThreshold this.ITEM_WIDTH / 2; let targetIndex this._model.items.length - 1; for (let i 0; i this._model.items.length; i) { if (this._itemNodes[i].name this._currentDragId) continue; const pos this._itemNodes[i].position; if (Math.abs(touchPos.x - pos.x) distThreshold) { targetIndex i; break; } } return targetIndex; }坐标系注意点Cocos Creator 的 UI 坐标系 y 轴向上横向列表通常从左到右x 越大越靠右。判断比较时别把 和 写反。5.2 网格列表最容易被忽视的「跨行插入」网格的插入判定复杂在跨行用户拖一个第 2 行的元素到第 1 行的第 1 格到底是插到整个列表的第 1 位还是插到第 1 行第 2 格我的建议是统一按「线性 index」处理把网格铺平成数组插入判定继续用「与各格子中心点的距离」找最近的格子 index。这样唯一需要额外处理的是数据与格子之间的映射// 将网格的行列坐标转为线性 index public colRowToIndex(col: number, row: number, colCount: number): number { return row * colCount col; }这个方案最简单且不出错。如果你要做更细腻的「同列吸附」等交互可以加一列额外的权重判断但阈值不好调容易弄巧成拙。网格拖拽建议先做「线性插入」用户反馈强烈需要再升级。5.3 让完美实现可获得从列表到预制体的完整示例为了让读者能跑起来把第三、四章内容拼装成一个可直接用的控制器组件放在一个项目里作为参考。// DraggableListController.ts 完整版含 ScrollView 支持 import { _decorator, Component, Node, Prefab, instantiate, ScrollView, UITransform } from cc; import { DraggableListModel, IDraggableItem } from ./DraggableListData; import { ListItem } from ./ListItem; const { ccclass, property } _decorator; ccclass(DraggableListController) export class DraggableListController extends Component { property(Node) public content: Node null!; property(Prefab) public listItemPrefab: Prefab null!; property(Node) public dragLayer: Node null!; property({ tooltip: 列表排列方向Horizontal 或 Vertical }) public orientation: Vertical | Horizontal Vertical; private _model new DraggableListModel(); private _itemNodes: Node[] []; private _dragProxy: Node null!; private _currentDragId ; private _currentDragIndex -1; private _currentTargetIndex -1; // 你的条目尺寸常量 private readonly ITEM_SIZE this.orientation Vertical ? 120 : 200; // 初始化并加载列表 public loadItems(items: IDraggableItem[]): void { this._model.setup(items); this._itemNodes []; this.content.removeAllChildren(); for (const item of this._model.items) { const node instantiate(this.listItemPrefab); this.content.addChild(node); node.name item.id; const listItem node.getComponent(ListItem)!; listItem.init(this._model, { onDragStart: this._onDragStart.bind(this), onDragMove: this._onDragMove.bind(this), onDragEnd: this._onDragEnd.bind(this), }, item.id); this._itemNodes.push(node); } // 按初始顺序摆放 this._refreshPositions(false); } private _refreshPositions(animated: boolean): void { for (let i 0; i this._itemNodes.length; i) { const pos this._layoutPosition(i); if (animated) { this._tweenMoveTo(this._itemNodes[i], pos, 0.2); } else { this._itemNodes[i].setPosition(pos); } } } private _layoutPosition(index: number): Vec3 { const spacing 10; if (this.orientation Vertical) { const h this.content.getComponent(UITransform)!.height; return new Vec3(0, h / 2 - index * (this.ITEM_SIZE spacing) - this.ITEM_SIZE / 2, 0); } else { const w this.content.getComponent(UITransform)!.width; return new Vec3(-w / 2 index * (this.ITEM_SIZE spacing) this.ITEM_SIZE / 2, 0, 0); } } private _onDragStart(itemId: string, touchPos: Vec3): void { const fromIndex this._model.indexOf(itemId); if (fromIndex 0) return; this._currentDragId itemId; this._currentDragIndex fromIndex; this._currentTargetIndex fromIndex; const originalNode this._itemNodes[fromIndex]; this._dragProxy instantiate(originalNode); this._dragProxy.name originalNode.name _proxy; this.dragLayer.addChild(this._dragProxy); this._dragProxy.setSiblingIndex(9999); this._dragProxy.setScale(1.05); this._dragProxy.setPosition(touchPos); originalNode.getComponent(ListItem)!.setDraggingVisual(true); } private _onDragMove(itemId: string, touchPos: Vec3): void { if (this._currentDragId ! itemId || !this._dragProxy) return; this._dragProxy.setPosition(touchPos); const targetIndex this._calcTargetIndex(touchPos); if (targetIndex ! this._currentTargetIndex) { this._currentTargetIndex targetIndex; this._updatePlaceholder(targetIndex); } } private _onDragEnd(itemId: string): void { if (this._currentDragId ! itemId) return; this._model.move(this._currentDragIndex, this._currentTargetIndex); this._dragProxy.destroy(); this._dragProxy null!; this._rebuildList(); this._currentDragId ; this._currentDragIndex -1; this._currentTargetIndex -1; } // 占位更新让除被拖项以外的节点按目标 index 做位置微调 private _updatePlaceholder(targetIndex: number): void { const items this._model.items; let draggedSkipped false; for (let i 0; i items.length; i) { const node this._itemNodes[i]; if (node.name this._currentDragId) { draggedSkipped true; continue; } const logicalIndex draggedSkipped ? i - 1 : i; const pos this._layoutPosition(logicalIndex targetIndex ? logicalIndex : logicalIndex 1); this._tweenMoveTo(node, pos, 0.1); } } }这个文件直接把前面的零散逻辑收拢成了一个可挂载组件。把它挂到 Canvas 下的任意空节点上给content、listItemPrefab、dragLayer赋值然后调用loadItems([...])即可。对于还想深入的朋友编码时你应该再考虑一件事——如果同步地让原列表「让位」体验层次会再拉开一截。5.4 让位逻辑从代理到原位节点这里我们实现常见的「排挤」让位效果——拖拽过程中原节点位置会实时让出一个空位给被拖节点其余节点流式填充。private _updatePlaceholder(targetIndex: number): void { for (let i 0; i this._itemNodes.length; i) { const node this._itemNodes[i]; if (node.name this._currentDragId) { continue; // 被拖节点保持原位不参与让位 } const curIdx i; let targetIdx curIdx; if (this._currentDragIndex targetIndex) { // 被拖项向下移中间项上移一位 if (curIdx this._currentDragIndex curIdx targetIndex) { targetIdx curIdx - 1; } } else if (this._currentDragIndex targetIndex) { // 被拖项向上移中间项下移一位 if (curIdx targetIndex curIdx this._currentDragIndex) { targetIdx curIdx 1; } } const targetPos this._layoutPosition(targetIdx); this._tweenMoveTo(node, targetPos, 0.12); } }这时的_rebuildList会因_tweenMoveTo的引入自动顺滑落地。说一下参数0.12秒是让位动画的舒适区。过快显得生硬过慢则拖拽时其他节点跟不上手指速度产生延迟感。targetIndex 判定逻辑与_calcTargetIndex保持一致否则会出现「数据说要到这里但视觉留出的位置不对」的错位。顺带提一嘴真机测试的口头经验这类拖拽交互一定要在真机上试编辑器的模拟触摸和真实手指在响应速度上差异极大。不要只在编辑器里点两下就说做完了——那个坑我栽过一次后来上真机发现灵敏度差得离谱差点推翻重做。6. 拖拽期间的分帧布局与 setSiblingIndex 的性能陷阱拖拽排序列表做到「功能完整、动画流畅」之后还有一个容易被忽略但直接决定上限的问题性能。尤其是当列表达到几十上百项时帧率可能突然掉下去而很多开发者第一时间怀疑的是「节点太多没法优化」——其实不是大多数时候是算法和操作方式的问题。6.1 setSiblingIndex 的隐性 O(n) 代价先看一个看似无害的操作this.node.setSiblingIndex(0);每次调用setSiblingIndex引擎都会触发节点在父节点 children 数组中的重新排序。如果父节点有 50 个子节点单次操作复杂度的量级是 O(n)如果你在拖拽过程中每一帧都对多个节点调用setSiblingIndex很快帧时间就会从 1ms 涨到 8ms 甚至更高。处理不要在拖拽过程中频繁重排节点索引。正确的打开方式拖拽过程中的层级调整只作用于dragLayer代理节点始终保持最高层级其他节点一律不动。松手后的列表重排是为数不多的合法使用setSiblingIndex的场景。此时一次性调整所有节点的层级顺序然后立即布局不会造成过多性能损耗。6.2 多节点 tween 并发控制同时动画的节点数量让位动画和归位动画都需要挪动节点但如果列表很长100 项一轮动画会让 100 个节点的tween同时运行。Cocos Creator 的 tween 系统在并发高时会明显吃掉 CPU 时间片。处理手段有三种复用同一批 tween 实例不要每次创建新tween(node)而是node上的 tween 先stop()再复用.to()方法。透明度和缩放动画尽量合并到同一个 tween 序列里不要分开启动多个tween(node)。如果列表很长且不需要动画提供一个开关列表项超过 30 项时默认关闭动画直接瞬移。这样在极端场景下保证功能可用体验退化可接受。private _tweenMoveTo(node: Node, targetPos: Vec3, duration: number): void { // 如果已经存在 tween先停掉再复用 tween(node).stop(); tween(node) .to(duration, { position: targetPos }, { easing: quadOut }) .start(); }6.3 性能预算与真机调优最后给一个实际可用的参考预算在 60fps 目标下拖拽排序列表的 CPU 时间不含渲染应控制在 5ms 以内。如果超出优先检查是否在拖拽循环中调用了getComponent应预先缓存是否频繁访问UITransform的convertToNodeSpaceAR坐标转换有开销尽量每帧只算一次而不是多次代理节点是否意外带有大量子节点和实时阴影去掉实时阴影、降低代理节点的渲染复杂度会立刻看到帧率回升。把这些检查项跑一遍通常能稳定解决列表一大就卡的问题。最后说一个个人习惯每次做完列表类交互我一定会做一轮真机快速滑动测试同时开着性能统计面板盯帧耗。所有拖拽相关的「差不多」最后都会变成用户端的「好卡」或「好难用」。希望这篇文章能让你少走几段弯路做出来的列表一次过关。祝顺利。本文还有配套的精品资源点击获取