简介一套面向 Cocos Creator 开发者的拖拽排序列表演示工程适合已了解场景图、节点层级与组件使用希望实现列表项自由拖动并实时重排的初中级开发者。工程围绕触摸或鼠标事件监听、拖拽状态记录与排序结果持久化展开可用于商品列表、背包、任务栏等需要自定义顺序的界面原型。压缩包共16个文件约243KB主要由 json 配置、meta 资源描述、TypeScript 脚本以及 fire 场景文件构成体积轻便便于导入编辑器后按模块查阅。已有115人学习说明该案例对同类交互需求具有一定参考价值。通过场景与脚本的对应关系读者可以梳理拖拽开始、移动、结束三个阶段的处理逻辑观察数组顺序如何驱动节点位置刷新并据此提炼视图更新与性能优化的排错思路快速复用到自己的排序列表功能中。1. 三十个背包格子里的一次拖拽这套 Cocos Creator 拖拽排序列表到底管什么背包里塞了三十件装备玩家按住其中一件往上拖中间那几格要顺势让位松手之后顺序还得存进存档下次进来不能变。这就是一份 Cocos Creator 拖拽排序列表源码包最典型的落地场景它要解决的问题很具体包含监听触摸、把手指坐标换算成列表局部坐标、让其它格子主动让位最后把视觉顺序和内存里的数据数组对齐。这套东西不是某一个 API而是一套把事件、布局、占位节点、数据回写串起来的小系统。它适合两类人一类是做背包、技能栏、卡组编排、任务队列这种“顺序即语义”的界面cocos-creator里现成的 ScrollView 加 Layout 组合直接拿来拖一定会翻车另一类是把这套逻辑抽成通用组件、准备在多个面板复用的团队。包里给的不是拖拽特效是拖拽之后“顺序怎么算、数据怎么存、边界怎么守”这三件事。2. 拆骨架DragLayer 占位节点 Layout 的三层结构为什么比直接改 siblingIndex 稳先把最容易被低估的一点讲清楚拖拽排序的难点从来不在“跟着手指走”而在于“其它节点怎么让”。让位这件事Cocos Creator 已经用 Layout 组件做了一大半你要做的是别跟它抢活。2.1 在 Layout 容器里直接拖 item为什么会抖Layout 组件的职责非常明确它按 siblingIndex 顺序排列子节点一旦layout.updateLayout()被调用或者布局参数变化触发刷新它就会把所有子节点的position重新写一遍。这意味着如果被拖拽的 item 仍然是 Layout 容器的子节点就同时有两个“作者”在改它的坐标你的 TOUCH_MOVE 想让它的位置跟手移动Layout 想把它放回格子里。结果就是肉眼可见的抖动、拖到一半弹回去或者拖得动但松手瞬间位置错乱。更隐蔽的一层是你在拖动过程中为了提高落点精度去调setSiblingIndex()这个动作本身就会打乱 Layout 的子节点顺序进而触发重排于是所有 item 一起闪一下。有人在 TOUCH_MOVE 里每帧调updateLayout()帧率直接被拉到三十以下列表一长就更明显。比较稳妥的结构是把“被拖的节点”从 Layout 容器里摘出去容器里留一个同尺寸的占位节点参与排布。占位节点不动手只负责“占坑”Layout 只排它被拖节点在一个不受 Layout 管束的层里自由移动。这样一来两个作者各管各的边界清晰。2.2 三层结构的职责划分推荐的节点树是这样text只是示意层级实际工程里节点名按项目习惯改就行BagPanel ├── ScrollView (ScrollView view/Mask) │ └── view │ └── content (Layout: GRID / VERTICAL挂 DragSortList) │ ├── item_0 (ItemView预制体实例) │ ├── item_1 │ ├── ... │ └── placeholder (Sprite 半透明底默认 active false) └── DragLayer (空节点与 ScrollView 同级节点顺序在其之上)三层各自管什么最好一开始就定死后面所有代码都围绕这个分工作业节点挂载组件职责容易出问题的地方contentLayout DragSortList排序容器负责最终顺序拖着 item 不放和 Layout 抢位置placeholderSprite UITransform记录“当前落点”让其它 item 让位尺寸没同步导致让位后错位DragLayer无仅 UITransform承载被拖节点隔离 Layout层级盖不住 ScrollView 的遮罩itemItemView UITransform展示单条数据尺寸随内容变化时占位没跟上DragLayer 必须是 ScrollView 的兄弟节点而不是子节点。放在 content 里会被 Mask 裁剪拖到边缘时 item 直接消失一半放在 view 里则会被 ScrollView 的滚动偏移带动出现“item 跟着列表一起滚”的诡异效果。2.3 原点对齐这件事能省掉一半坐标 bug上面那套结构里唯一需要提前确认的是坐标系。content 的锚点通常是(0.5, 1)顶部对齐的垂直列表DragLayer 的锚点通常是(0.5, 0.5)两者原点根本不在一个位置。这时候如果你图省事直接拿dragging.position.y去和item.position.y比大小得到的顺序必然是错的。我的做法是统一走一次“世界坐标 → content 局部坐标”的换算任何比较之前先把被拖节点的位置折算到 content 的坐标系里// 把任意节点的世界位置换算成 content 的局部坐标 private toContentSpace(node: Node): Vec3 { const contentUT this.node.getComponent(UITransform)!; return contentUT.convertToNodeSpaceAR(node.worldPosition); }convertToNodeSpaceAR的“AR”是 anchor relative即以目标节点的锚点为原点。换算出来的y值在垂直列表里就是“离 content 原点多远”可以直接和子节点的position.y比。这套换算多做一次的代价是一次矩阵乘法比你在每个分支里手动加减锚点偏移靠谱得多。顺带提醒一个版本差异3.x 里尺寸和换算都在UITransform上2.x 里是node.width/height和node.convertToNodeSpaceAR()两个大版本的 API 名字接近但宿主不同迁移的时候这类调用要全量搜一遍。3. 把触摸事件接上从 TOUCH_START 到落点换算的完整 TypeScript 实现结构定好之后剩下的就是三段时间轴按下时记录状态移动时算落点抬手时换数据。下面这套代码是 3.x 写法可以直接抄进组件里跑。3.1 触摸开始三个必须记下来的值按下这一瞬间要记的东西只有三样但每一样漏了都会出问题起点下标、手指相对 item 中心的偏移、以及 item 换算到 DragLayer 之后的世界坐标。偏移不记item 会“啪”一下跳到手指正下方世界坐标不换算setParent 之后 item 会瞬移到屏幕角落。import { _decorator, Component, Node, UITransform, Vec3, EventTouch, Layout, ScrollView, tween, v3 } from cc; const { ccclass, property } _decorator; ccclass(DragSortList) export class DragSortList extends Component { property(Node) dragLayer: Node null!; property(Node) placeholder: Node null!; property(ScrollView) scrollView: ScrollView null!; property(Layout) layout: Layout null!; property({ tooltip: 抬手回位的补间时长秒 }) snapTime 0.12; private dragging: Node | null null; // 正在被拖的 item private dragIndex -1; // 起点在 dataList 里的下标 private targetIndex -1; // 当前落点在“抽掉被拖项后”的数组里的下标 private touchOffset new Vec3(); // 手指与 item 中心的偏移content 局部 /** 由 ItemView 在 TOUCH_START 时回调把自身和下标传进来 */ public beginDrag(item: Node, index: number, e: EventTouch) { if (this.dragging) return; // 状态锁一次只允许拖一个 this.dragging item; this.dragIndex index; this.targetIndex index; const contentUT this.node.getComponent(UITransform)!; const ui e.getUILocation(); const local contentUT.convertToNodeSpaceAR(v3(ui.x, ui.y, 0)); // 记录手指相对 item 中心的偏移避免 item 一上手就跳到手指中心 this.touchOffset.set(local.x - item.position.x, local.y - item.position.y, 0); // 占位节点就位尺寸同步、插到当前下标、显形 const size item.getComponent(UITransform)!.contentSize; this.placeholder.getComponent(UITransform)!.setContentSize(size); this.placeholder.setSiblingIndex(item.getSiblingIndex()); this.placeholder.active true; // 摘下 item保持它在屏幕上的位置不变 const world item.getComponent(UITransform)!.convertToWorldSpaceAR(Vec3.ZERO); item.setParent(this.dragLayer); item.setWorldPosition(world); item.setSiblingIndex(this.dragLayer.children.length - 1); // 关掉 ScrollView 的触摸监听避免一边拖一边滚 this.scrollView.enabled false; } }几个参数值得单独说。setSiblingIndex(this.dragLayer.children.length - 1)是把被拖节点顶到 DragLayer 的最上层防止它被同层其它节点遮住this.scrollView.enabled false依赖 ScrollView 在onEnable里注册触摸监听、在onDisable里注销这套生命周期不同小版本可能有出入升级引擎后建议拿一条长列表实测一遍。snapTime默认给 0.12 秒是因为低于 0.08 秒人眼几乎看不出回位过程会觉得 item 是“闪”回去的高于 0.2 秒又会觉得拖拽有延迟。3.2 移动阶段落点计算与“只在变化时重排”移动阶段有一句非常关键的判断只有落点下标变了才调layout.updateLayout()。这一句直接决定了列表在低端机上是流畅还是卡成幻灯片。public moveDrag(e: EventTouch) { if (!this.dragging) return; const contentUT this.node.getComponent(UITransform)!; const ui e.getUILocation(); const local contentUT.convertToNodeSpaceAR(v3(ui.x, ui.y, 0)); // 手指位置减去按下时的偏移就是 item 中心应该在的位置 const center v3(local.x - this.touchOffset.x, local.y - this.touchOffset.y, 0); const world contentUT.convertToWorldSpaceAR(center); this.dragging.setWorldPosition(world); const next this.calcTargetIndex(); if (next ! this.targetIndex) { this.targetIndex next; this.placeholder.setSiblingIndex(next); this.layout.updateLayout(); // 整段拖拽里这里最多被调用 itemCount 次 } this.tryAutoScroll(local.y); } /** 参与排布的子节点排除占位节点顺序即“抽掉被拖项之后的数据顺序” */ private getItems(): Node[] { return this.node.children.filter(n n ! this.placeholder n.activeInHierarchy); }getItems()这个过滤器是整个索引换算的基础。被拖节点此刻已经不在 content 里了所以取出来的数组天然等于“dataList 去掉dragIndex那一项”。后面算出来的落点下标正好可以直接用在“先 splice 删除、再 splice 插入”的逻辑里不需要额外补偿。很多人在这里手动-1、1地凑越凑越乱根因就是没想清楚数组到底代表哪一段。calcTargetIndex()的具体算法按列表形态分三种下一章展开。tryAutoScroll负责拖到边缘时自动滚放到 4.3 讲。3.3 抬手与中断补间、回挂与数据换位抬手这一段最容易漏的是 TOUCH_CANCEL。手指划出屏幕边缘、系统弹窗抢焦点、来电打断都会触发 CANCEL 而不是 END只接 END 的结果就是 item 永远悬在 DragLayer 上玩家再点一次界面直接崩。public endDrag(e: EventTouch, cancelled false) { if (!this.dragging) return; const item this.dragging; const from this.dragIndex; const to cancelled ? from : this.targetIndex; // 1) 数据层先换位保证逻辑顺序和视觉顺序同源 if (to ! from) { const [data] this.dataList.splice(from, 1); this.dataList.splice(to, 0, data); } // 2) 视觉层补间回占位位置再挂回 content const target this.placeholder.worldPosition.clone(); tween(item) .to(this.snapTime, { worldPosition: target }, { easing: quadOut }) .call(() { item.setParent(this.node); item.setSiblingIndex(this.placeholder.getSiblingIndex()); this.placeholder.active false; this.layout.updateLayout(); this.scrollView.enabled true; this.dragging null; this.dragIndex -1; this.targetIndex -1; this.onOrderChanged(this.dataList.map(d d.id)); }) .start(); }cancelled这个分支值得单独强调中断时to from也就是数据不动item 原路返回。有些实现图省事中断时直接销毁重建整个列表功能上是通的但玩家正在看的那一屏会整个刷新一次体验断层很明显。tween(item).to(..., { worldPosition: target })这行的写法依赖 Node 的worldPosition有公开 setter3.x 是支持的。如果你担心不同小版本对 Vec3 属性的插值行为不一致退一步改成补间一个临时 Vec3、在onUpdate里setWorldPosition也行多写五行的代价换确定性。最后onOrderChanged()里传出去的是 id 数组不是 node 数组。node 顺序只是渲染层的产物下一帧某个 item 被回收复用顺序就变了这个区别在第五章还会再提一次。4. 列表形态适配垂直列表、九宫格与不等高条目的三套索引算法同一个拖拽组件换个Layout.Type就要换一套落点算法。包里给了三套选哪套只看你的列表长什么样不看别的。4.1 垂直列表按中心线计数法等高的垂直列表是最简单的情况判断标准可以简化成一句话有多少个 item 的中心在拖拽中心之上落点就是几。private calcTargetIndex(): number { const items this.getItems(); const dragLocal this.toContentSpace(this.dragging!); let idx 0; for (let i 0; i items.length; i) { // 垂直向下增长的列表y 越大越靠上Layout 默认从顶往下排 if (items[i].position.y dragLocal.y) idx; } return idx; }这个方法的时间复杂度是 O(n)n 是可见 item 数量四十个以内完全无感。它的隐含假设是“所有 item 等高且等间距”一旦某个格子因为文案换行撑高了一点中心线比较就会出现一两格的偏差。这时候要换成上一章那套解法用“最近的邻居”来判断代价是多一次遍历。items[i].position.y dragLocal.y里的大于号方向跟你的 Layout 起始方向绑定。Layout.Type.VERTICAL默认是从上往下排第一个子节点在最上面所以 y 递减如果你在 content 上加了Widget或者手动翻转过符号要跟着翻。这是最容易复制粘贴出错的一行。4.2 九宫格最近中心点加边界钳制Layout.Type.GRID下纵坐标比较就失效了得改算平面距离。思路是遍历所有格子中心取距离最小的那个作为落点private calcGridIndex(): number { const items this.getItems(); const dragLocal this.toContentSpace(this.dragging!); let best 0; let bestDis Number.MAX_VALUE; for (let i 0; i items.length; i) { const d Vec3.squaredDistance(items[i].position, dragLocal); if (d bestDis) { bestDis d; best i; } } // 边界钳制拖出列表末尾时最近点仍可能落在倒数第二格 const last items.length - 1; const cellW items.length 1 ? Math.abs(items[1].position.x - items[0].position.x) : 0; if (cellW 0 dragLocal.x items[last].position.x cellW / 2) { best last; } return best; }用squaredDistance而不是distance是为了省掉每次比较里的开方这个优化在两三个格子上无所谓但拖拽时每帧调一次四十格就是每帧四十次开方能省则省。边界钳制这段是踩过坑才加的。九宫格最后一行的格子数往往不满拖到最后一行右侧的空白区域时距离最近的是倒数第二格玩家松手会发现东西插到了错误的位置。用“超过最后一格中心线半个格子宽就算越界”这条规则能把这种情况兜住。cellW从第 0、1 两格的 x 差值取前提是前三格在同一行如果你的列数小于 3这条就失效了得改成从 Layout 的paddingLeft和cellSize直接算。4.3 不等高条目与长列表什么时候该放弃 Layout两种情况下我会建议直接放弃 Layout自己算位置第一种是条目高度由内容决定聊天记录、评论区、任务描述。Layout 虽然有resizeMode CONTAINER能自适应但每次内容变化都触发全量重排拖动过程中的落点计算还得挨个读UITransform.height累加代码量和运行时开销都不划算。这时候用“前缀和数组加二分查找”定位落点更直接维护一个offsets[i]表示第 i 项顶部的 y 坐标拖动时对dragLocal.y做一次二分落在哪个区间就是哪个下标。第二种是可见条目超过六十个。哪怕有 Mask 裁剪Layout 依然会排布所有子节点updateLayout()的耗时会随着总数线性增长。常见的做法是做虚拟列表只实例化可视区加缓冲区的节点靠content的高度撑出滚动条滚动时复用节点并刷新数据。虚拟列表配拖拽排序麻烦点在于被拖的 item 可能在滚动过程中被回收所以拖动期间要给它打一个“禁止回收”的标记。至于拖到边缘自动滚我的实现是这样的private tryAutoScroll(localY: number) { const view this.scrollView.content!.parent!; const halfH view.getComponent(UITransform)!.height / 2; const edge Math.min(80, halfH * 0.25); // 触发区最多 80px且不超过视口四分之一 const speed 12; if (localY halfH - edge) { const off this.scrollView.getScrollOffset(); this.scrollView.scrollToOffset(new Vec2(off.x, off.y speed), 0); } else if (localY -halfH edge) { const off this.scrollView.getScrollOffset(); this.scrollView.scrollToOffset(new Vec2(off.x, off.y - speed), 0); } }edge取halfH * 0.25是为了在矮屏上不让触发区吃满整个视口否则手指一进列表就开始滚根本没法选中。speed是个经验值每帧 12 像素在 60 帧下差不多 720px/s长列表翻一屏大概一秒出头。方向对不对跟 ScrollView 的vertical设置和 content 锚点有关第一次接的时候一定实机试一遍符号我在这上面浪费过半小时。5. 避坑与排查拖拽排序最容易翻的五个车这五条是按“复现频率”排的每一条都在真机上出现过不是理论推演。5.1 手指划出列表再松开item 悬在半空不回来现象拖到屏幕边缘外面松手item 停在 DragLayer 上不动占位节点还亮着再点别的格子直接叠罗汉。原因只监听了TOUCH_END没监听TOUCH_CANCEL。触摸被系统或父级节点拦截时引擎派发的是 CANCEL 而不是 ENDendDrag根本没被调用。还有一种是setParent到 DragLayer 之后item 的触摸回调注册在旧父节点的冒泡链上事件收不到了。解决在 item 上同时注册TOUCH_END和TOUCH_CANCEL两个都走endDragCANCEL 传true。更稳的做法是把触摸监听挂在 content 上用e.target判断是哪个 item这样 setParent 不影响事件链。另外在onDisable里加一道兜底如果this.dragging还在直接复位防止界面被关闭时残留状态。5.2 轻轻一划整个列表跟着滚item 纹丝不动现象想拖某个格子结果触发的是 ScrollView 的滑动列表滚了格子没动。原因ScrollView 的触摸监听注册在自己的节点上item 的 TOUCH_MOVE 冒泡上去之后ScrollView 认为这是一次滚动。引擎默认不做“移动阈值”判断手指一动它就接管。解决两条路选一条。轻量做法是beginDrag里this.scrollView.enabled false抬手时恢复前面代码里用的就是这个它的副作用是拖动期间不能用 ScrollView 的原生惯性但自动滚动可以照常用scrollToOffset。另一条路是给 item 加移动阈值TOUCH_START 时只记录坐标TOUCH_MOVE 里位移超过 6 像素才真正进入拖拽状态没超过就把事件放行给 ScrollView。后者的体验更接近原生代价是代码多一个状态位。5.3 当前页面顺序是对的退出再进顺序又复原了现象拖完看着没问题切个页签回来、或者重新打开背包顺序回到初始状态。原因只改了节点的siblingIndex没改数据数组。节点顺序是渲染结果一旦列表重建、对象池复用、或者走了一遍排序逻辑视觉顺序就会被数据顺序覆盖回去。解决把dataList当成唯一事实来源先改数组再让节点跟着数组走。代码里的顺序是“先 splice 数据再补间回位最后按占位下标设 siblingIndex”这个顺序不能倒。补一个自检函数在拖拽结束时跑一遍比较dataList[i].id和node.children[i]上挂的 id对不上就在控制台报红字问题当场暴露。5.4 手快连点两个 item两个都飞起来了现象快速在 A 上按下、还没抬起又点到 BDragLayer 上叠了两个 item占位节点也出现两个。原因缺状态锁。beginDrag里没有if (this.dragging) return或者补间还没结束就把dragging置空了第二次按下时状态判断失效。解决开头加锁末尾解锁中间补间结束前不解锁。注意解锁要放在tween的call里不能放在tween().start()后面——那行是同步执行的等于没锁。如果产品要求“拖着 A 的时候点 B 要能切换”那就得先走一遍endDrag再beginDrag别指望两个同时存在。5.5 拖到末尾所有格子集体错位一格现象往下拖得越远错位越明显最后一格怎么都拖不到。原因把siblingIndex直接当数据下标用了。content 里除了 item 还可能有别的东西顶部留白节点、空态提示、加载更多的按钮、占位节点本身。这些也在children里占着 index一旦拿它去索引dataList从第一个非 item 节点往后全部偏移。解决所有下标运算都走getItems()过滤之后的数组永远不要直接读node.getSiblingIndex()当数据下标。占位节点设置 siblingIndex 的时候倒是可以用真实下标因为它确实要插在 children 的对应位置上。这两个坐标系混用是这类组件最隐蔽的 bug写的时候在注释里标清楚“这里是 children 下标”还是“这里是数据下标”省的是自己的时间。6. 排序落地与验证把 node 顺序和 dataList 顺序绑死的一个土办法拖拽做完只是半成品真正决定这套东西能不能上线的是“顺序有没有被存下来”和“下次加载能不能还原”。我一般的收尾方式很简单拖拽结束之后立刻做一次断言把不一致当场喊出来别等策划来提 bug。/** 拖拽结束后立即自检node 顺序、dataList 顺序、存档 id 三者必须一致 */ private assertOrder(): boolean { const nodes this.getItems(); let ok nodes.length this.dataList.length; for (let i 0; ok i nodes.length; i) { const view nodes[i].getComponent(ItemView)!; if (view.dataId ! this.dataList[i].id) { console.error([DragSortList] 第 ${i} 项错位node${view.dataId} data${this.dataList[i].id}); ok false; } } // 存档侧只存 id不存 node 名重建列表时按下标回填 const snapshot JSON.stringify(this.dataList.map(d d.id)); if (ok snapshot ! this.lastSnapshot) { this.lastSnapshot snapshot; console.log([DragSortList] 顺序已变更, snapshot); // 这里对接你的存档 / 协议层 } return ok; }这段代码的价值不在断言本身而在于它把三套顺序强制对齐渲染层的 children、内存里的dataList、以及要落盘或发协议的 id 数组。三者任意一个出现偏差console.error会直接指出是第几项、node 侧是什么、data 侧是什么排查时间从半小时压缩到十秒。还有一个容易被忽略的点lastSnapshot做的是去重。玩家拖完又拖回原位顺序没变这时候不该再发一次协议或者写一次存档。很多项目里背包顺序异常最后查出来是频繁写入导致的读写竞态不是排序算错了。顺便给个还原的入口从存档读回来的时候不要重建节点顺序而是按 id 给已有节点排序/** 按存档里的 id 顺序重排节点不重建节点 */ public applyOrder(ids: string[]) { const map new Mapstring, Node(); this.getItems().forEach(n map.set(n.getComponent(ItemView)!.dataId, n)); ids.forEach((id, i) { const n map.get(id); if (n) n.setSiblingIndex(i); // 按 id 顺序重排 siblingIndex }); this.layout.updateLayout(); }这套写法依赖 id 唯一重复 id 会静默丢掉后面的项所以数据层要做唯一性约束别指望组件兜。还有一点setSiblingIndex在循环里调用的时间复杂度不是 O(1)项数上百时会有明显卡顿那种规模建议直接按 id 顺序实例化节点别在原有节点上折腾。从那以后我每次接拖拽排序都会先把assertOrder和applyOrder这两个函数写出来再写拖拽逻辑——先把“怎么验证”想明白再动手写“怎么拖”返工率能降一大截。希望帮到你。本文还有配套的精品资源点击获取