Cocos Creator ScrollView 滚动按钮防误触完全指南:事件穿透与触摸判断实战

引言

在 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 的事件系统遵循以下规则:

  1. 最上层节点优先:触摸事件首先被最上层的可交互节点接收
  2. 节点树内传递:事件响应后,只在当前节点的节点树中传递,不会传递到其他节点树
  3. 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;

// 计算滑动距离(y 方向)
const deltaY = Math.abs(touch.getStartLocation().y - touch.getLocation().y);
const deltaX = Math.abs(touch.getStartLocation().x - touch.getLocation().x);

// 滑动阈值:超过 10 像素视为滑动,不响应点击
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;

/**
* 防误触按钮组件
* 用于 ScrollView 中的按钮,防止滚动时误触发
*/
@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();

// 同时将事件传递给 ScrollView
if (this.scrollView) {
this.scrollView['_onTouchBegan'](event, this.scrollView['_touchListener']);
}
}

private _onTouchMove(event: cc.Event.EventTouch) {
// 直接传递给 ScrollView 处理滚动
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);
}

// 传递结束事件给 ScrollView
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() {
// ScrollView 开始滚动时会触发 CANCEL
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
/**
* 完整的 ScrollView 列表项组件
*/
@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 按钮防误触的核心要点:

  1. 问题本质:ScrollView 和 Button 共享事件系统,滑动操作被 Button 误识别为点击
  2. 距离判断:计算触摸起始点和结束点的距离,超过阈值则不响应点击,是最通用的方案
  3. 事件取消:利用 TOUCH_CANCEL 事件判断 ScrollView 是否开始滚动,适合简单场景
  4. 组件封装:将防误触逻辑封装为通用组件,在新项目中复用
  5. 视觉反馈:点击时添加缩放动画,提升用户体验
  6. 内存管理:在 onDestroy 中注销事件监听,避免内存泄漏

通过合理的触摸事件处理,可以让 ScrollView 列表既支持流畅滚动,又支持精确的按钮点击,显著提升游戏的交互体验。