引言
在 Cocos Creator 游戏开发中,ScrollView 是展示列表内容的常用组件。当 ScrollView 中的子节点包含 Button 时,用户在滚动过程中很容易误触按钮,导致极差的用户体验。本文将详细分析这一问题的原因,并提供多种解决方案,帮助开发者实现流畅的滚动交互。
问题分析
现象描述
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| ┌─────────────────────────────────────────────────────────────────────┐ │ ScrollView 按钮误触问题 │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ 用户意图:向下滚动查看列表 │ │ │ │ ┌─────────────────────────┐ │ │ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ │ │ │ ▓ [按钮1] 内容... ▓ │ ◄── 手指按下 │ │ │ ▓ [按钮2] 内容... ▓ │ │ │ │ ▓ [按钮3] 内容... ▓ │ ◄── 手指向上滑动(意图滚动) │ │ │ ▓ [按钮4] 内容... ▓ │ │ │ │ ▓ [按钮5] 内容... ▓ │ │ │ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ │ │ └─────────────────────────┘ │ │ │ │ 实际结果:手指抬起时,按钮2触发了点击事件 ❌ │ │ 期望结果:只有真正的点击才触发按钮事件 ✅ │ │ │ │ 原因:Cocos Creator 的事件系统默认由最上层节点响应, │ │ 事件只在其节点树中传递,ScrollView 和 Button 都在同一个 │ │ 事件处理体系下,滑动操作被误识别为点击 │ │ │ └─────────────────────────────────────────────────────────────────────┘
|
核心原因
Cocos Creator 的事件系统遵循以下规则:
- 最上层节点优先:触摸事件首先被最上层的可交互节点接收
- 节点树内传递:事件响应后,只在当前节点的节点树中传递,不会传递到其他节点树
- Button 默认响应:Button 组件默认会消费触摸事件,即使在滑动操作中
| 组件 |
事件处理 |
问题 |
| ScrollView |
监听触摸移动,计算偏移量 |
需要触摸事件 |
| Button |
监听触摸开始/结束,触发回调 |
会消费事件 |
解决方案一:滑动距离判断(推荐)
原理
在按钮的点击回调中,计算触摸起始点和结束点的距离。如果移动距离超过阈值,则判定为滑动操作,不执行点击逻辑。
代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
| const { ccclass, property } = cc._decorator;
@ccclass export default class CountryDialog extends cc.Component {
@property(cc.Prefab) countryCellPrefab: cc.Prefab = null;
@property(cc.Node) scrollViewContent: cc.Node = null;
initCountryList() { for (let i = 0; i < 10; i++) { let countryCell = cc.instantiate(this.countryCellPrefab);
let button = countryCell.getComponentInChildren(cc.Button);
let clickEventHandler = new cc.Component.EventHandler(); clickEventHandler.target = this.node; clickEventHandler.component = "CountryDialog"; clickEventHandler.handler = "onSend"; clickEventHandler.customEventData = i.toString();
button.clickEvents.push(clickEventHandler);
this.scrollViewContent.addChild(countryCell); } }
onSend(event: cc.Event.EventTouch, customEventData: string) { const touch = event.currentTouch;
const deltaY = Math.abs(touch.getStartLocation().y - touch.getLocation().y); const deltaX = Math.abs(touch.getStartLocation().x - touch.getLocation().x);
const THRESHOLD = 10;
if (deltaY > THRESHOLD || deltaX > THRESHOLD) { console.log('检测到滑动操作,忽略按钮点击'); return; }
console.log('按钮被点击,数据:', customEventData); this.handleCountrySelect(parseInt(customEventData)); }
private handleCountrySelect(index: number) { } }
|
关键 API 说明
| API |
说明 |
touch.getStartLocation() |
获取触摸起始点坐标(世界坐标) |
touch.getLocation() |
获取触摸当前坐标(世界坐标) |
touch.getStartLocationInView() |
获取触摸起始点(视图坐标) |
touch.getLocationInView() |
获取触摸当前点(视图坐标) |
封装通用组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92
| const { ccclass, property } = cc._decorator;
@ccclass export default class AntiMisclickButton extends cc.Component {
@property({ tooltip: '滑动判定阈值(像素)', min: 0, max: 50 }) threshold: number = 10;
@property({ tooltip: '是否同时检测 x 轴滑动' }) checkHorizontal: boolean = true;
@property({ tooltip: '是否同时检测 y 轴滑动' }) checkVertical: boolean = true;
private _button: cc.Button = null; private _originalHandlers: cc.Component.EventHandler[] = [];
onLoad() { this._button = this.getComponent(cc.Button); if (!this._button) { cc.error('AntiMisclickButton 需要配合 Button 组件使用'); return; }
this._interceptClickEvents(); }
private _interceptClickEvents() { this._originalHandlers = this._button.clickEvents.slice();
this._button.clickEvents = [];
const interceptor = new cc.Component.EventHandler(); interceptor.target = this.node; interceptor.component = "AntiMisclickButton"; interceptor.handler = "_onClickInterceptor"; this._button.clickEvents.push(interceptor); }
_onClickInterceptor(event: cc.Event.EventTouch, data: string) { const touch = event.currentTouch;
let isScrolling = false;
if (this.checkVertical) { const deltaY = Math.abs(touch.getStartLocation().y - touch.getLocation().y); if (deltaY > this.threshold) { isScrolling = true; } }
if (this.checkHorizontal && !isScrolling) { const deltaX = Math.abs(touch.getStartLocation().x - touch.getLocation().x); if (deltaX > this.threshold) { isScrolling = true; } }
if (isScrolling) { event.stopPropagation(); return; }
for (const handler of this._originalHandlers) { handler.emit([event, data]); } } }
|
解决方案二:触摸事件代理分发
原理
在 ScrollView 上层放置一个透明的代理节点,统一接收触摸事件。根据操作类型(点击或滑动)决定事件分发目标。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| ┌─────────────────────────────────────────────────────────────────────┐ │ 事件代理分发架构 │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ ScrollView │ │ ┌─────────────────────────┐ │ │ │ [代理节点 - 全屏透明] │ ← 统一接收触摸事件 │ │ │ │ │ │ │ 判断:点击 or 滑动? │ │ │ │ │ │ │ │ 点击 ──► 分发给按钮 │ │ │ │ 滑动 ──► 分发给ScrollView │ │ │ │ │ │ └─────────────────────────┘ │ │ │ │ │ ┌────┴────┐ │ │ ▼ ▼ │ │ ┌─────┐ ┌──────────┐ │ │ │Button│ │ScrollView│ │ │ └─────┘ └──────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────┘
|
代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73
| const { ccclass, property } = cc._decorator;
@ccclass export default class TouchProxy extends cc.Component {
@property(cc.ScrollView) scrollView: cc.ScrollView = null;
@property(cc.Node) buttonContainer: cc.Node = null;
private _startLocation: cc.Vec2 = null; private _threshold: number = 10;
onLoad() { this.node.on(cc.Node.EventType.TOUCH_START, this._onTouchStart, this); this.node.on(cc.Node.EventType.TOUCH_MOVE, this._onTouchMove, this); this.node.on(cc.Node.EventType.TOUCH_END, this._onTouchEnd, this); this.node.on(cc.Node.EventType.TOUCH_CANCEL, this._onTouchCancel, this); }
private _onTouchStart(event: cc.Event.EventTouch) { this._startLocation = event.getLocation();
if (this.scrollView) { this.scrollView['_onTouchBegan'](event, this.scrollView['_touchListener']); } }
private _onTouchMove(event: cc.Event.EventTouch) { if (this.scrollView) { this.scrollView['_onTouchMoved'](event, this.scrollView['_touchListener']); } }
private _onTouchEnd(event: cc.Event.EventTouch) { const endLocation = event.getLocation(); const distance = endLocation.sub(this._startLocation).mag();
if (distance < this._threshold) { this._dispatchClick(event); }
if (this.scrollView) { this.scrollView['_onTouchEnded'](event, this.scrollView['_touchListener']); } }
private _onTouchCancel(event: cc.Event.EventTouch) { if (this.scrollView) { this.scrollView['_onTouchCancelled'](event, this.scrollView['_touchListener']); } }
private _dispatchClick(event: cc.Event.EventTouch) { const buttons = this.buttonContainer.getComponentsInChildren(cc.Button);
for (const button of buttons) { const rect = button.node.getBoundingBoxToWorld(); if (rect.contains(event.getLocation())) { button['_onTouchEnded'](event); break; } } } }
|
解决方案三:利用 cc.Node.EventType
使用 TOUCH_CANCEL 事件
当 ScrollView 开始滚动时,会给触摸目标发送 TOUCH_CANCEL 事件。我们可以利用这一点来判断是否发生了滚动:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| const { ccclass } = cc._decorator;
@ccclass export default class ScrollSafeButton extends cc.Component {
private _isScrolling: boolean = false;
onLoad() { this.node.on(cc.Node.EventType.TOUCH_START, this._onTouchStart, this); this.node.on(cc.Node.EventType.TOUCH_CANCEL, this._onTouchCancel, this); this.node.on(cc.Node.EventType.TOUCH_END, this._onTouchEnd, this); }
private _onTouchStart() { this._isScrolling = false; }
private _onTouchCancel() { this._isScrolling = true; }
private _onTouchEnd() { if (this._isScrolling) { console.log('滚动中,不响应点击'); return; }
console.log('正常点击'); this._doClick(); }
private _doClick() { } }
|
方案对比
| 方案 |
优点 |
缺点 |
适用场景 |
| 滑动距离判断 |
实现简单,无侵入 |
阈值需要调试 |
大多数场景 |
| 封装通用组件 |
复用性强 |
需要修改现有按钮 |
新项目推荐 |
| 事件代理分发 |
控制精确 |
实现复杂 |
特殊交互需求 |
| TOUCH_CANCEL |
利用引擎特性 |
可能与其他逻辑冲突 |
简单列表 |
生产环境最佳实践
推荐组合方案
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81
|
@ccclass export default class ListItem extends cc.Component {
@property(cc.Label) label: cc.Label = null;
@property(cc.Button) button: cc.Button = null;
private _index: number = 0; private _clickCallback: (index: number) => void = null;
init(index: number, data: any, callback: (index: number) => void) { this._index = index; this._clickCallback = callback; this.label.string = data.name;
this.button.clickEvents = [];
this.button.node.on(cc.Node.EventType.TOUCH_START, this._onTouchStart, this); this.button.node.on(cc.Node.EventType.TOUCH_END, this._onTouchEnd, this); this.button.node.on(cc.Node.EventType.TOUCH_CANCEL, this._onTouchCancel, this); }
private _touchStartPos: cc.Vec2 = null; private _isCancelled: boolean = false; private readonly _THRESHOLD = 10;
private _onTouchStart(event: cc.Event.EventTouch) { this._touchStartPos = event.getLocation(); this._isCancelled = false;
this.button.interactable = true; }
private _onTouchCancel() { this._isCancelled = true; }
private _onTouchEnd(event: cc.Event.EventTouch) { if (this._isCancelled) { return; }
const endPos = event.getLocation(); const distance = endPos.sub(this._touchStartPos).mag();
if (distance <= this._THRESHOLD) { this._triggerClick(); } }
private _triggerClick() { cc.tween(this.node) .to(0.05, { scale: 0.95 }) .to(0.05, { scale: 1 }) .call(() => { if (this._clickCallback) { this._clickCallback(this._index); } }) .start(); }
onDestroy() { this.button.node.off(cc.Node.EventType.TOUCH_START, this._onTouchStart, this); this.button.node.off(cc.Node.EventType.TOUCH_END, this._onTouchEnd, this); this.button.node.off(cc.Node.EventType.TOUCH_CANCEL, this._onTouchCancel, this); } }
|
父节点列表管理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| @ccclass export default class CountryList extends cc.Component {
@property(cc.ScrollView) scrollView: cc.ScrollView = null;
@property(cc.Prefab) itemPrefab: cc.Prefab = null;
private _dataList: any[] = [];
setData(data: any[]) { this._dataList = data; this._refreshList(); }
private _refreshList() { const content = this.scrollView.content; content.removeAllChildren();
this._dataList.forEach((item, index) => { const node = cc.instantiate(this.itemPrefab); const listItem = node.getComponent(ListItem);
listItem.init(index, item, this._onItemClick.bind(this));
content.addChild(node); }); }
private _onItemClick(index: number) { const data = this._dataList[index]; console.log('选中国家:', data.name); } }
|
总结
Cocos Creator ScrollView 按钮防误触的核心要点:
- 问题本质:ScrollView 和 Button 共享事件系统,滑动操作被 Button 误识别为点击
- 距离判断:计算触摸起始点和结束点的距离,超过阈值则不响应点击,是最通用的方案
- 事件取消:利用
TOUCH_CANCEL 事件判断 ScrollView 是否开始滚动,适合简单场景
- 组件封装:将防误触逻辑封装为通用组件,在新项目中复用
- 视觉反馈:点击时添加缩放动画,提升用户体验
- 内存管理:在
onDestroy 中注销事件监听,避免内存泄漏
通过合理的触摸事件处理,可以让 ScrollView 列表既支持流畅滚动,又支持精确的按钮点击,显著提升游戏的交互体验。