news 2026/10/17 9:12:18

Cocos Creator 3.x拖拽排序列表:ScrollView联动与坐标换算实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Cocos Creator 3.x拖拽排序列表:ScrollView联动与坐标换算实战

简介:面向 Cocos Creator 开发者的拖拽排序列表实现资源,适合游戏背包、任务清单、设置面板等需要自定义排列顺序的界面场景。压缩包共 16 个文件,核心包含 3 个 TS 脚本、1 个 .fire 场景文件、4 个 meta 元数据及 5 个 JSON 配置,另附 .gitignore 等辅助文件,整体仅 243KB;JSON 用于工程与场景配置,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 的场景,拖拽排序会很麻烦,建议先确认交互层级再决定是否用嵌套,否则手指在里层拖拽时外层列表也会跟着滚动。

带着这套实现去搭列表,我每次都会先确认三件事:列表项高度是不是固定、内容总高度会不会超过视图、排序结束后要不要触发数据层同步。这三件事决定了整个拖拽逻辑的复杂度,确认完再动手写,基本能少踩一半的坑。把那三件事确认完再动手写,基本能少踩一半的坑。希望这篇能帮你省几个晚上的排错时间。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 9:11:12

CLI-Anything:命令行的Agent原生范式迁移

1. CLI-Anything 是什么&#xff1a;一个被热词掩盖的底层范式迁移你最近在技术社区、GitHub Trending 或 Python 相关论坛里&#xff0c;大概率已经刷到过CLI-Anything这个词——它不像 Flask 那样有明确官网&#xff0c;也不像 Poetry 那样自带清晰文档&#xff0c;更没有“手…

作者头像 李华
网站建设 2026/10/17 9:11:12

金融服务系统开发实战:账户设计、资金安全与合规经验总结

做金融服务类项目&#xff0c;很多人第一反应是“写个理财记账App”或者“套个支付接口”&#xff0c;但真正扑进去做一两年后回头看&#xff0c;会发现这行最硬核的部分根本不在功能代码里&#xff0c;而在于账户设计、资金安全、合规边界和运维稳定性这些看不见的地方。这篇文…

作者头像 李华
网站建设 2026/10/17 9:10:55

AI工作流从开发到发布:基于Coze平台的完整实战指南

做AI应用最绕不开的一个话题就是工作流。不管你是刚接触Coze的新手&#xff0c;还是已经搓过几个Bot的老手&#xff0c;应该都听过这个词——但很多人对它的理解停留在“把几个节点连起来”这个层面&#xff0c;真正上手做复杂业务的时候才发现&#xff0c;工作流的开发与发布&…

作者头像 李华
网站建设 2026/10/17 9:10:47

中国首个高分辨率全覆盖建筑屋顶面积数据集Tif详解

这两年做地理信息相关项目的人&#xff0c;应该都能感觉到一件事&#xff1a;建筑数据的需求量越来越大&#xff0c;但真正能拿来做量化分析的底图却少得可怜。尤其是“建筑屋顶面积”这个指标&#xff0c;看着简单&#xff0c;真要自己算一遍&#xff0c;能从影像预处理干到深…

作者头像 李华
网站建设 2026/10/17 9:10:26

WinForms DataGridView虚拟模式实现动态加载防卡顿

1. 项目概述&#xff1a;为什么“动态加载部分数据”不是优化选项&#xff0c;而是必选项你有没有遇到过这样的场景&#xff1a;在Windows Forms应用里拖一个DataGridView控件&#xff0c;双击就绑定个DataTable&#xff0c;几十行数据丝滑如水&#xff1b;可一旦后台查出上万条…

作者头像 李华
网站建设 2026/10/17 9:09:53

VS Code调试配置核心:launch.json与tasks.json详解

1. 项目概述&#xff1a;搞懂 launch.json 和 tasks.json&#xff0c;才是 VS Code 真正上手的分水岭刚接触 VS Code 的人&#xff0c;常以为装上插件、写完代码、点一下绿色三角就能运行——结果要么报错“找不到调试器”&#xff0c;要么提示“终端中未定义任务”&#xff0c…

作者头像 李华