Cocos Creator 3.x拖拽排序列表:ScrollView联动与坐标换算实战
简介面向 Cocos Creator 开发者的拖拽排序列表实现资源适合游戏背包、任务清单、设置面板等需要自定义排列顺序的界面场景。压缩包共 16 个文件核心包含 3 个 TS 脚本、1 个 .fire 场景文件、4 个 meta 元数据及 5 个 JSON 配置另附 .gitignore 等辅助文件整体仅 243KBJSON 用于工程与场景配置meta 记录资源导入信息。脚本围绕拖拽移动、排序存储和状态更新三个功能组织通过事件监听捕获拖拽开始、移动与结束利用数组实时记录顺序并更新节点位置完成重新排列场景文件搭建了可直接运行的列表示例。已有 114 人学习适合想通过小型 Demo 掌握 Cocos Creator 事件系统与坐标换算的开发者。资源目录结构清晰既可在 Creator 工程中导入参考也可提取核心脚本复用到实际项目中节省从零实现拖拽排序功能的时间。1. 拖拽排序列表为什么我建议你直接抄这套实现做 UI 列表的同学迟早会遇到一个需求让玩家手动调整列表项的顺序比如背包整理、任务优先级、排行榜自定义。网上能搜到的 Cocos Creator 拖拽排序教程不少但大多只演示了在固定容器里拖一拖换到真实项目里一接 ScrollView触点坐标错位、列表跟着乱滚、拖拽项穿模各种翻车现场。这套基于 Cocos Creator 3.x 的拖拽排序列表实现把从监听器搭建、坐标换算到滚动联动一整套流程走通了直接复制改参数就能用。适合正在做背包、任务、设置页这类需要手排顺序的界面开发也适合刚接触 UI 拖拽想搞明白坐标系底层逻辑的同学。它的核心思路不复杂拖拽的本质不是移动节点而是先翻译触点坐标再决定是重排还是滚动想明白这点后面所有代码都顺了。2. 拖拽排序的技术选型为什么用位置换算而不是直接挪子节点很多第一次做拖拽排序的人第一反应是给列表项挂一个点击监听然后在 onTouchMove 里直接改节点 position。这个思路在静止列表里确实能跑通但一旦列表超过屏幕高度需要滚动或者列表项大小不一致问题就全出来了。先讲清楚底层逻辑拖拽排序要解决的不只是“跟着手指走”而是“手指移动时判断该把当前项插到哪个位置同时让其他项让出位置”。这需要把触点从屏幕坐标换算到目标节点的本地坐标再对比每个列表项的中心点来完成排序。这套实现选择基于节点本地坐标的换算方案而不是用全局坐标做比较。原因是 Cocos Creator 3.x 的 UITransform 提供了便捷的 convertToNodeSpaceAR 方法能把世界坐标转成某个节点锚点下的相对坐标省去手动减位置的手续代码也更不容易出错。另一个关键选型点是监听器挂在哪列表项上只挂点击和移动的回调而滚动判断放在父容器层处理。这样各司其职事件不会互相污染。// 假设 listItem 是单个列表项节点绑定以下事件 listItem.on(Input.EventType.TOUCH_START, this.onTouchStart, this); listItem.on(Input.EventType.TOUCH_MOVE, this.onTouchMove, this); listItem.on(Input.EventType.TOUCH_END, this.onTouchCancel, this); listItem.on(Input.EventType.TOUCH_CANCEL, this.onTouchCancel, this);这段代码把四个事件统一绑定在列表项上。TOUCH_CANCEL 必须绑定因为拖拽中途手指滑出列表项区域或系统中断触摸时如果没有它节点会状态不一致表现为列表项卡住不再跟随手指。参数说明this 是监听回调的 target确保回调里能用组件的实例方法。对于 Cocos Creator 3.x事件名统一走 Input.EventType 枚举字符串写法的兼容性更差建议直接枚举。接下来要处理一个容易忽略的细节拖拽过程中其他列表项是不响应触摸的否则手指滑到别的项上会误触发它的事件。常见做法是在 onTouchStart 时记录一个布尔标志位并在 onTouchMove 里先判断标志位再执行逻辑。这里不需要做复杂的八方向手势识别因为拖拽排序本身就是线性的只需要关注纵向移动。private isDragging: boolean false; private draggedItem: Node | null null; private onTouchStart(event: EventTouch) { this.isDragging true; this.draggedItem event.currentTarget as Node; // 提升节点渲染层级让拖拽项浮在列表之上 this.draggedItem.setSiblingIndex(this.content.children.length - 1); }setSiblingIndex 的作用是把当前项挪到所有兄弟节点的最上层这样拖拽时不至于被其他列表项压住视觉层级。这一步很多人漏掉表现就是拖拽项偶尔一闪一闪地躲在后面。参数说明content 是 ScrollView 的 content 节点children.length - 1 是最后一个索引也就是最上层。3. 触摸坐标换算把触点翻译成列表内的逻辑位置拖拽排序最核心的一个函数就是坐标转换。触点坐标来自 EventTouch 的 getLocation默认返回的是 UI 世界坐标。要想知道手指当前落在列表的哪个区间必须先把它转成列表 content 节点的本地坐标。这里我推荐使用 UITransform 的 convertToNodeSpaceAR它会基于锚点计算比 convertToNodeSpace 更直观。private getTouchPosInContent(event: EventTouch): Vec3 { const uiTransform this.content.getComponent(UITransform)!; const touchPos event.getLocation(); // 返回 Vec2表示世界坐标 const localPos uiTransform.convertToNodeSpaceAR(new Vec3(touchPos.x, touchPos.y, 0)); return localPos; }convertToNodeSpaceAR 把世界坐标转换成以 content 锚点为原点的本地坐标。注意 getLocation() 返回的是屏幕左下角为原点的坐标系而 UI 节点默认锚点居中这两个坐标系如果不做换算直接相减结果会差出一个偏移。参数说明getLocation 传入的 Vec2 要包一层 Vec3 再交给转换函数因为 convertToNodeSpaceAR 接收三维向量。如果嫌这一步麻烦也可以直接调 convertToNodeSpace但要注意锚点位置不是0.5, 0.5时结果会有偏差。拿到本地坐标之后下一步是判断当前拖拽项应该排在哪个位置。常见做法是遍历列表里除了拖拽项本身之外的每一项计算它们的中心点与触摸点的距离找到最近的那一项然后决定把拖拽项交换到它的前面还是后面。这个算法的好处是不依赖列表项是否等宽等高也不要求间隙固定属于比较通用的方案。private findTargetIndex(touchLocalPos: Vec3): number { const children this.content.children.filter(n n ! this.draggedItem); let targetIndex 0; let minDist Number.MAX_VALUE; children.forEach((child, i) { const childCenter child.getComponent(UITransform)!.getBoundingBox().center; // 把子节点的中心点转成 content 的本地坐标 const centerInContent this.content.getComponent(UITransform)! .convertToNodeSpaceAR(child.getBoundingBox().center); const dist Vec3.distance(touchLocalPos, centerInContent); if (dist minDist) { minDist dist; targetIndex i; } }); return targetIndex; }这里有个容易踩的坑直接拿 child 的 position 当作中心点坐标但 position 是相对父节点锚点的而 getBoundingBox 返回的是世界坐标二者坐标系不同。上面的代码先把包围盒中心转成世界坐标再转成 content 本地坐标这样才不会算错。参数说明getBoundingBox().center 拿到的是世界坐标的 Vec3所以要用 content 的 convertToNodeSpaceAR 再做一次转换。Vec3.distance 是静态方法直接调用无需实例化。拿到目标索引后就是执行这个拖拽项的插入逻辑。这里我用的是移出数组再插入的方式而不是相邻交换。两者区别是相邻交换只需要换位置但在列表中间项较多时视觉上会出现一连串动画错位而移出再插入是一次性把拖拽项放到最终位置其他项统一补位视觉更整齐。private applySort(targetIndex: number) { if (!this.draggedItem) return; const children this.content.children.filter(n n ! this.draggedItem); children.splice(targetIndex, 0, this.draggedItem); this.rebuildChildren(children); } private rebuildChildren(children: Node[]) { // 按新顺序重新设置每个节点的 index children.forEach((child, i) { child.setSiblingIndex(i); }); this.refreshPositions(); }rebuildChildren 里做的事情是把数组顺序重新映射到节点层级因为兄弟索引决定 UI 渲染顺序索引 0 在最底层还是最顶层取决于 ScrollView 的渲染模式。参数说明默认 Layout 组件会自动排版但拖拽过程中 Layout 的自动刷新会造成抖动所以通常要手动关掉 Layout 的 enabled改成代码控制位置。refreshPositions 会遍历所有子节点把它们的 position 设置为按 index 和间距算出来的坐标。4. 接上 ScrollView 的滚动联动自动滚动边界的实现拖拽排序的难点不在排序本身而是当列表内容超过可视区域后拖拽项接近上下边缘时列表需要自动滚动否则手指拖到边界就只能在可视区域内徘徊根本排不到列表外面的项。这套实现里加了顶部和底部边界检测逻辑写在 onTouchMove 里。private onTouchMove(event: EventTouch) { if (!this.isDragging || !this.draggedItem) return; // 移动拖拽节点本身 const touchLocal this.getTouchPosInContent(event); this.draggedItem.setPosition(touchLocal.x, touchLocal.y, 0); // 边界判断触发自动滚动 const viewHeight this.view.getComponent(UITransform)!.height; const touchInView this.view.getComponent(UITransform)! .convertToNodeSpaceAR(new Vec3(event.getLocation().x, event.getLocation().y, 0)); if (touchInView.y viewHeight / 2 - 50) { this.startAutoScroll(true); // 向上滚 } else if (touchInView.y -viewHeight / 2 50) { this.startAutoScroll(false); // 向下滚 } else { this.stopAutoScroll(); } }判断边界的阈值取 50这个值不是拍脑袋定的它对应拖拽项边缘距离视图上下边的最小距离。小于这个距离时手指其实已经接近屏幕边缘如果不滚动拖拽项就超出可视区域看不到位置了。参数说明viewHeight 是 ScrollView 视图节点在宽度方向的高度如果是纵向列表要拿到的是 UITransform.height。注意这里判断用的是触摸点相对 view 节点的本地坐标而不是相对 content 的坐标因为 view 不会移动用它做边界判断永远是屏幕可视区域的固定参考。自动滚动本身用帧循环驱动每帧按固定速度移动 content 的 position直到拖拽项回到边界内或手指抬起。private autoScrollSpeed: number 300; private startAutoScroll(isUp: boolean) { this.isAutoScrolling true; this.autoScrollDir isUp ? 1 : -1; } protected update(dt: number) { if (!this.isAutoScrolling) return; const contentPos this.content.position; const newY contentPos.y this.autoScrollDir * this.autoScrollSpeed * dt; // 夹在 content 可滚动范围内 const clampedY this.clampContentY(newY); this.content.setPosition(contentPos.x, clampedY, 0); // 每帧同步拖拽项位置 if (this.draggedItem) { const p this.draggedItem.position; this.draggedItem.setPosition(p.x, p.y this.autoScrollDir * this.autoScrollSpeed * dt, 0); } }拖拽项跟随 content 滚动的原理content 的 position 变了子节点的本地坐标不变但屏幕位置跟着变。为了让拖拽项保持在手指所点的屏幕位置必须手动把它往相反方向拉回来。所以 update 里对 content 加了 speed * dt同时对 draggedItem 也加了同样数值两者抵消拖拽项就保持相对屏幕不动。参数说明autoScrollSpeed 根据列表项高度调节如果列表项高度大而滚速慢拖拽项会跟不上手指的节奏感觉“飘”速度建议设为列表项高度的 3 到 5 倍。clampContentY 做的是边界钳制防止 content 被拖过头。这里不再贴完整代码思路是拿到 content 的最大滚动距离超出就截断。需要注意的一点自动滚动只在手指按住期间生效全程不修改 ScrollView 的 inertia惯性开关否则滚动结束后 content 还会自己滑一段拖拽项对不上手指。5. 拖拽排序避坑指南这几个问题最容易翻车5.1 拖拽项总是在其他项下面穿行现象拖拽过程中列表项视觉上偶尔被压住看不清楚手指在哪。原因没有在拖拽开始时把当前节点放到最顶层。setSiblingIndex 只在挂载时执行一次但动态创建的列表项层级会变。解决进入 onTouchStart 时强制 setSiblingIndex(this.content.children.length - 1)。如果内容还包含其他非列表项节点比如背景图、分割线需要把拖拽项放到所有 UI 节点之上可以单独设置一个固定渲染层也可以简单粗暴点用 this.content.addChild(this.draggedItem)把节点挪到 children 数组末尾再 addChild 会触发一次重排并不推荐直接改索引更轻量。5.2 手指拖到列表项 A 上结果 A 也跟着移动现象拖拽一个项经过另一个项时被经过的项也响应触摸事件混乱。原因默认情况下节点会派发事件给所有命中的节点拖拽项和底下经过的项都收到了 TOUCH_MOVE。解决在 onTouchStart 里用一个标志位标记正在拖拽的状态然后在其他列表项的 onTouchStart 回调开头判断如果当前已有拖拽项直接 return。另一种做法是在 content 上统一监听触摸事件用 event.target 区分当前点中了谁但这样代码侵入性更强不如标志位简单。注意 TOUCH_MOVE 事件的 event.currentTarget 永远是绑定的节点而 event.target 是实际点中的节点这个区别很容易看错。5.3 排序结束后列表项位置不对错位一个身位现象手指抬起后列表项没有落在应有的位置要么重叠要么多出一个空隙。原因手指抬起时没有做最终的位置对齐可能停在两个项的中间或者 layout 组件在拖拽过程中自动刷新了一次导致计算偏移。解决onTouchEnd 里执行一次最终排序把拖拽项吸附到最近的列表项中心点。代码就是 applySort(findTargetIndex(...))但需要再加一次 refreshPositions。同时排查 Layout 的 enabled 状态拖拽期间务必保持 Layout 关闭否则它的自动排列会和手动排序冲突位置永远对不上。5.4 滚动到顶部或底部时系统自动滚回弹跳现象滚动边界处列表会回弹拖拽项和手指脱节。原因ScrollView 默认自带弹性效果content 超出边界时会被弹回。自动滚动时没有禁用弹性。解决在 startAutoScroll 时把 ScrollView 的 elastic 设为 false结束拖拽时恢复 true。需要注意弹性关闭后直接拖 content 的 position 不再有回弹保护clampContentY 必须严格 clamp否则 content 会被拖出界。5.5 真机上拖拽有肉眼可见的延迟现象模拟器上流畅打包到手机上拖拽项跟手但慢半拍。原因帧循环里做一次完整的 getBoundingBox 遍历所有子节点耗时不短真机 UI 渲染挤在同一线程上瓶颈出在 findTargetIndex 的全量遍历。解决把 findTargetIndex 的遍历从每帧改成“距离变化超过阈值才触发”比如手指移动超过 10 像素才重新计算。另一种方案是简化包围盒计算列表项如果大小一致可以直接用 index * itemHeight 算中心点而不用每次调 getBoundingBox。这套实现里默认列表项高度固定所以改成公式计算后性能能提升一倍还多。6. 进阶多列网格拖拽排序与 View 组件版的选择很多场景不只是单列列表背包通常都是多列网格。单列的排序算法只需要考虑纵向距离多列网格要做的是把触摸坐标换算成“第几行第几列”然后决定交换位置。可以参考这个思路先算出触摸点在 content 中的行列再找出距离最近的同行列项执行插入。private getGridIndex(touchLocalPos: Vec3, cols: number, itemWidth: number, itemHeight: number): number { // 以 content 左下角为原点计算相对行列 const col Math.floor((touchLocalPos.x itemWidth / 2) / itemWidth); const row Math.floor((touchLocalPos.y itemHeight / 2) / itemHeight); return row * cols col; }这段代码把坐标拆成列和行再合成索引。参数说明itemWidth 和 itemHeight 是每个网格的实际格宽格高包括间距。这里默认锚点是左下角如果你的 content 锚点是中心需要先把 touchLocalPos 加上 content 宽度的一半再除。多列的实现难点不在索引计算而在交换逻辑单列是插入式多列通常做成“拖拽项和目标项互换位置”两种手感不同前者列表会顶开一项后者是直接交换。背包类游戏多用后者因为玩家直觉是“我把这个东西挪到那个格子”。至于要不要改用 View 组件来实现拖拽排序我的看法是分情况。View 组件自带抓取和释放手势但排序逻辑依然要自己写还要处理 View 对屏蔽点击的侵入式行为调试成本不一定比纯触摸监听低。如果你的列表项数量固定且排序逻辑简单直接用这套实现就够了如果是复杂的自由拖拽、嵌套拖拽、多列表联动View 确实提供更细粒度的手势控制但需要对组件生命周期有深入了解。// 一个信息密度较高的完整拖拽项组件脚本可直接挂载到列表项上 ccclass(DraggableItem) export class DraggableItem extends Component { property(Node) draggableParent: Node; private static draggingItem: DraggableItem | null null; protected onEnable() { this.node.on(Input.EventType.TOUCH_START, this.onStart, this); this.node.on(Input.EventType.TOUCH_MOVE, this.onMove, this); this.node.on(Input.EventType.TOUCH_END, this.onEnd, this); this.node.on(Input.EventType.TOUCH_CANCEL, this.onEnd, this); } private onStart(event: EventTouch) { if (DraggableItem.draggingItem) return; DraggableItem.draggingItem this; this.node.setSiblingIndex(999); } private onMove(event: EventTouch) { if (DraggableItem.draggingItem ! this) return; const uiTrans this.draggableParent.getComponent(UITransform); const localPos uiTrans.convertToNodeSpaceAR( new Vec3(event.getLocation().x, event.getLocation().y, 0) ); this.node.setPosition(localPos.x, localPos.y, 0); } private onEnd() { if (DraggableItem.draggingItem this) { DraggableItem.draggingItem null; // 这里调用上一章写好的 applySort 和 refreshPositions } } }这个脚本展示了一个静态的 draggingItem 引用作用是全局唯一拖拽项。参数说明draggableParent 需要指向 content 节点它负责把事件坐标转成 content 本地坐标。注意 onMove 里没有做任何排序计算排序动作全部延后到 onEnd 统一处理这样运行起来性能更好因为手指滑动过程中只要位置跟着就行不需要每帧找最近索引。实际项目里还需要补充 content 的置灰和点击拦截逻辑——拖拽过程中其他按钮不应响应这个可以在 onStart 里给 content 挂一个 BlockInput 组件或者用一个遮罩节点拖拽结束后移除。遇到 ScrollView 嵌套 ScrollView 的场景拖拽排序会很麻烦建议先确认交互层级再决定是否用嵌套否则手指在里层拖拽时外层列表也会跟着滚动。带着这套实现去搭列表我每次都会先确认三件事列表项高度是不是固定、内容总高度会不会超过视图、排序结束后要不要触发数据层同步。这三件事决定了整个拖拽逻辑的复杂度确认完再动手写基本能少踩一半的坑。把那三件事确认完再动手写基本能少踩一半的坑。希望这篇能帮你省几个晚上的排错时间。本文还有配套的精品资源点击获取