简介:面向 Cocos Creator 初学者与需要实现可拖拽排序列表的开发者,这份资源围绕触摸或鼠标拖拽重排、排序数据存储与界面状态刷新三个核心环节,提供可运行的示例工程与脚本参考,可用于背包、任务栏、商城等需要自定义顺序的界面场景。压缩包共 16 个文件,约 243KB,以 TypeScript 脚本、JSON 工程配置、meta 资源描述及 Cocos Creator 场景文件为主,另含少量项目辅助配置,结构紧凑,便于导入编辑器对照理解节点层级、组件绑定与事件监听。已有 114 人学习下载,适合作为入门与功能验证的轻量样例。通过拆解示例,读者可掌握拖拽开始、移动、结束的事件处理思路,理解数组顺序与节点位置同步更新、拖拽完成后排序状态保存的方法,并借此延伸到对象池、批处理渲染等性能优化方向,减少从零搭建排序列表时的试错成本。
1. 拖拽排序列表看着简单,问题全出在坐标系上
在 cocos creator 里做拖拽排序列表,第一版往往都能跑:手指按住 item 跟着走,松手交换数据。但只要列表放进 ScrollView、挂上 Layout、项数超过一屏,问题就集中出现:拖到屏幕边缘被裁掉、松手后位置回弹、快速拖动索引乱跳。问题不在逻辑,而在三个坐标系没对齐:触摸事件给的是 UI 世界坐标,item 的 position 是 content 本地坐标,算目标索引用的又是"第几行"这套逻辑坐标。
这份资源是一个可直接复用的拖拽排序列表实现,核心是 DragSortItem 管单项手势、DragSortList 管数据与布局两层结构,数据与视图分离,不依赖第三方库,只用 cocos creator 内置的触摸事件、UITransform 和 tween。适用于设置项排序、播放列表调整、看板卡片重排这类场景。适合已经能拼 UI、但被拖拽细节卡住的开发者。
2. 事件与坐标:把手指位置换算成列表里的第几个
2.1 拖拽触发用"长按 + 位移阈值",别一按下就动
一按下就进入拖拽态是最常见的写法,也是最容易出问题的写法。列表在 ScrollView 里时,用户想滚动,结果手指刚碰到 item 就被拎起来了;两个手指同时按下,两个 item 同时进入拖拽。我的做法是同时看两个条件:按住超过 120~200ms,或位移超过 6~10px,满足其一才真正进入拖拽态。前者贴合"长按排序"的直觉,后者照顾"直接拖"的习惯。
import { _decorator, Component, Node, EventTouch, Vec2, Vec3, v3 } from 'cc'; const { ccclass, property } = _decorator; @ccclass('DragSortItem') export class DragSortItem extends Component { @property({ tooltip: '触发拖拽的位移阈值,单位 px' }) dragThreshold = 8; @property({ tooltip: '长按判定时间,单位秒' }) longPressTime = 0.15; index = 0; // 当前在数据数组中的位置 private _touchId: number | null = null; // 只认第一个按下的手指 private _pressTime = 0; // 按下累计时长 private _pressing = false; private _dragging = false; 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.onTouchCancel, 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.onTouchCancel, this); } private onTouchStart(e: EventTouch) { if (this._touchId !== null) return; // 已有手指在操作,直接忽略 this._touchId = e.getID(); this._pressTime = 0; this._pressing = true; this._dragging = false; } }_touchId那段是整个类的第一道保障:多点触控时只保留首个触点,后面的 TOUCH_START 全忽略,避免两个 item 绑在一起走。dragThreshold设 8px 是移动端的经验值,太小会被手指轻微抖动误触发,太大则拖起来发黏;longPressTime给 0.15s,低于 0.1s 基本等于按下即拖,高于 0.3s 用户会以为没反应。
2.2 三个坐标系各管一段,别混着用
先把三套坐标摆清楚,后面所有计算都基于这张表:
| 坐标类型 | 来源 | 单位与原点 | 用途 |
|---|---|---|---|
| UI 世界坐标 | event.getUILocation() | px,屏幕左下角 | 判断手指相对可视区的位置 |
| content 本地坐标 | UITransform.convertToNodeSpaceAR | px,content 锚点为原点,y 向上 | 设置拖拽项与其余项的位置 |
| 索引坐标 | 由本地 y 反算 | 第几行,0 起 | 数据交换、补位动画目标 |
最容易错的一步是把getUILocation()的结果直接赋给 item 的 position。只有当 item 的父节点恰好锚点在 (0.5,0.5) 且没有滚动偏移、没有嵌套层级时,两者才碰巧相等,一旦 content 被 ScrollView 滚起来就立刻错位。
// 把触摸点从 UI 坐标转换到 content 的本地坐标系 private toContentLocal(uiPos: Vec2): Vec3 { const uiTf = this.content.getComponent(UITransform)!; return uiTf.convertToNodeSpaceAR(v3(uiPos.x, uiPos.y, 0)); }convertToNodeSpaceAR的 "AR" 是 anchor relative,即以目标节点的锚点为原点,这正是我们想要的:算出来的 y 可以直接和 item 的position.y比大小,不需要再减一次 content 高度的一半。前提是 content 的锚点保持 (0.5,0.5);如果项目里习惯用左上角锚点 (0,1),公式里所有H/2都要换成对应的锚点偏移,否则会整体偏一屏。
2.3 反查目标索引:一次除法加一次 clamp
不管是拖动中实时预览还是松手落位,都绕不开同一个问题:当前拖拽项的中心点落在第几行。在固定行高、固定间距的前提下,这就是一次除法:
// 第 i 项中心的本地 y = H/2 - h/2 - i * (h + gap) // 反解得 i = (H/2 - h/2 - y) / (h + gap) private indexAtLocalY(localY: number): number { const uiTf = this.content.getComponent(UITransform)!; const H = uiTf.height; // content 总高 const step = this.itemHeight + this.spacing; const raw = (H / 2 - this.itemHeight / 2 - localY) / step; // 四舍五入:以 item 中心为基准,拖过一半才换位 const idx = Math.round(raw); return Math.min(Math.max(idx, 0), this.itemCount - 1); // 必须 clamp }三个参数里itemHeight和spacing建议在初始化时从 prefab 的 UITransform 上实测读取,而不是硬编码常量,否则换了皮肤或加了字号,排序位置就会整体偏。Math.round而不是Math.floor是关键:用 round,拖拽项中心越过相邻项中心时才交换,手感是"过半换位";用 floor 会变成"一碰就换",拖过快时索引会连续跳好几格,视觉上像抽搐。
clamp同样不能省。手指拖到列表上方或下方很远时,raw会是负数或超过itemCount-1,不夹住的话splice会静默插入到数组末尾,表现为"项数没变但顺序全乱了",而且很难复现。
2.4 数据与视图分离:数组顺序是唯一权威
很多人把顺序信息同时存在两处:item 节点在父节点里的 siblingIndex,和数据数组的下标。这两个一旦不同步,下一次拖拽就会跳变。我的做法是只认数组,视图永远是被动渲染的:
interface SortItemData { id: string; title: string; icon: string; } private _data: SortItemData[] = []; private _views: DragSortItem[] = []; // from 与 to 都是数组下标,交换后立刻重排视图 onDrop(from: number, to: number) { if (from === to || from < 0 || to < 0) return; const [moved] = this._data.splice(from, 1); this._data.splice(to, 0, moved); this.syncIndex(); // 把 _data 的下标写回每个 view.index this.layoutAll(); // 按新顺序摆位置 } private syncIndex() { for (let i = 0; i < this._views.length; i++) { this._views[i].index = i; } }syncIndex()这一行看着多余——毕竟布局按数组顺序摆的——但它是后面所有拖拽计算的输入:indexAtLocalY算出目标索引后,要拿它和当前 item 的index比较才知道要不要触发交换。漏掉这一步,第二次拖拽的起点就是错的,表现是"第一下正常,第二下拖不动或者跳到别处"。数据结构尽量保持扁平,只放展示字段和 id;如果排序结果要落盘,落的是 id 数组而不是下标,因为下标会随增删失效。
3. 补位动画:关掉 Layout,让 tween 接管位置
3.1 Layout 和手动定位只能二选一
给 content 挂 Layout(VERTICAL)能省掉第一次布局的代码,但拖拽排序这个场景里,它和 tween 是冲突的。Layout 在lateUpdate阶段按子节点顺序重排位置,而 tween 是逐帧改position,两边同时写,结果就是补位动画播到一半被拽回原位,或者干脆不播、直接瞬移。
处理方式有两条路,我倾向前者:
- 保留 Layout 组件但
layout.enabled = false,只在初始化时手动调一次layout.updateLayout()拿到基准位置,之后全程手动摆; - 彻底删掉 Layout,在
onLoad里就把每项的 y 算好写进一个_slotY: number[]数组。
第二条更干净,推荐。_slotY数组的好处是所有补位目标位置都是查表,不需要每次现算,也不受 content 高度变化影响——真要变高度,重建一次数组即可。
3.2 一次交换的完整时序
把"拖动中预览"和"松手落位"分开处理,但布局函数是同一个:
// 按 _slotY 把所有项摆到位,skip 用于跳过正在被手指拖着的那一项 private layoutAll(skip: DragSortItem | null = null, duration = 0.18) { for (let i = 0; i < this._views.length; i++) { const view = this._views[i]; if (view === skip) continue; const targetY = this._slotY[i]; const node = view.node; Tween.stopAllByTarget(node); // 关键:先停掉上一次补位动画 tween(node) .to(duration, { position: v3(node.position.x, targetY, 0) }, { easing: 'quadOut' }) .start(); } }Tween.stopAllByTarget(node)是这段代码里最不能删的一行。快速拖动时,同一项可能在一秒内被连续指定两次不同的目标位置,不停掉旧动画就会出现两段 tween 抢同一个属性,表现为位置来回抖动。duration取 0.15~0.2s,低于 0.1s 看不出补位过程、像瞬移,高于 0.25s 会明显跟不上手指。缓动用quadOut,起步快收尾缓,符合"被挤开"的物理直觉;backOut会回弹一点,短列表里好看,长列表里连续回弹会显得很乱。
拖拽项本身不走这个函数,它的位置由手指的位移增量直接驱动:
private onTouchMove(e: EventTouch) { if (e.getID() !== this._touchId) return; const local = this.toContentLocal(e.getUILocation()); if (!this._dragging) { this._pressTime += /* 由 update 累计的 delta */ 0; const moved = Vec3.distance(local, this._startLocal); if (moved < this.dragThreshold && this._pressTime < this.longPressTime) return; this.enterDrag(); // 真正进入拖拽态 } // 直接跟随手指,不做平滑,否则会有"橡皮筋"的滞后感 this.node.setPosition(this._startNodePos.x, this._startNodePos.y + (local.y - this._startLocal.y), 0); this.trySwap(); }注意这里用的是"起点位置 + 位移增量",而不是把 item 位置设成触摸点。两者在数学上接近,但前者不受手指按在 item 哪个位置影响——按在卡片下沿往上拖,卡片不会被瞬间吸到手指中心。
3.3 拖拽项的层级:换父节点,而不只是改 siblingIndex
只调setSiblingIndex是不够的。拖拽项仍然是 content 的子节点,会被 content 上的 Mask 裁剪——拖到可视区外就消失一半;也会被后渲染的兄弟节点盖住。我的做法是准备一个覆盖全屏的 dragLayer(挂在 Canvas 下、最上层、无 Mask),拖拽开始时把节点搬过去:
private enterDrag() { this._dragging = true; this._originParent = this.node.parent!; this._originSibling = this.node.getSiblingIndex(); const worldPos = this.node.getWorldPosition(); this.node.parent = this.dragLayer; // 换父节点 this.node.setWorldPosition(worldPos); // 保持屏幕位置不变 this.node.setScale(1.04, 1.04, 1); // 轻微放大,提供"拎起来"的反馈 }setWorldPosition必须紧跟在改 parent 之后,否则节点会因为父节点坐标系变化而瞬移。至于放大 1.04,是个成本极低但体感提升明显的细节——用户能立刻确认"这个东西现在归我控制"。松手时反向操作,先算落位目标、tween 回原 parent 下的槽位,再恢复 siblingIndex 和 scale。如果列表项有圆角背景或阴影,记得在拖拽态换一张不带阴影的图,否则放大后阴影也跟着放大,边缘会糊。
3.4 实时预览交换与松手落位要一致
拖动过程中一旦目标索引变化,就立刻重排一次(skip掉自己),用户能看到其他项让位的动画;松手时再调一次onDrop把数据真正换过去,然后把拖拽项 tween 到新槽位。这两步必须用同一套_slotY和同一个layoutAll,否则会出现"预览时看着在第 3 位,松手后落到第 4 位"的错位。
还有一个容易忽略的点:onDrop(from, to)里的from要用 item 的index而不是拖动前记下的旧值。因为拖动过程中如果已经做过预览交换,数组其实没动,index也没动,用哪个都一样;但如果你的实现里预览阶段就改了数组,那就必须用实时值。我的建议是预览阶段只动视觉、不动数组,松手才提交,这样撤销逻辑也好写。
4. 与 ScrollView 共存:阈值、锁滚动、边缘自动滚
4.1 先判意图,再决定谁响应
列表放进 ScrollView 后,同一根手指既可能是在滚动页面,也可能是在拖 item。判别方式就是 2.1 里那套"长按 + 阈值":没达到条件前,事件继续传给 ScrollView 正常滚;达到条件后才接管。有个细节要注意,item 上的 TOUCH_START 不会自动阻止 ScrollView 滚动——ScrollView 监听的是它自己的节点,两者各收各的。所以想"拖拽时页面不动",只能显式处理。
4.2 进入拖拽后锁住滚动
private lockScroll(lock: boolean) { if (!this.scrollView) return; this.scrollView.enabled = !lock; // 最直接:整个组件停掉 if (lock) this.scrollView.stopAutoScroll(); }把 ScrollView 组件enabled = false会连它的触摸监听一起停掉,对纯排序列表来说正合适。但如果这个 ScrollView 上还挂着别的手势(比如下拉刷新),就得改成只禁用它的TOUCH_MOVE逻辑,或者用一个_locked标志位在回调里提前 return。另外,stopAutoScroll()也别漏——用户可能是甩了一下列表惯性滚动中按住的 item,不叫停的话松手后 content 还在飘,落位位置就错了。
4.3 拖到可视区边缘时自动滚动
长列表必然遇到"想把第 30 项拖到第 2 项,但第 2 项在屏幕外"。手动滚是不可能的,因为手指正按着 item。标准解法是边缘感应 + 持续滚动:
update(dt: number) { if (!this._dragging) return; const viewTf = this.viewport.getComponent(UITransform)!; const local = viewTf.convertToNodeSpaceAR(this.node.getWorldPosition(), v3()); const edge = 60; // 边缘感应带宽度,px const maxSpeed = 600; // 最大滚动速度,px/s const halfH = viewTf.height / 2; let speed = 0; if (local.y > halfH - edge) { speed = maxSpeed * Math.min(1, (local.y - (halfH - edge)) / edge); } else if (local.y < -halfH + edge) { speed = -maxSpeed * Math.min(1, (local.y - (-halfH + edge)) / edge); } if (speed === 0) return; const offset = this.scrollView.getScrollOffset(); this.scrollView.scrollToOffset(new Vec2(offset.x, offset.y - speed * dt), 0); // content 动了,拖拽项的世界位置没动,本地坐标变了 —— 必须重算索引 this.trySwap(); }这里有三处必须说明。第一,速度用线性插值而不是恒定值,越靠近边缘越快,手感更自然;直接从边界跳满速会很突兀。第二,scrollToOffset后面那个0是时长,传 0 表示立即生效、不播动画——我们自己在update里逐帧推,再叠一层动画会打架。不同小版本对scrollToOffset可能有弃用提示,换成等价的scrollTo重载即可,语义一样。第三,滚动之后 content 的本地点变了,但拖拽项挂在 dragLayer 上世界坐标没变,所以它在 content 坐标系里的本地 y 是变的,必须立刻重算索引,否则会出现"滚了半天,插入位置还是原来那个"。
4.4 参数集中管理
这些数散落在各个方法里,调手感时得全局搜,不如收进一个配置对象:
| 参数 | 建议值 | 调大的后果 | 调小的后果 |
|---|---|---|---|
| dragThreshold | 6~10 px | 拖起来发黏、延迟明显 | 轻微抖动就误触发 |
| longPressTime | 0.12~0.2 s | 用户以为没响应,重复按 | 与滚动几乎无法区分 |
| tweenDuration | 0.15~0.2 s | 补位跟不上,看着拖沓 | 接近瞬移,无过程感 |
| edgeBand | 40~80 px | 稍一靠近边缘就狂滚 | 要拖到屏幕外才触发 |
| autoScrollMaxSpeed | 400~800 px/s | 滚过头、难以定位 | 长列表拖一次要等很久 |
| dragScale | 1.02~1.06 | 遮挡相邻项、显得笨重 | 看不出被拎起 |
参数没有普适值,跟 item 高度强相关:行高 80px 的列表,edgeBand给 60px 差不多是一行半;行高 160px 的大卡片,给 80px 才够。我一般是先按行高定edgeBand ≈ 0.8 × itemHeight,再在真机上微调。
5. 避坑与排查:真机才复现的五条
5.1 拖拽项拖出可视区就消失,或者被下面的卡片盖住
现象:编辑器预览里一切正常,打包到手机上,往下拖一点点,item 的下半截就没了,再拖远一点整块消失。
原因:拖拽项还是 content 的子节点,content 上挂着 Mask 做裁剪,超出部分被裁掉。编辑器里 Mask 有时不生效或者分辨率不同,不容易发现。
解决:按 3.3 的做法,拖拽开始时把节点搬到 Canvas 下的 dragLayer,setWorldPosition保持屏幕位置。dragLayer 不要挂 Mask,siblingIndex 设成最大值。松手时再搬回来。另外检查一下 Widget 对齐,dragLayer 的尺寸要铺满全屏,否则搬到边缘位置照样被父节点的尺寸限制裁掉。
5.2 松手后位置回弹,或者补位动画播到一半被拽回去
现象:拖完松手,item 飞到目标位置,但下一帧又跳回拖动前的地方,来回闪一下。
原因:content 上的 Layout 组件还开着。Layout 在lateUpdate里按子节点顺序重排,tween 在update里改 position,两个系统每帧互相覆盖。
解决:把layout.enabled = false关死,或者在onLoad里调完一次updateLayout()后直接把组件destroy()。如果不想删组件,至少在enterDrag()里先关、onDrop动画结束的回调里再决定要不要开——但列表一旦支持排序,Layout 的自动排序本身就没有意义了,直接不用更省心。
5.3 快速拖动时索引连续跳变,插入位置乱飞
现象:手指快速划过几个 item,trySwap被连续触发,_data被 splice 好几次,最终位置完全不对。
原因:Math.floor做索引 + 每次 MOVE 都无条件重排,手指跨过 5 行就触发 5 次交换,而每次交换后其它项又开始 tween,位置参考系在变。
解决:改用Math.round(过半才换),并且在trySwap里加一道判断,只在目标索引与本项index不同时才执行;同时不要在预览阶段改数组,只改视觉。如果还是抖,加一个 30~50ms 的节流,让连续快速移动合并成一次。
5.4 拖到列表最上方或最下方,顺序整个错乱
现象:把最后一项往上拖到顶部区域,松手后它跑到数组末尾,或者列表少了一项。
原因:indexAtLocalY算出的raw越界,没有 clamp。splice(from, 1)之后数组长度减一,再用越界的to执行splice(to, 0, moved),JS 会静默按末尾处理,不报错但结果不对。
解决:clamp 必须在两处都做,一是在indexAtLocalY里夹住[0, itemCount-1],二是在onDrop开头再校验一次from和to的合法性。这类错误最麻烦的地方是不抛异常,只能靠肉眼比对顺序,所以在onDrop里加一行断言式日志,开发期打开、上线前关掉。
5.5 拖拽时页面也在滚,两件事同时发生
现象:按住 item 往上拖,item 跟着走,但整个列表页面也在往上滚,位移叠加,item 很快飞出屏幕。
原因:没有禁用 ScrollView 的滚动,或者禁用的时机太晚——要等位移超过阈值、确认是拖拽意图之后才锁,但在此之前用户已经产生了滚动量。
解决:在enterDrag()的第一行就调lockScroll(true),早于任何位置计算;松手或TOUCH_CANCEL时无条件lockScroll(false)。TOUCH_CANCEL尤其不能漏——来电、系统手势打断时只会发 CANCEL,不处理的话 ScrollView 会一直处于禁用状态,用户以为页面卡死了。
6. 项数上到 200 以后:复用、收敛 tween 和一套验证手法
20 项以内,上面这套全量重排完全没有压力,每帧改几十个节点的 position 不算事。但列表一旦上百,layoutAll每帧遍历所有节点、每个节点起一个 tween,光 GC 就能让帧率掉到 30 以下。做长列表时我一般这么改:
第一步,只对可视区 + 上下各 3 项做补位动画,其余节点直接setPosition瞬移,不起 tween。判断方式很简单,用每项的_slotY和当前content.position.y比,超出可视高度的直接跳过原动画逻辑。第二步,节点复用,滚动时回收离开窗口的节点、从对象池取新的:
import { NodePool } from 'cc'; private _pool = new NodePool(); private acquire(): Node { const n = this._pool.size() > 0 ? this._pool.get()! : instantiate(this.itemPrefab); n.active = true; return n; } private recycle(n: Node) { n.active = false; this._pool.put(n); // 放回池里,避免频繁 instantiate / destroy }NodePool在 3.x 里仍然可用,put之后节点要从父节点摘下或置为active = false,否则会残留在舞台上。复用带来的新问题是 item 上的状态(缩放、透明度、拖拽标记)会带到下一个数据上,所以setData里必须把所有视觉状态重置一遍,尤其是setScale(1,1,1)——上一个被放大的卡片回收后再拿出来,会是放大的样子。
第三步,验证。别靠肉眼看帧率,我习惯在拖拽开始时打开一个计数面板,统计两件事:当前舞台节点数、每帧活着的 tween 数量。正常范围是,节点数不超过可视项数 × 1.5,tween 数不超过一屏可见项数。如果 tween 数在拖拽过程中持续上涨,说明某处漏了Tween.stopAllByTarget;如果节点数只增不减,说明回收分支没走到。
| 项数 | 全量重排每帧耗时 | 虚列表每帧耗时 | 建议 |
|---|---|---|---|
| 20 | < 0.5 ms | — | 全量重排足够 |
| 50 | 1~2 ms | — | 仍可全量,注意关掉 tween |
| 200 | 6~10 ms | 1~2 ms | 必须上可视区裁剪 |
| 1000 | 卡顿明显 | 1~3 ms | 必须节点复用 + 虚列表 |
那张表的数据来自某项目在中端机型上的粗略记录,只能看量级、不能当基准。规律是清楚的:耗时基本随节点数线性增长,而虚列表把它压成了常数。
还有个技巧:trySwap里不要每次 MOVE 都调用layoutAll,而是先把目标索引缓存下来,在update里做一次比较,只有索引真正变化时才触发重排。手指在一行内来回摩挲不会产生任何布局计算,这一步能把无效重排砍掉八成以上。
这套东西我最早是给一个卡片看板写的,当时图省事没做 clamp,测试同学把卡片拖到屏幕最下面再松手,顺序全乱,日志里一行报错都没有,查了整整一个下午。从那以后我每次写拖拽排序,第一件事就是把越界、断触、多点这三个用例先跑一遍,再谈动画好不好看。希望帮到你。
本文还有配套的精品资源,点击获取