Cocos Creator 拖拽排序列表实战:滚动冲突、坐标换算与动画手感

发布时间:2026/10/12 4:33:43
Cocos Creator 拖拽排序列表实战:滚动冲突、坐标换算与动画手感 简介基于Cocos Creator实现的拖拽排序列表完整工程面向需要快速掌握列表拖拽交互的Cocos游戏与UI开发者。资源覆盖从事件监听、拖拽移动、排序存储到界面状态更新的全套实现可应用于背包、任务清单、设置项排序等常见界面适合已有Cocos基础、希望直接参考工程代码的开发者。包体共16个文件以3个ts脚本承载核心拖拽与排序逻辑5个json与4个meta文件完成场景及资源标记配置另含fire场景文件和项目级配置压缩包总大小仅243KB结构紧凑便于直接导入工程。已有114人学习下载。通过该工程可对照学习触摸事件监听与坐标换算、列表项位置实时交换、排序结果保存与视图刷新机制脚本注释和目录分层还能帮助理解对象池、批处理渲染等性能优化思路导入Cocos Creator即可运行调试省去从零搭建的重复工作。1. 拖拽排序列表不是“绑个事件”就能跑起来的做背包、仓库、关卡选择、收藏夹这类界面时最烦的不是数据而是列表项怎么“愿意”跟着手指走。Cocos Creator 里给列表项绑 TOUCH_MOVE 很容易难的是它和 ScrollView 抢触摸手指一动列表先滚了根本拖不动就算拖起来了松手后位置不对、动画抖动、多指一碰全乱。拖拽排序列表这个需求本质是一套“触摸状态机”什么时候让滚动接管、什么时候让拖拽接管、松手后怎么重排并同步数据。今天我把做稳过的方案直接讲透——长按启动拖拽、拖动即重排、松手后按顺序回调数据层。适合做 UI 系统、背包仓库界面、编辑器式排序的 Cocos Creator 开发者完整代码、索引重排、动画归位、常见坑全在后文。2. 拖拽排序的交互模型与坐标系设计先把“怎么拖”定死2.1 先把交互方式定死长按拖拽与编辑模式开关拖拽排序和滚动的本质冲突是它们都在监听同一个触摸流而手指只能有一个意图。我见过不少第一次做这个功能的开发者直接在 item 上绑 TOUCH_MOVE然后发现列表滚动和拖拽互相干扰最后用布尔开关硬切体验非常生硬。正确做法是先选一种交互模型再写代码。常用模型有两种。第一种是长按启动默认情况下列表完全正常滚动用户按住某个 item 超过一定时间且手指没怎么移动才进入拖拽模式。这种对用户无感知适合大多数场景。第二种是编辑模式开关界面上放一个“编辑/排序”按钮点击后列表进入排序模式此时所有滑动都变成拖拽不触发滚动。适合设置页、管理页这类“明确知道自己在排序”的场景。我一般默认用长按启动原因是背包、收藏夹这种高频滚动场景里用户不会每次都先进编辑模式。参数上有两个数要定死一个是长按触发时间默认 0.2 秒左右一个是滚动判定阈值默认 10 像素。手指在 0.2 秒内移动超过 10 像素就当它是滚动拖拽候选直接作废。这个组合能让滚动和拖拽互不误触。2.2 坐标换算所有点都先转到 content 坐标系拖拽中最大的坐标坑是把参照系搞混。item 的 position 是相对父节点 content 的所以拖拽时我们拿到的触摸点也要换算到 content 坐标系再参与位置计算。不要在 item 自己身上做坐标转换item 位置一变参照系就跟着变位置会漂得越来越远。// 触摸点从屏幕坐标 - content 本地坐标 let touchWorld event.touch.getLocation(); // 屏幕坐标 Vec2 let touchLocal this.node.convertToNodeSpaceAR(touchWorld); // this.node 是 content 节点 // 反向换算content 本地坐标 - 屏幕坐标 let itemWorld this.node.convertToWorldSpaceAR(item.position);逻辑说明convertToNodeSpaceAR 会自动处理 Canvas 的缩放和 content 的锚点偏移所以我们不需要手动除以 UI 缩放比。注意它在 2.x 和 3.x 里返回的都是带 z 的 Vec3拿 .x 和 .y 用就行别把它当 Vec2 解构。按下时记录抓取偏移 grabOffset item.position - touchLocal移动时 item.position touchLocal grabOffset。这样 item 不会“跳”到手指标记点而是保持按下时手指相对 item 的位置手感上像真的被抓住。2.3 两个实现流派手动布局 vs Layout 自动重排Cocos 自带 Layout 组件很多人第一反应是用 Layout 自动排列拖拽时只改子节点的 siblingIndex 让 Layout 重新排版。这个方案确实能跑但有两个问题一是 Layout 的刷新是延迟的拖拽过程中子节点顺序频繁变化界面会闪二是 Layout 没有内建的动画过渡重排是瞬移的手感很硬。对比下来我更建议手动布局不用 Layout自己控制每个 item 的 position。这样动画完全可控tween 想怎么插值就怎么插值。代价是 content 尺寸要自己维护、新增 item 时自己要定位代码多一些但对拖拽排序这种交互密集的场景更稳。方案动画过渡实现成本适用场景Layout siblingIndex无瞬移低静态列表、无需拖拽的回显手动布局 就地重排可做 tween中拖拽排序、手感动画浮层拖拽克隆节点到上层可做高跨列表拖拽、拖出列表删除浮层拖拽是第三种做法拖拽开始时把 item 克隆到 ScrollView 上层原位置留一个占位拖动的是克隆节点。它最大的优势是不受 mask 裁剪影响能拖到列表外但坐标维护和销毁时机都比较麻烦。单个列表内排序手动布局足够后面代码全部基于这个方案。3. 写一个可复用的 DragSortList 组件长按判定与索引重排3.1 组件挂载方式与列表项约定组件挂在 content 节点上所有 item 是 content 的直接子节点。列几个硬性约定每个 item 高度固定组件里用 itemHeight 统一管理content 上不要挂 cc.Layoutitem 高度不固定也能做但索引计算要改为按累计高度查表复杂度高不少第一版固定高度足够覆盖绝大多数列表。const { ccclass, property } cc._decorator; ccclass export default class DragSortList extends cc.Component { property({ tooltip: 每个列表项的固定高度 }) itemHeight: number 120; property({ tooltip: 长按多久进入拖拽模式秒 }) longPressDelay: number 0.2; property({ tooltip: 归位动画时长秒 }) animDuration: number 0.12; private items: cc.Node[] []; private draggingItem: cc.Node null; private draggingIndex: number -1; private dragStartTouch: cc.Vec3 null; private grabOffset: cc.Vec3 null; private pressTime: number 0; private dragEnabled: boolean false; private longPressTriggered: boolean false; private activeTouchId: number -1; onLoad() { this.init(); } init() { this.unscheduleAllCallbacks(); this.items this.node.children.filter(c c.active); this.items.forEach(item { item.off(cc.Node.EventType.TOUCH_START); item.off(cc.Node.EventType.TOUCH_MOVE); item.off(cc.Node.EventType.TOUCH_END); item.off(cc.Node.EventType.TOUCH_CANCEL); item.on(cc.Node.EventType.TOUCH_START, this.onTouchStart, this); item.on(cc.Node.EventType.TOUCH_MOVE, this.onTouchMove, this); item.on(cc.Node.EventType.TOUCH_END, this.onTouchEnd, this); item.on(cc.Node.EventType.TOUCH_CANCEL, this.onTouchEnd, this); }); this.node.height this.items.length * this.itemHeight; } }逻辑说明init 方法做成可重入的方便列表动态增删 item 后重新收集和绑定。onLoad 里直接调 init后续外部新增 item 时再调一次 init 即可。content 的高度在这里一次性算好ScrollView 才能算出正确的滚动范围。参数说明itemHeight 是排序的基准单位所有索引计算都依赖它所以每个 item 的实际高度必须一致。longPressDelay 不建议低于 0.15 秒否则快速滚动时容易误触拖拽。animDuration 控制在 0.1 秒上下太短没过渡太长松手后有黏滞感。3.2 触摸事件绑定与长按判定逻辑拖拽状态机在 update 里计时在 TOUCH_MOVE 里判断是否取消。这里的关键是长按没触发前所有触摸事件都不要拦截让 ScrollView 正常工作长按触发后用 propagationStopped 阻断事件继续上抛。update(dt: number) { if (this.activeTouchId 0 this.dragEnabled !this.longPressTriggered) { this.pressTime dt; if (this.pressTime this.longPressDelay) { this.startDragging(); } } } private onTouchStart(event: cc.Event.EventTouch) { if (this.activeTouchId 0) return; // 忽略多指 this.activeTouchId event.touch.getID(); const node event.currentTarget as cc.Node; // 谁触发就是谁 this.draggingItem node; this.draggingIndex this.items.indexOf(node); const touchLocal this.node.convertToNodeSpaceAR(event.touch.getLocation()); this.grabOffset cc.v3( node.position.x - touchLocal.x, node.position.y - touchLocal.y, 0 ); this.dragStartTouch touchLocal; this.pressTime 0; this.dragEnabled true; this.longPressTriggered false; } private onTouchMove(event: cc.Event.EventTouch) { if (event.touch.getID() ! this.activeTouchId) return; if (!this.draggingItem) return; if (!this.longPressTriggered) { // 还没进拖拽模式先看这算不算滚动 const delta event.touch.getDelta(); if (Math.abs(delta.y) 10 || Math.abs(delta.x) 10) { this.dragEnabled false; // 取消拖拽候选交给 ScrollView this.clearState(); } return; } // 已经进入拖拽阻止 ScrollView 滚动 event.propagationStopped true; this.updateDraggingPosition(event); this.updateSorting(); }逻辑说明onTouchStart 里只记录状态不做任何位置修改避免按下瞬间 item 跳动。onTouchMove 分两条路未触发长按时手指移动超过 10 像素就判定为滚动dragEnabled 置 falseupdate 里不会再触发拖拽已触发长按时直接把事件拦住不交给 ScrollView然后更新位置和排序。这里注意ScrollView 的滚动事件是通过事件冒泡从 item 一路传到 ScrollView 节点的。我们在 item 的监听器里不调 propagationStopped滚动就能正常发生一旦调了ScrollView 就收不到这个 move 事件自然不滚。这个机制是整个冲突解法的核心。3.3 拖拽移动与目标索引重算splice 法拖拽中要做两件事把 item 放到手指位置然后算出它当前应该落在哪个索引再让其他 item 给新位置让位。private updateDraggingPosition(event: cc.Event.EventTouch) { const touchLocal this.node.convertToNodeSpaceAR(event.touch.getLocation()); this.draggingItem.setPosition( touchLocal.x this.grabOffset.x, touchLocal.y this.grabOffset.y, 0 ); } private updateSorting() { const y this.draggingItem.position.y; const newIndex cc.misc.clamp( Math.round(-y / this.itemHeight), 0, this.items.length - 1 ); if (newIndex this.draggingIndex) return; // 把拖拽项从原位置取出插入新位置 const curIndex this.items.indexOf(this.draggingItem); this.items.splice(curIndex, 1); this.items.splice(newIndex, 0, this.draggingItem); this.draggingIndex newIndex; this.refreshPositions(false); } private refreshPositions(includeDragging: boolean) { this.items.forEach((node, i) { if (node this.draggingItem !includeDragging) return; const targetY -i * this.itemHeight; const targetPos cc.v3(0, targetY, 0); // 距离小于 1 像素直接跳过避免无意义动画 if (Math.abs(node.position.y - targetY) 1) return; cc.tween(node) .stop() .to(this.animDuration, { position: targetPos }, { easing: quadOut }) .start(); }); }逻辑说明newIndex 用 -y / itemHeight 四舍五入算出。y 是拖拽项在 content 坐标系下的纵坐标content 锚点是 (0.5, 1) 时第 0 项 y 为 0第 i 项 y 为 -i * itemHeight所以取负号除以高度就是目标索引。clamp 保证拖到边界外不会越界。splice 法的好处不管拖拽项跨越多少个位置其他项的目标位置都由数组顺序自动决定不需要逐个判断谁上谁下。refreshPositions 遍历整个数组把每个非拖拽项 tween 到它的新位置。每次索引变化都全量刷新一次项目数在几十个以内完全没压力。3.4 松手归位与回调通知松手时进入两条分支如果是滚动造成的取消直接清状态如果确实拖拽中先把拖拽项也做归位动画再把排序结果通过回调抛出去。private onTouchEnd(event: cc.Event.EventTouch) { if (event.touch.getID() ! this.activeTouchId) return; if (this.longPressTriggered) { event.propagationStopped true; this.refreshPositions(true); // 拖拽项也归位到目标索引 const list this.items.map(node node.name); this.onSortEnd this.onSortEnd(list, this.draggingIndex); } this.clearState(); } private clearState() { this.draggingItem null; this.activeTouchId -1; this.dragEnabled false; this.longPressTriggered false; this.pressTime 0; }常见的做法是在组件里留一个可覆写的 onSortEnd 回调把排序后的节点顺序和最终索引传给外部。外部拿到这个数组后按顺序刷新数据层。如果你的 item 节点挂了自定义组件存数据 id这里直接把 id 数组取出来会更实用这个放到进阶章讲。4. 渲染顺序、动画手感与 Content 尺寸让排序不“僵硬”4.1 把拖拽项提到最上层siblingIndex 的玄学拖拽项在移动过程中很可能被其他 item 盖住。因为同级的渲染顺序按子节点顺序走被拖的项如果没有排到最上层它会在交叉越过其他项时“钻进”别人下面。很多人遇到这个现象以为是 zIndex 没设其实是节点顺序的问题。private startDragging() { if (this.longPressTriggered) return; this.longPressTriggered true; this.draggingItem.setSiblingIndex(this.node.children.length - 1); }逻辑说明startDragging 在长按触发的一瞬间把拖拽项挪到 content 的子节点最末尾也就是渲染的最上层。这里注意我们操作的 items 数组是逻辑顺序不受 siblingIndex 影响节点显示顺序则被提升两者不冲突。之后排序动画用的是 items 数组渲染遮挡用的是节点顺序各管各的。如果你给 item 设了比较高的 zIndex也可以达到同样的效果但 zIndex 在 Canvas 渲染批次里有时会受到父节点影响不如 setSiblingIndex 直接。我一般优先用 siblingIndexzIndex 留给跨 UI 层级的浮层场景。4.2 tween 动画的参数选择时长、缓动与复用动画手感主要靠三个参数时长、缓动曲线、tween 复用。时长太长会让排序拖泥带水太短等于没有过渡quadOut 这种先快后慢的缓动让列表项在归位时有一种“自己滑过去”的感觉比 linear 自然很多。参数推荐值说明animDuration0.08 ~ 0.15s小于 0.08 无过渡大于 0.2 拖沓easingquadOut先快后慢归位不突兀longPressDelay0.15 ~ 0.25s太短误触太长不跟手滚动判定阈值8 ~ 12px超过即判定为滚动取消拖拽一个容易忽略的细节tween 在目标位置频繁变化时会叠加。如果不先 stop 再重新 to一个节点上会同时挂着多个 tween表现就是抖动、鬼畜。refreshPositions 里每次 tween 前调 stop是必须写的别省。cc.tween(node) .stop() .to(this.animDuration, { position: targetPos }, { easing: quadOut }) .start();逻辑说明stop 会把节点上未完成的 tween 全部停掉然后以当前位置为起点开始新动画。拖拽频繁换位时这个写法保证永远只有一个动画在跑。实际调试中项目数超过 50 个时如果发现动画吃帧优先把 animDuration 调到 0.08再把目标位置距离小于 3 像素的节点直接 setPosition能省掉不少无意义动画。4.3 Content 尺寸与列表项定高的约定手动布局意味着 content 的高度不会自动变。你不维护 heightScrollView 的滚动范围就是错的要么滚不动要么多出一大截空白。常规做法是在 init 和拖拽结束后都更新一次。private updateContentSize() { const targetHeight this.items.length * this.itemHeight; if (Math.abs(this.node.height - targetHeight) 1) { this.node.height targetHeight; } }逻辑说明拖拽排序本身不增删 item数量不变content 高度理论上不变。但如果你在 onSortEnd 里做了删除项或新增项的操作必须回到 init 重新收集并调用 updateContentSize 修正滚动范围。代码里用差值 1 像素做判断避免每帧都赋值引发不必要刷新。还有一个锚点约定建议把 content 的 anchorY 设为 1这样 position 为 (0, 0) 就是列表第一项的初始位置往下依次是 -itemHeight 的整数倍。索引换算的公式依赖这个约定anchor 不对索引和位置就会系统性偏移那种“拖到两个 item 中间结果插到隔壁”的怪现象十有八九是这个。5. 拖拽排序排查手册四个必踩的坑5.1 拖动列表项时列表却在滚动现象长按 item 后手指一滑列表整个滚动了item 没有跟随。原因事件冒泡到了 ScrollView拖拽逻辑没拦住 move 事件。最常见的是没有调 propagationStopped或者在 TOUCH_MOVE 里没判断 longPressTriggered。解决进入拖拽状态的 move 事件里必须event.propagationStopped true。同时确认长按判定用的 delta 判断没把拖拽误杀——如果你在一次 move 里 delta.y 超过 10 就 cancel用户长按后小幅移动也会被误判为滚动。正确逻辑是先看长按是否触发再看位移。5.2 长按后拖拽项瞬间“跳”到别处现象长按触发后 item 突然移到屏幕另一个位置或者跳到列表末尾。原因一般是坐标换算参照系错了。拖动中用 item 自己做了 convertToNodeSpaceARitem 一动参照系就漂或者 grabOffset 没有记录直接把 item 位置设成触摸点导致 item 中心跳到手指下。解决统一把触摸点换算到 content 坐标系grabOffset 在 onTouchStart 里一次性记好移动时保持偏移。另外确认 touch.getLocation() 是屏幕坐标不要手动除以缩放比convertToNodeSpaceAR 已经处理。5.3 排序动画鬼畜、来回抖动现象拖拽时其他 item 疯狂抖动或者归位时来回弹。原因tween 没有 stop 就重复启动同一节点挂了多个 tween目标位置互相覆盖或者 itemHeight 设置与实际节点高度不一致索引计算来回横跳。解决每个 tween 前先 stopitem 的实际高度必须严格等于 itemHeight有缩放或者 anchorY 不一致都会导致索引计算漂移。调试时可以在 updateSorting 里打印 newIndex看它是不是在连续帧里来回变变就说明高度约定有问题。5.4 多指操作把两个项同时拖动现象手指按在 A 上拖到一半再用另一根手指按 BB 也进入拖拽两个 item 一起乱跑。原因没有做触摸 id 过滤。第二个手指触发 onTouchStart 时activeTouchId 还是第一根手指的值但代码没拦截直接把第二根手指的数据覆盖了。解决onTouchStart 开头判断if (this.activeTouchId 0) return后续所有事件根据event.touch.getID()和 activeTouchId 比对不一致的直接忽略。这个判断加上之后多指乱象会立刻消失。6. 进阶数据同步、排序撤销与版本迁移6.1 排序结果写回数据层用一个回调兜底onSortEnd 只传节点数组还不够实际项目里 item 可能挂了自己的数据组件。常规做法是从节点上取数据 id再按顺序重排数据数组。onSortEnd(orderList: string[], newIndex: number) { const dataList orderList.map(name this.itemDataMap[name]); // 外部调用方按 dataList 更新配置或存档 this.node.emit(drag-sort-end, dataList); }逻辑说明itemDataMap 是节点名字到数据对象的映射排序结束后数据顺序就是视觉顺序。把结果做成事件抛出去组件本身不关心数据存哪调用方自己处理存档或服务端同步边界清晰。6.2 撤销排序操作栈的简单实现做编辑器或配置型界面时用户会想要后悔药。做法很朴素每次排序结束把移动前后的索引对压栈撤销时反向执行一次。索引对就是 draggingIndex 的旧值和新值数据还原时做一次反向 splice 即可。private history: Array{ from: number, to: number } []; undo() { const last this.history.pop(); if (!last) return; // 反向移动一次代码逻辑与拖拽排序一致 }逻辑说明每个 item 的数据对象在排序时跟着节点数组走所以撤销只需要把数据数组里的元素从 to 移回 from。注意拖拽过程中会触发多次 updateSorting只在 onTouchEnd 记录最终一次结果避免历史栈里塞满中间态。6.3 2.x 代码迁移到 3.x 的替换清单上面代码是 2.x 风格迁移到 3.x 只需要做几处机械替换。convertToNodeSpaceAR 和事件枚举在 3.x 仍然存在核心拖拽流程不需要重写。功能2.x3.x全局对象cc 命名空间按需 importtween 调用cc.tween(node)tween(node)触摸事件cc.Node.EventType.TOUCH_STARTNode.EventType.TOUCH_START事件对象cc.Event.EventTouchEventTouchcontent 高度node.height hnode.getComponent(UITransform).setContentSize(w, h)位移计算cc.v3v3可能是向量临时对象注意复用迁移时最容易忽略的是 UITransform。3.x 里节点尺寸归 UITransform 管直接改 height 会不生效。建议迁移后先跑一遍长按拖拽流程重点看 content 滚动范围对不对再跑一遍多指测试。我这个组件已经在某模拟项目X的背包系统上稳定跑过核心就是状态机、坐标系和 tween 复用三件事。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询