Cocos Creator 拖拽排序:基于Layout的轻量级实现与避坑指南
简介面向 Cocos Creator 开发者的拖拽排序列表项目示例适合需要在游戏或应用中实现列表项自由拖拽重排的开发者参考。资源包以完整项目工程为主压缩包共 16 个文件约 243KB核心包括 TypeScript 脚本、Fire 场景文件、JSON 配置与 meta 元数据其中 ts 文件承载拖拽事件监听与排序逻辑fire 场景提供可直接运行的演示环境。项目展示了从开始拖拽、拖拽中到结束拖拽的完整处理流程并通过数组记录实时位置、拖拽完成后刷新节点布局帮助理解列表排序的核心机制。由于拖拽过程涉及频繁界面刷新示例还引导开发者关注对象池、批处理渲染等性能优化思路。目前已有 114 人学习下载对于刚接触 Cocos Creator 交互组件或希望快速落地排序列表功能的开发者是一份轻量且实用的参考。1. 这个拖拽排序列表解决的是游戏 UI 里真正磨人的那件事做背包整理、任务栏排序、编队调整这类界面时拖拽排序永远是「功能三天手感两周」的活。网上能找到的 Cocos Creator 拖拽排序方案要么是 2.x 时代的旧 API升级到 3.x 直接报错要么依赖第三方库为了一个列表去引一个完整插件总觉得不值。这个资源的核心思路很反直觉它没有用手写补间动画去“追”每一个卡片的落点而是把拖拽中的节点从列表里临时摘出去让 Layout 组件自动给其他卡片让位。结果就是排序逻辑极短手感的顺滑程度却比手动算位置的方案高一大截。项目不挑版本我在 3.8.x 上跑通2.4.x 改一下事件接口也能用。适合做游戏背包和通用 UI 列表的开发者也适合想搞懂触摸事件 Layout 协作链路的同学。2. 工程结构先摸清两个脚本、一个场景、层级怎么挂2.1 目录与资源组成整个资源的结构非常干净核心就落在assets/scripts/drag-sort/目录下assets/ ├── scenes/ │ └── DragSortDemo.scene // 示例场景打开即可预览效果 └── scripts/ ├── drag-sort/ │ ├── DragSortList.ts // 列表管理者维护数据源、创建占位符、完成排序回调 │ └── DragSortItem.ts // 列表项脚本处理触摸、拖拽跟随、上报碰撞 ├── DragSortDemo.ts // 演示入口初始化数据、监听排序结果 └── DataItem.ts // 数据模型给每个 item 打一个唯一 key两个脚本的分工很明确DragSortItem 只负责“感知手指”它不知道列表里有多少个兄弟节点只管把触摸增量上报给 DragSortListDragSortList 只负责“安排位置”它持有 Layout 组件接收 item 上报的位置后判断当前应该插到哪个索引然后通过修改兄弟顺序让 Layout 自动完成视觉过渡。这种「单项依赖」的设计后续加虚拟列表、加跨列表拖拽时只需要改 DragSortList 的判定逻辑item 侧基本不用动。2.2 场景层级与组件依赖打开示例场景Canvas 下的层级长这样Canvas (Canvas UICamera) └── UIRoot ├── DragLayer // 空节点拖拽中的卡片临时挂到这里 ├── Viewport // 固定 720x1280 的视口 │ └── ScrollView (ScrollView Mask) │ └── content (Layout UITransform) │ ├── Item_0 (DragSortItem UITransform Sprite) │ ├── Item_1 │ └── ... └── DebugLabel (Label) // 实时打印排序后的数据数组这里最容易被忽略的是DragLayer。它和content是兄弟节点挂在 Viewport 下而不是 ScrollView 下原因后面第 5 章会详细讲——简单说如果不把拖拽中的节点摘出去Layout 每帧都会强制把它“掰回”格子位置你会看到卡片一边跟手走一边拼命往回弹非常鬼畜。content 上的 Layout 配了四个关键属性Type 设为 VERTICAL纵向列表ResizeMode 设为 CONTAINER让 content 高度跟随子节点总和变化spacingY 10间距Padding 上下左右统一 10。初始化时给 content 塞 8 个 Item每个 Item 尺寸 600x100。2.3 公开属性和事件回调DragSortList 对外暴露的字段不多但在二次开发时每个都很关键字段类型作用contentNode列表容器节点必须挂 Layout 和 UITransformdragLayerNode拖拽临时挂载点建议放在 Mask 之外placeholderNode占位符节点用于标记当前插入位置itemPrefabPrefabitem 预制体初始化时用sortEndEventEventHandler排序完成后的回调传出一个DataItem[]数组其中placeholder不需要手动创建DragSortList 初始化时会根据第一个 Item 的尺寸克隆一个半透明节点。这里用「占位符 交换 index」而不是「直接用手指位置除以行高算索引」最大的好处是无论 Layout 是横向、纵向还是 Grid 网格判定逻辑都不用改只要两个节点的包围盒相交就认为应该交换。这种通用性是我选它做默认方案的原因。3. 核心实现拆解从按下到松手的完整链路这一章把两个脚本里最核心的逻辑拎出来逐段讲。理解了这条链路后面改任何功能都有底气。3.1 触摸事件注册与手势判定DragSortItem 的onEnable里注册了四个事件对应触摸的完整生命周期// DragSortItem.ts 核心片段 import { _decorator, Component, Node, UITransform, Vec3, EventTouch, tween, UIOpacity } from cc; const { ccclass, property } _decorator; ccclass(DragSortItem) export class DragSortItem extends Component { property({ tooltip: 拖拽时节点放大倍率 }) dragScale 1.05; property({ tooltip: 超过该位移才算拖拽避免误触 }) dragThreshold 5; private isDragging false; private touchStartPos new Vec3(); private list: DragSortList null; private cachedOpacity: UIOpacity null; onEnable() { 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); } onDisable() { 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) { // event.getUILocation() 拿到的是 UI 坐标系下的触点位置 this.touchStartPos.set(event.getUILocation().x, event.getUILocation().y, 0); this.list this.node.parent.getComponent(DragSortList); // list 里做正式拖拽准备摘除节点、创建占位符 this.list.onDragStart(this.node); this.isDragging false; // 还没超过阈值先不标记为拖拽 } // onTouchMove / onTouchEnd 见 3.2 }几个值得注意的点TOUCH_CANCEL必须和TOUCH_END走同一个处理函数——手机来电、系统弹窗打断触摸时如果漏了 CANCEL节点会被永远留在 DragLayer 上这个坑我在早期版本里踩过好几次。另外event.getUILocation()拿到的是基于 UI 根节点的坐标不是节点本地坐标所以用它做「位移阈值」判定最合适不用自己做坐标转换。拖拽阈值dragThreshold 5的用途是区分「点击」和「拖拽」如果手指移动还没超过 5 像素就松手应该走「点击」逻辑比如弹出物品详情而不是执行排序。判定的位置在 3.3 的 onTouchMove 里超过阈值的那一刻才把isDragging置为 true。3.2 使出拖拽层把节点摘出来给 Layout“让位”这是整个方案的核心也是和其他教程最不一样的地方。当手指按住一个 item 时DragSortList 会执行三件事// DragSortList.ts onDragStart 核心逻辑 onDragStart(item: Node) { const oldIndex item.getSiblingIndex(); this.dragData { item, oldIndex, placeholder: this.createPlaceholder(item), // 克隆一个半透明占位符 }; // 1. 把占位符插到 item 原来的位置Layout 会立刻补上空位 item.parent.addChild(this.dragData.placeholder); this.dragData.placeholder.setSiblingIndex(oldIndex); // 2. 把真正的 item 从 content 里摘除挂到 DragLayer 下 this.dragLayer.addChild(item); item.setPosition(0, 0, 0); // DragLayer 下以锚点对齐 // 3. 放大 提亮视觉上告诉玩家“这张卡片已经抽起来了” const uiOpacity item.getComponent(UIOpacity) || item.addComponent(UIOpacity); uiOpacity.opacity 200; const scale (item.getComponent(UITransform) as any)._contentSize; tween(item) .to(0.08, { scale: new Vec3(this.itemScale, this.itemScale, 1) }) .start(); // 记录拖拽节点在 DragLayer 下的初始位置为手指位置 this.dragLayer.getComponent(UITransform).convertToNodeSpaceAR( this.lastTouchWorldPos, this.dragData.itemLocalPos ); item.setPosition(this.dragData.itemLocalPos); }这一段信息量比较大拆开讲。第 1 步创建占位符并插入原位置是最聪明的操作占位符本身也是一个节点它参与 Layout 布局所以原 item 离开后Layout 不会重新排列其他节点视觉上列表只是「多了一个空槽位」不会发生整列卡片突然往上顶一下的跳变。第 2 步把 item 挂到 DragLayer 下是为了脱离 Layout 的布局计算——Layout 在 update 时会对 content 的所有子节点重新算位置item 不摘出去你每帧手动 setPosition 都会被 Layout 覆盖。第 3 步的透明度调整用UIOpacity而不是Sprite的 color是因为 item 上可能有多个 Sprite 子节点UIOpacity 能统一控制整棵子树的透明度。注意convertToNodeSpaceAR这个调用DragLayer 的锚点通常设在中心0.5, 0.5把手指的世界坐标转成它的本地坐标item 才会出现在手指正上方。这里为什么要用世界坐标而不是直接累加 delta因为在拖拽过程中ScrollView 的 content 本身可能因为滚动在移动如果用setPosition(node.position delta)会把 ScrollView 的滚动增量也算进去卡片会越拖越飞。3.3 排序判定矩形相交与占位节点交换拖拽过程中item 已经脱离了列表它的每一次位移由 DragSortItem.onTouchMove 驱动// DragSortItem.ts onTouchMove private onTouchMove(event: EventTouch) { const delta event.getDelta(); const list this.list; // 更新拖拽节点的位置DragLayer 本地坐标下 this.node.setPosition( this.node.position.x delta.x, this.node.position.y delta.y, 0 ); list.updateDragPosition(this.node); // 超过阈值才激活排序否则只是“按住” const uiPos event.getUILocation(); const dist Vec3.distance(this.touchStartPos, new Vec3(uiPos.x, uiPos.y, 0)); if (!this.isDragging dist this.dragThreshold) { this.isDragging true; list.onDragActivate(this.node); // 触发视觉提亮等逻辑 } if (this.isDragging) { event.propagationStopped true; // 阻断事件继续冒泡给 ScrollView } }event.getDelta()拿到的是本次触摸帧相对上一帧的位移直接在当前节点位置上累加即可不需要做坐标转换。这里每帧都调用一次list.updateDragPosition把 item 的世界坐标同步给列表由列表做相交判定。DragSortList 里的判定核心是「矩形相交」它不关心列表是横排还是竖排通用性最强// DragSortList.ts updateDragPosition 核心判定 updateDragPosition(item: Node) { if (!this.dragData) return; const dragBox item.getComponent(UITransform).getBoundingBoxToWorld(); const children this.content.children; for (let i 0; i children.length; i) { const child children[i]; if (child this.dragData.placeholder) continue; // 跳过占位符自己 const childBox child.getComponent(UITransform).getBoundingBoxToWorld(); if (dragBox.intersects(childBox)) { // 相交时把占位符和目标节点的 index 互换 const targetIndex child.getSiblingIndex(); this.dragData.placeholder.setSiblingIndex(targetIndex); break; // 一次 move 只处理一次交换避免快速拖拽时顺序反转 } } }这段判定有两个关键细节。第一getBoundingBoxToWorld()拿到的是世界坐标下的包围盒所以即使拖拽节点在 DragLayer 下、其他节点在 content 下属于不同的父层级也能直接比较这是它通用性强的根本原因。第二break非常关键——如果不 break同一帧内拖拽节点可能同时与多个子节点相交连续交换两次会让占位符直接跑到列表末尾造成反向排序这是新手最容易翻车的地方。手动计算的目标索引终归能用同一套代码跑通横排、竖排和 Grid 网格这就是为什么我把这个组件设计成不关心 Layout 的 Type 属性——你换成 HORIZONTAL一行代码都不用改。3.4 松手回写从节点顺序回到数据数组触摸结束时的逻辑相对直接摘除占位符、把 item 挂回 content 原位置、从内容顺序重新生成数据数组// DragSortItem.ts onTouchEnd private onTouchEnd(event: EventTouch) { if (!this.isDragging) return; // 没超过阈值视为点击不做排序 this.isDragging false; this.list.onDragEnd(this.node); this.node.setScale(1, 1, 1); } // DragSortList.ts onDragEnd onDragEnd(item: Node) { const { placeholder, oldIndex } this.dragData; const finalIndex placeholder.getSiblingIndex(); placeholder.destroy(); // 先删占位符再取索引 this.content.addChild(item); item.setSiblingIndex(finalIndex); item.setPosition(0, 0, 0); // 交给 Layout 算位置 // 按顺序遍历 content 子节点重排数据源 const newData: DataItem[] []; this.content.children.forEach((node) { const data this.dataMap.get(node.name); if (data) newData.push(data); }); this.sortEndEvent.emit(newData); // 恢复透明度 const uiOpacity item.getComponent(UIOpacity); if (uiOpacity) uiOpacity.opacity 255; this.dragData null; }注意item.setPosition(0, 0, 0)这行item 回到 content 后位置应该交给 Layout 统一计算绝对不要手动设置成拖拽结束前的位置否则 Layout 下一帧会强制纠正造成一次肉眼可见的跳变。数据回写这里用了一个dataMap初始化时把 item 名字和数据对象绑在一起这样不管 item 被拖到哪个位置数据都不会错绑。整个链路串起来就是按下瞬间摘除节点、创建占位符移动过程中通过矩形相交交换占位符和兄弟节点的顺序松手后把 item 放回列表并重排数据数组。核心代码加起来不到 150 行没有一行是多余的。4. 跑起来从空项目到拖拽列表的完整装配步骤4.1 最小场景搭建流程新创建一个 2D 空项目后按以下步骤装配把assets/scripts/drag-sort/整个目录拷进项目的 assets 下Creator 会自动编译。在 Canvas 下创建空节点命名为DragLayer随便给个颜色标记方便调试时看清层级。创建一个ScrollView删除它自带的content重新建一个空节点挂到 ScrollView 下ScrollView 组件的 content 属性要拖新节点进去。给新的 content 节点挂UITransform和Layout组件Layout 按 2.2 里的参数配置。做一个小小的 Item 预制体一个 600x100 的 Sprite随便给个颜色作为根节点挂DragSortItem脚本根节点上再加一个 Label 显示物品名。在场景里创建一个DragSortDemo脚本挂到 UIToot 上初始化时实例化 8 个 Item 塞进 content。把DragLayer节点和 itemPrefab 分别拖到 DragSortDemo 的对应属性上。这里容易漏的一步是给 ScrollView 挂Mask组件——不挂的话列表项被拖出可视区域时会穿帮显示在 Viewport 外面。4.2 需要手动配的属性说明装配好之后有三处参数值得按需调整参数默认值说明DragSortItem.dragScale1.05拖拽放大倍率调成 1.0 就没有放大效果1.1 以上会遮挡相邻卡片DragSortItem.dragThreshold5手抖补偿阈值单位像素值越大越不容易误拖DragSortList.itemScale1.0拖拽时的缩放通常和 dragScale 保持一致DragSortList.fixItemHeight100占位符的高度初始化时从第一个 child 读取即可不用手动填fixItemHeight如果填错占位符的尺寸会和真实 item 不一致松手时列表会出现一个高度塌陷的瞬间视觉效果非常明显。正确做法在脚本里this.fixItemHeight this.content.children[0].getComponent(UITransform).height读取第一帧真实数据。4.3 验证排序正确性的三个检查点跑起来之后别急着收工我一般按这三个顺序验证按住第一张卡片拖到第五张下方松手看其他卡片是否平滑让位没有瞬间跳变。如果看到「其他卡片瞬间挤在一起再弹开」说明占位符的尺寸和 item 不一致。快速来回拖拽不松手在列表里走 S 形看排序结果是否和手指最终停下的位置一致。如果出现「明明停在第三格数据却是第五格」检查 onUpdateDragPosition 里有没有做break。往下拖拽让列表滚动时检查是否还能正常拖拽、滚动是否顺滑。滚动卡顿通常是因为 item 上的触摸回调没有propagationStopped true事件还在往 ScrollView 冒泡。这三条都过了这个列表基本就是可用的状态。下面聊聊我实际使用中遇到过的几个真实翻车场景。5. 避坑实录拖拽排序最容易踩的 5 个坑5.1 Layout 和手写位置打架卡片疯狂抖动现象拖拽开始时item 一边跟手走一边不停回弹画面像在抽搐。原因item 还挂在 content 下Layout 组件的 update 每帧都在重新计算它的坐标手动 setPosition 的增量被 Layout 覆盖两边反复拉扯。解决这就是为什么 3.2 里的 onDragStart 必须先把 item 摘到 DragLayer。如果实在不想用 DragLayer可以在拖拽期间layout.enabled false松手后再恢复——但这样其他卡片不会自动让位交互观感会差很多。正确思路是摘出去而不是关 Layout。5.2 ScrollView 滚动和拖拽互相抢事件现象按住一个 item 上下滑动时列表滚不动或者明明是拖拽却变成了滚动。原因item 的触摸事件默认会冒泡到 ScrollView 的滚动逻辑。ScrollView 用touchmove的位移量判断是否滚动而 item 的拖拽逻辑也在消费同一个位移增量两方同时响应。解决在 onTouchMove 里判定isDragging为 true 时调用event.propagationStopped true阻断冒泡。注意要在 item 的onTouchMove里做而不是 DragSortList 里——因为事件绑定的目标对象是 item。5.3 松手后卡片偏了几个像素现象拖拽结束、item 回到 content 后位置和周围的格子差了十几像素看起来很别扭。原因DragSortItem 在 touchend 里最后执行了this.node.setPosition(...)手动给 item 设置了残影位置Layout 虽然下一帧会纠正过来但那一帧的偏差已经被渲染出来了。解决松手后直接item.setPosition(0, 0, 0)并把位置计算完全交给 Layout一行代码都不用手动设置。如果用了tween做回弹动画动画的终点坐标也必须是(0, 0, 0)相对 content而不是手指位置。5.4 排序结果和视觉位置对不上现象卡片明明显示在第三个槽位但排序回调里它的数据排在第五。原因onDragEnd 里SiblingIndex是在占位符销毁之前取的。占位符还占着一个 index取到的 finalIndex 比真实索引小 1。解决严格按照 3.4 的顺序——先placeholder.destroy()再item.setSiblingIndex(finalIndex)。销毁操作是延迟生效的稳妥写法是先调用placeholder.removeFromParent()再取 index避免 destroy 的帧延迟干扰计算。我在测试时把这两步先后写反过一次排查了半个多小时才发现是索引偏移。5.5 拖拽卡片跑到了滚动遮罩的外面现象列表往下滚动后item 被拖到列表视觉范围外时卡片直接穿透 Mask 显示了出来。原因项目初始版本把 DragLayer 放在 ScrollView 里item 摘出 content 后挂到 ScrollView 下而 ScrollView 带了 Mask拖拽节点被裁剪。解决把 DragLayer 提升到 ScrollView 的父级Canvas 下再通过convertToNodeSpaceAR做一次坐标转换。拖拽节点在最顶层渲染天然不被任何 Mask 裁剪这也是 2.1 里场景层级那样设计的原因。6. 二次开发思路往虚拟列表和跨列表方向改造这套 150 行的骨架在常规列表场景下已经够用但真正的工作场景里列表往往是几千条数据——直接把 8 个 Item 的方案复制过去实例化 5000 个节点Cocos Creator 的渲染性能立刻撑不住。改造方向是把它做成虚拟列表只在可视区域维护 8~12 个 item 实例滚动的过程中复用节点池、动态替换数据内容。// 虚拟列表排序的最小改造骨架 updateDragPositionVirtual(item: Node) { // 1. 用当前拖拽节点的世界 Y 坐标除以行高直接算出目标索引 const worldPos item.worldPosition; const targetIndex Math.round((this.contentHeight - worldPos.y - this.paddingTop) / this.cellHeight); if (targetIndex 0) targetIndex 0; if (targetIndex this.totalCount) targetIndex this.totalCount - 1; // 2. 只有真正跨越了格子边界时才触发交换 if (targetIndex ! this.dragVirtualIndex) { this.dragVirtualIndex targetIndex; this.placeholder.setSiblingIndex(targetIndex); } }虚拟列表里没法再用「占位符 Layout 自动布局」的方案因为 content 下同时只有 12 个真实节点布局全靠 ScrollView 的滚动偏移计算。这时把目标索引直接用「手指位置 / 行高」算出来再通过recyclePool回收的 item 复用窗口内做一次排序标记是更务实的做法。我一般会在排序完成后把数据数组持久化到本地缓存下次进入时直接恢复到上次的手动排序。跨列表拖拽是另一个常见需求——比如背包和装备栏之间互拖。改造点有两个拖拽开始时把 item 的挂载点从「列表的 DragLayer」抬高到「Canvas 的 DragLayer」结束放置时用cc.view的坐标做一次命中检测判断手指是否落在目标列表的 content 范围内落在哪个列表就插入哪个列表。这中间最容易出错的是目标列表的 content 可能是滚动的插入时必须把当前滚动偏移量考虑进去否则 item 会插到错误的可见位置。这套拖拽排序的方案我迭代过三轮第一轮全部手写位置计算代码量翻倍且容易漂移第二轮用 Layout 但没摘节点被抖动折磨了几天第三轮才是这个「摘出节点 占位符交换」的版本。从那以后我每次给列表加新功能都会先写一版不带 Layout 的纯位置数组来验证核心逻辑再套回这套组件里排错的时间至少省了一半。希望这些经验和代码能帮你在拖拽排序上少走点弯路。本文还有配套的精品资源点击获取