简介本资源是一个基于Cocos Creator 3.x实现的可拖拽排序列表完整工程面向游戏开发初学者与UI交互功能进阶者解决移动端/PC端应用中常见的内容自定义排序需求如任务列表、技能栏、背包物品重排等典型场景。压缩包共16个文件含3个核心TypeScript脚本实现拖拽监听、索引更新与节点重布局、5个JSON配置与序列化数据、1个主场景fire文件及配套meta元信息整体仅243KB轻量易集成适合快速学习与项目复用。已有115人学习下载资源结构清晰Script目录封装完整拖拽逻辑assets下保留最小化场景结构同时包含tsconfig.json、project.json等工程配置便于理解Creator项目组织规范。读者可直接运行调试掌握事件响应链onTouchStart/move/end、数组状态同步、节点位置动态刷新三大关键技术点并借鉴其轻量对象管理思路规避频繁重绘性能问题。1. 拖拽排序列表不是“加个 onDragStart 就完事”的 UI 组件而是 Cocos Creator 里最常被低估的交互黑匣子你有没有试过在 Cocos Creator 项目里拖着一个列表项从第 2 行拖到第 5 行松手后它卡在中间、跳回原位、或者把隔壁两个 item 挤得错位变形这不是你代码写错了——是 Cocos 的节点层级、坐标系转换、事件穿透、以及ScrollView和Layout的隐式协作机制在你没注意时悄悄合谋翻车。这个「【cocos creator】拖拽排序列表」资源包就是一套经过真机反复验证、覆盖 Android/iOS/PC 多端、支持自定义动画缓动、可嵌入任意 ScrollContainer 的完整实现方案。它不依赖第三方插件纯 TS Cocos Creator 原生 API 实现核心逻辑封装成DraggableList组件拖拽过程自动计算插入位置索引、实时更新数据源、触发onItemReordered回调——你只需要传入一个itemPrefab和一个itemDataArray剩下的交由它处理。适合做后台管理面板的模块排序、游戏内技能栏重排、关卡选择页的自定义顺序、甚至背包物品整理等真实业务场景。如果你正卡在“拖不动”“插不准”“松手就乱”这三座大山之间这份资源就是你该立刻下载跑起来的后悔药。2. 架构设计与目录结构一个组件三层职责五类文件这个拖拽排序列表不是堆砌功能的“大杂烩”而是一个职责清晰、边界明确的轻量级模块。整个资源以DraggableList为核心组件采用“数据驱动视图 事件驱动状态 动画驱动反馈”的三层分离设计。所有逻辑都收敛在components/下不污染全局命名空间也不强耦合 UI 节点结构。我拆开源码包后第一件事就是确认它的目录是否符合 Cocos Creator 工程规范——结果很干净没有冗余的.meta文件残留没有未引用的资源占位符resources下的预制体prefab和脚本一一对应连tsconfig.json都配好了allowSyntheticDefaultImports: true避免 TS 编译报错。这种细节往往决定了你导入后能不能 5 分钟内跑通第一个 demo。2.1 核心组件结构DraggableList.ts是唯一入口但背后有三套状态机DraggableList继承自Component但它内部维护了三套独立的状态机DragState记录当前是否处于拖拽中、拖拽起始坐标、当前鼠标/触摸位置、是否已进入目标区域InsertState负责监听滚动容器的scrolling事件动态计算每个 item 的可视区域边界并在拖拽过程中实时判断“松手时该插在哪两个 item 之间”AnimationState控制拖拽中 item 的跟随偏移、插入前的缩放提示、插入后的平滑归位动画使用tween系统而非action避免与 Layout 冲突。提示DraggableList不直接操作子节点的position而是通过worldPositionparent.convertToWorldSpaceAR()做坐标系对齐。这是解决多层嵌套 ScrollView 中拖拽偏移的根本办法——很多开发者卡在这里是因为死磕node.position却忘了 Cocos 的position是相对于父节点的局部坐标。2.2 目录结构一览5 类文件各司其职类型路径说明主组件components/DraggableList.ts入口脚本暴露init(data: T[])、refresh()、onItemReordered: (fromIndex: number, toIndex: number) void三个关键 APIItem 模板prefabs/ListItem.prefab带ListItem.ts脚本的预制体含dragHandle节点可选、content区域、highlight层拖拽时高亮工具函数utils/drag-utils.ts封装getWorldRect(node)、isPointInRect(point, rect)、clampIndex(index, length)等复用逻辑全部为纯函数无副作用动画配置configs/drag-animation-config.ts定义插入动画时长默认 0.2s、缓动函数quartOut、高亮透明度0.8、拖拽缩放比例1.05等可配置项Demo 场景scenes/demo-sort-list.fire含完整 ScrollView Layout DraggableList 绑定的测试场景带 12 个测试 item支持键盘方向键模拟拖拽调试用特别注意ListItem.prefab中的ListItem.ts并非空壳——它实现了onDragStart/onDragMove/onDragEnd三个生命周期钩子并通过this.node.emit(list-item-drag-start, this)向父级广播事件。这种解耦方式让你未来可以轻松替换 item 的视觉表现比如换成卡片式、图标式而无需修改DraggableList一行代码。2.3 数据流图从用户手指按下到数据数组重排全程可追踪整个拖拽排序的数据流非常线性没有隐式状态泄漏用户 touchStart → ListItem.onDragStart → 广播 list-item-drag-start → DraggableList 监听并启动 DragState → touchMove → ListItem.onDragMove → 计算 worldPosition → 更新拖拽 item 的 worldPosition → 同时 InsertState 扫描所有 visible item 的 worldRect → 判断插入点 index → touchEnd → DraggableList 触发 insertAt(toIndex) → 调用 splice() 重排 data array → emit item-reordered, { fromIndex, toIndex } → 外部监听者执行业务逻辑如保存到 localStorage、同步到后端这个流程里最关键的断点是insertAt()方法——它不是简单地array.splice(toIndex, 0, item)而是先判断fromIndex toIndex还是fromIndex toIndex再决定是splice(toIndex - 1, 0, item)还是splice(toIndex, 0, item)否则会出现“往前拖一格实际往后跳两格”的玄学现象。这个细节我在某跨平台系统里踩过两次坑后来把它写进了drag-utils.ts的calculateInsertIndex()函数里。3. 核心代码逐段解析看懂这 4 段你就掌握了 80% 的拖拽排序逻辑别被“拖拽”二字吓住。Cocos Creator 的拖拽排序本质是“坐标计算 数组操作 动画调度”。下面这四段代码覆盖了从初始化、拖拽中定位、到松手重排的全链路。每段我都贴出真实源码已脱敏、标注关键参数、说明为什么这么写以及常见误用后果。3.1 初始化init(data: T[])—— 不只是 setData更是建立双向映射// components/DraggableList.ts init(data: T[]) { this._data [...data]; // 浅拷贝避免外部修改影响内部状态 this._itemNodes []; // 存储当前生成的所有 item node 引用 this.contentNode.removeAllChildren(); // 清空旧内容支持刷新 // 关键为每个 item 创建 node并绑定数据索引 this._data.forEach((item, index) { const itemNode instantiate(this.itemPrefab); const listItem itemNode.getComponent(ListItem); listItem.init(item, index); // 传入数据 当前索引 listItem.on(item-drag-start, this._onItemDragStart, this); listItem.on(item-drag-end, this._onItemDragEnd, this); this.contentNode.addChild(itemNode); this._itemNodes.push(itemNode); }); // 启动 Layout 自动布局必须在 addChild 后调用 this.contentNode.getComponent(Layout)?.updateLayout(); }参数说明this.itemPrefab必须是带ListItem.ts脚本的预制体且 prefab 根节点需挂载Widget组件用于适配 ScrollView 边界listItem.init(item, index)让每个 item 知道自己“当前属于第几个”这是后续计算fromIndex的唯一依据this.contentNode必须是ScrollView.content节点不能是 ScrollView 本身——否则 Layout 会失效。为什么这么写很多新手直接for (let i 0; i data.length; i)循环instantiate却忘了给每个 item 绑定index。结果拖拽结束时fromIndex取出来永远是 0。这里的listItem.init(item, index)就是那个“锚点”。3.2 拖拽中定位_updateInsertIndex()—— 插在哪不是靠猜是靠矩形相交检测// components/DraggableList.ts private _updateInsertIndex(worldPos: Vec3): number { if (!this._itemNodes.length) return 0; const insertIndex this._itemNodes.findIndex((node, idx) { const rect getWorldRect(node); // utils/drag-utils.ts 提供 const isBelowCenter worldPos.y rect.y - rect.height / 2; return isBelowCenter idx this._itemNodes.length - 1; }); // 如果没找到拖到末尾返回数组长度 return insertIndex -1 ? this._data.length : insertIndex; }参数说明worldPos当前拖拽 item 的世界坐标由event.getUILocation()convertToUINode()转换而来getWorldRect(node)精确获取节点在 UI 坐标系下的包围盒考虑 Widget、Scale、Rotationrect.y - rect.height / 2取每个 item 的“中心线”拖拽 item 的 y 坐标低于该线即视为“应插入到该项之后”。为什么这么写不用event.getLocation()获取局部坐标是因为 ScrollView 滚动后局部坐标系会偏移不用node.worldPosition直接比较是因为worldPosition是三维向量而 UI 布局只关心二维平面。这个getWorldRect是我从某高校项目里抄来的血泪经验——它内部做了node.parent.convertToWorldSpaceAR(node.position)node.getContentSize()的组合计算比官方文档写的更鲁棒。3.3 松手重排_handleDrop()—— splice 的方向决定你是神还是鬼// components/DraggableList.ts private _handleDrop(fromIndex: number, toIndex: number) { if (fromIndex toIndex) return; // 关键修正 toIndex避免数组越界或逻辑反转 const correctedToIndex toIndex fromIndex ? toIndex - 1 // 往后拖原位置已空出目标索引前移一位 : toIndex; // 往前拖原位置后移目标索引不变 // 执行数组重排 const movedItem this._data[fromIndex]; if (fromIndex toIndex) { this._data.splice(fromIndex, 1); this._data.splice(correctedToIndex, 0, movedItem); } else { this._data.splice(fromIndex, 1); this._data.splice(correctedToIndex, 0, movedItem); } // 通知外部 this.node.emit(item-reordered, { fromIndex, toIndex }); }参数说明fromIndex拖拽起始时的索引由ListItem.init()传入并保存toIndex_updateInsertIndex()返回的“视觉上应插入的位置”correctedToIndex修正值解决splice导致的索引漂移问题。为什么这么写这是整个项目里最反直觉的一段。假设数组[A,B,C,D]你把 Bindex1拖到 D 后面视觉上 toIndex4splice(1,1)删除 B 后数组变成[A,C,D]此时splice(4,0,B)会越界——正确做法是splice(3,0,B)。而如果把 Dindex3拖到 A 前面toIndex0删除 D 后数组是[A,B,C]splice(0,0,D)才是对的。这段correctedToIndex就是干这个的没它你的排序永远差一格。3.4 动画归位_animateItemToIndex()—— 用 tween 替代 action避开 Layout 冲突// components/DraggableList.ts private _animateItemToIndex(itemNode: Node, targetIndex: number) { const targetPos this._getTargetPosition(targetIndex); const currentPos itemNode.position; // 使用 tween不干扰 Layout 计算 tween(itemNode) .to(0.2, { position: targetPos }, { easing: quartOut }) .call(() { // 归位后重置节点层级确保 zOrder 正确 itemNode.setSiblingIndex(targetIndex); this._updateItemIndices(); // 重新给每个 item 设置>{ compilerOptions: { allowSyntheticDefaultImports: true, skipLibCheck: true, moduleResolution: node } }缺少allowSyntheticDefaultImports会导致import { Vec3 } from cc;报错。资源导入将下载包解压后把assets/文件夹下的全部内容拖入 Cocos Creator 资源面板Assets的根目录。不要建新文件夹再拖——Creator 会自动识别.prefab、.ts、.fire文件并生成.meta。导入完成后资源面板应出现components/、prefabs/、scenes/、utils/、configs/五个文件夹。注意导入后编辑器右下角可能弹出“正在编译 TypeScript”提示等待其消失再进行下一步。强行运行未编译的 TS 脚本会报ReferenceError: xxx is not defined。4.2 运行 Demo 场景三秒确认是否成功在资源面板中双击打开scenes/demo-sort-list.fire确保场景层级中Canvas → ScrollView → ViewPort → Content结构完整且Content节点上挂载了DraggableList组件点击编辑器上方的 ▶️ 按钮运行预览预期结果看到 12 个带编号的灰色卡片从上到下排列。鼠标按住任意一张卡片向上/向下拖动卡片会跟随鼠标移动其他卡片自动让出位置松手后卡片平滑归位控制台输出item-reordered: {fromIndex: X, toIndex: Y}。如果没看到卡片检查Content节点的Layout组件是否启用enable勾选、resizeMode是否为Container如果卡片不跟随鼠标检查ListItem.prefab根节点是否挂载了Widget组件必须有用于坐标系对齐。4.3 调试技巧用键盘模拟拖拽绕过真机触控限制真机调试时触控坐标难捕捉、松手时机难把控。demo-sort-list.fire场景内置了键盘调试模式选中任意一个ListItem节点在层级面板中点击按↑/↓方向键模拟向上/向下拖拽每次移动 1 个 item 高度按Enter键模拟松手触发重排按R键重置列表顺序。这个功能写在scenes/demo-sort-list.ts里原理是监听systemEvent.on(Input.EventType.KEY_DOWN, ...)然后调用DraggableList的simulateDrag()方法。你可以把它复制到自己的项目里调试效率提升 3 倍。5. 避坑 / 常见问题 / 排查5 条血泪经验条条对应真实翻车现场这些不是“可能遇到的问题”而是我在线上项目里被报警电话叫醒、连续 debug 12 小时后记下的真实日志。每一条都带现象、原因、解决照着做就能救火。5.1 现象拖拽时 item 卡在屏幕左上角0,0完全不跟随鼠标原因ListItem.ts中onDragStart事件里调用了this.node.parent.convertToWorldSpaceAR(this.node.position)但this.node.parent是ScrollView.content而content节点未启用Widget组件导致坐标转换失败返回(0,0)。解决选中ScrollView → ViewPort → Content节点在属性检查器中勾选Widget组件的alignTop和alignBottom即使你不需要对齐也必须启用 Widget 才能正确转换坐标。5.2 现象拖拽 item 到列表底部松手后它跳到第 2 个位置而不是末尾原因_updateInsertIndex()方法中findIndex返回-1时直接return this._data.length但this._data.length是 12而_itemNodes.length是 11因为拖拽中的 item 已从_itemNodes移除导致splice(12, 0, item)越界数组自动修正为splice(11, 0, item)。解决在_updateInsertIndex()末尾加一行保护return Math.min(insertIndex -1 ? this._data.length : insertIndex, this._data.length);5.3 现象iOS 真机上拖拽卡顿Android 正常原因iOS Webview 对touchmove事件的默认行为滚动未禁用导致拖拽时 ScrollView 也在滚动两个滚动冲突。解决在ListItem.ts的onDragStart中添加event.stopPropagation(); if (sys.isMobile) { event.preventDefault(); // 阻止默认滚动 }5.4 现象拖拽过程中其他 item 的 Layout 错位出现大片空白原因DraggableList的contentNode上同时挂载了Layout和ScrollView而ScrollView的bounce属性为true拖拽时 bounce 动画干扰 Layout 计算。解决选中ScrollView节点在属性检查器中将bounce设为false或者在DraggableList.ts的onEnable()中动态关闭this.scrollView.bounce false;5.5 现象刷新列表调用refresh()后拖拽失效控制台报Cannot read property emit of null原因refresh()方法中调用了this.contentNode.removeAllChildren()但未清理_itemNodes数组和ListItem实例上的事件监听器导致旧ListItem的onDragEnd回调试图访问已被销毁的DraggableList实例。解决在refresh()开头加清理逻辑this._itemNodes.forEach(node { const listItem node.getComponent(ListItem); if (listItem) { listItem.off(item-drag-start); listItem.off(item-drag-end); } }); this._itemNodes [];6. 二次开发实战如何接入你自己的业务数据、UI 和后端同步逻辑现在你已经跑通 demo理解了核心逻辑接下来就是把它焊进你自己的项目里。这不是“换个 prefab 就完事”的体力活而是要理解数据契约、UI 约束、以及状态同步的时机。我以一个真实需求为例某图像处理 Demo 的滤镜排序页需支持用户拖拽调整滤镜应用顺序并将新顺序实时同步到后端 API。下面是我实际落地的三步改造法。6.1 第一步适配你的数据结构 —— 从string[]到FilterItem[]Demo 里init()接收的是string[]但你的滤镜数据可能是interface FilterItem { id: string; name: string; icon: SpriteFrame; isEnabled: boolean; }改造点修改DraggableList.ts的泛型声明export class DraggableListT extends { id: string } extends Component在init(data: T[])中不再用index作为唯一标识改用item.idlistItem.init(item, item.id); // 传入 id 而非 index重写_handleDrop()不再操作数组索引而是根据fromId和toId查找位置const fromIndex this._data.findIndex(i i.id fromId); const toIndex this._data.findIndex(i i.id toId); // 后续 splice 逻辑不变6.2 第二步定制你的 UI 表现 —— 替换ListItem.prefab不碰核心逻辑你的滤镜 item 需要显示图标、开关、删除按钮而不是纯文本。不要修改DraggableList.ts只需复制prefabs/ListItem.prefab重命名为FilterItem.prefab在FilterItem.prefab根节点上替换ListItem.ts为你的FilterItem.tsFilterItem.ts必须继承ListItem保证事件接口一致并重写init()init(data: FilterItem, id: string) { super.init(data, id); this.iconSprite.spriteFrame data.icon; this.nameLabel.string data.name; this.toggleButton.isChecked data.isEnabled; }在DraggableList组件的 Inspector 面板中把itemPrefab拖入新的FilterItem.prefab。提示FilterItem.ts中所有 UI 更新逻辑如this.nameLabel.string ...必须放在init()里不能放在onLoad()——因为onLoad()在instantiate()时就执行早于init()的数据传入。6.3 第三步接入后端同步 —— 在item-reordered回调里发请求但加防抖用户每拖一次你就发一次 API不行。高频拖拽会产生大量无效请求。我的做法是在item-reordered后启动一个 800ms 防抖计时器松手 800ms 后无新事件才调用 API。// 在你的业务脚本中比如 FilterManager.ts private _reorderDebounceTimer: NodeJS.Timeout | null null; onLoad() { this.draggableList.node.on(item-reordered, this._onItemReordered, this); } private _onItemReordered(event: any) { // 清除上一次计时器 if (this._reorderDebounceTimer) { clearTimeout(this._reorderDebounceTimer); } // 启动新计时器 this._reorderDebounceTimer setTimeout(() { this._syncToBackend(); }, 800); } private _syncToBackend() { const orderIds this.draggableList.getData().map(item item.id); axios.post(/api/filters/order, { order: orderIds }) .then(() console.log(排序同步成功)) .catch(err console.error(同步失败, err)); }为什么是 800ms实测发现用户完成一次拖拽按下→移动→松手平均耗时 300~500ms800ms 足够覆盖绝大多数操作又不会让用户等太久。太短如 200ms容易误触发太长如 2s体验迟钝。从那以后我每次接入拖拽排序需求都强制走一遍这三步先跑通 demo 确认环境再用键盘调试模式验证数据流最后在item-reordered回调里加防抖和错误重试。这套流程让我在三个不同项目里零线上事故交付了拖拽排序功能。希望帮到你。本文还有配套的精品资源点击获取