Cocos Creator ScrollView 滚动时按钮误触问题解决方案

问题描述

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
┌─────────────────────────────────────────────────────────────┐
│ ScrollView 场景 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ 📱 列表项 1 [购买按钮] │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ 📱 列表项 2 [购买按钮] │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ 📱 列表项 3 [购买按钮] ← 用户滚动时误触 │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ 📱 列表项 4 [购买按钮] │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ 📱 列表项 5 [购买按钮] │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ │ │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ 问题:用户在快速滑动时,手指按在了按钮上, │
│ 导致触发按钮点击而不是继续滚动 │
│ │
└─────────────────────────────────────────────────────────────┘

用户操作分析

操作类型 用户意图 触发区域 期望行为
快速滑动 浏览更多内容 整个列表项 滚动列表
缓慢拖动 浏览更多内容 整个列表项 滚动列表
点击按钮 触发按钮功能 按钮区域 触发按钮事件
点击列表项 查看详情 非按钮区域 打开详情页

解决方案

方案一:触摸移动距离判断(推荐)

最简单的解决方案是判断用户触摸的移动距离,如果移动距离超过一定阈值,则认为是滚动操作,不响应按钮点击。

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
// ScrollViewButtonHandler.js
const { ccclass, property } = cc._decorator;

@ccclass
export default class ScrollViewButtonHandler extends cc.Component {

// 滑动阈值(像素)
@property(cc.Integer)
swipeThreshold: number = 10;

onLoad() {
// 动态创建按钮
this.createButtons();
}

createButtons() {
const prefab = cc.resources.get('prefabs/CountryCell', cc.Prefab);

for (let i = 0; i < 10; i++) {
const countryCell = cc.instantiate(prefab);
const button = countryCell.getComponentInChildren(cc.Button);

if (button) {
// 创建点击事件处理器
const clickEventHandler = new cc.Component.EventHandler();
clickEventHandler.target = this.node;
clickEventHandler.component = 'ScrollViewButtonHandler';
clickEventHandler.handler = 'onButtonClick';
clickEventHandler.customEventData = i.toString();

button.clickEvents.push(clickEventHandler);
}

// 添加到 ScrollView 的内容节点
this.node.addChild(countryCell);
}
}

/**
* 按钮点击处理
* @param event 触摸事件
* @param customEventData 自定义数据
*/
onButtonClick(event: cc.Event.EventTouch, customEventData: string) {
// 关键:判断滑动距离
const touch = event.currentTouch;
const startPoint = touch._startPoint; // 触摸开始位置
const currentPoint = touch._point; // 当前位置

// 计算 Y 方向滑动距离(垂直 ScrollView)
const deltaY = Math.abs(startPoint.y - currentPoint.y);

// 如果滑动距离超过阈值,视为滚动操作,不响应点击
if (deltaY > this.swipeThreshold) {
cc.log('Swiping detected, ignoring button click');
return;
}

// 水平 ScrollView 使用 deltaX
// const deltaX = Math.abs(startPoint.x - currentPoint.x);
// if (deltaX > this.swipeThreshold) {
// return;
// }

// 响应按钮点击
cc.log('Button clicked:', customEventData);
this.handleButtonClick(parseInt(customEventData));
}

handleButtonClick(index: number) {
// 实际的按钮点击处理逻辑
cc.log('Processing click for item:', index);
}
}

方案二:触摸事件拦截(更通用)

通过监听 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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
// ScrollViewButtonManager.js
const { ccclass, property } = cc._decorator;

@ccclass
export default class ScrollViewButtonManager extends cc.Component {

@property(cc.ScrollView)
scrollView: cc.ScrollView = null;

@property(cc.Integer)
disableThreshold: number = 5; // 超过此距离禁用按钮

private buttons: cc.Button[] = [];
private isScrolling: boolean = false;

onLoad() {
if (!this.scrollView) {
this.scrollView = this.getComponent(cc.ScrollView);
}

this.collectButtons();
this.setupScrollListener();
}

/**
* 收集所有按钮
*/
collectButtons() {
this.buttons = this.node.getComponentsInChildren(cc.Button);
}

/**
* 设置 ScrollView 监听
*/
setupScrollListener() {
if (!this.scrollView) return;

let startPos: cc.Vec2 = null;

// 触摸开始
this.scrollView.node.on(cc.Node.EventType.TOUCH_START, (event: cc.Event.EventTouch) => {
startPos = event.getLocation();
this.isScrolling = false;
}, this);

// 触摸移动
this.scrollView.node.on(cc.Node.EventType.TOUCH_MOVE, (event: cc.Event.EventTouch) => {
if (!startPos) return;

const currentPos = event.getLocation();
const distance = currentPos.sub(startPos).mag();

// 如果移动距离超过阈值,标记为滚动状态
if (distance > this.disableThreshold && !this.isScrolling) {
this.isScrolling = true;
this.disableAllButtons();
}
}, this);

// 触摸结束
this.scrollView.node.on(cc.Node.EventType.TOUCH_END, (event: cc.Event.EventTouch) => {
this.isScrolling = false;
this.enableAllButtons();
startPos = null;
}, this);

// 触摸取消
this.scrollView.node.on(cc.Node.EventType.TOUCH_CANCEL, (event: cc.Event.EventTouch) => {
this.isScrolling = false;
this.enableAllButtons();
startPos = null;
}, this);
}

/**
* 禁用所有按钮
*/
disableAllButtons() {
for (const button of this.buttons) {
button.interactable = false;
}
cc.log('Buttons disabled (scrolling)');
}

/**
* 启用所有按钮
*/
enableAllButtons() {
for (const button of this.buttons) {
button.interactable = true;
}
cc.log('Buttons enabled');
}
}

方案三:自定义按钮组件(更灵活)

创建一个继承自 cc.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
63
64
65
// ScrollSafeButton.ts
const { ccclass, property, menu } = cc._decorator;

@ccclass
@menu('CustomUI/ScrollSafeButton')
export default class ScrollSafeButton extends cc.Button {

@property(cc.Integer)
@tooltip('滚动检测阈值(像素),超过此值不触发点击')
scrollThreshold: number = 10;

@property(cc.Boolean)
@tooltip('是否启用滚动保护')
enableScrollProtection: boolean = true;

private _startTouchPos: cc.Vec2 = null;

onLoad() {
super.onLoad();

// 添加触摸开始监听
this.node.on(cc.Node.EventType.TOUCH_START, this._onTouchStart, this);
}

_onTouchStart(event: cc.Event.EventTouch) {
this._startTouchPos = event.getLocation();
}

/**
* 重写 _onTouchEnded,添加滚动检测
*/
_onTouchEnded(event: cc.Event.EventTouch) {
if (!this.enableScrollProtection || !this._startTouchPos) {
super._onTouchEnded(event);
return;
}

const endPos = event.getLocation();
const distance = endPos.sub(this._startTouchPos).mag();

// 如果移动距离超过阈值,不触发点击
if (distance > this.scrollThreshold) {
// 重置状态,不调用父类方法
this._resetState();
return;
}

// 正常触发点击
super._onTouchEnded(event);
}

_resetState() {
// 重置按钮状态
if (this._pressed) {
this._pressed = false;
this._updateState();
}
this._startTouchPos = null;
}

onDestroy() {
this.node.off(cc.Node.EventType.TOUCH_START, this._onTouchStart, this);
super.onDestroy();
}
}

方案四:使用 ScrollView 的滚动事件(推荐用于复杂场景)

利用 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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
// SmartScrollView.ts
const { ccclass, property } = cc._decorator;

@ccclass
export default class SmartScrollView extends cc.Component {

@property(cc.ScrollView)
scrollView: cc.ScrollView = null;

@property(cc.Integer)
@tooltip('滑动判定阈值(像素)')
swipeThreshold: number = 10;

@property(cc.Float)
@tooltip('滚动结束后的保护时间(秒)')
protectionTime: number = 0.1;

// 静态标志,全局可用
static isScrolling: boolean = false;
static scrollEndTime: number = 0;

private _lastPos: cc.Vec2 = null;
private _isTouching: boolean = false;

onLoad() {
if (!this.scrollView) {
this.scrollView = this.getComponent(cc.ScrollView);
}

this.setupEventListeners();
}

setupEventListeners() {
if (!this.scrollView) return;

const content = this.scrollView.content;

// 触摸开始
content.on(cc.Node.EventType.TOUCH_START, () => {
this._isTouching = true;
this._lastPos = null;
SmartScrollView.isScrolling = false;
}, this);

// 滚动中
this.scrollView.node.on('scrolling', () => {
const currentPos = this.scrollView.getScrollOffset();

if (this._lastPos) {
const delta = currentPos.sub(this._lastPos);
const distance = delta.mag();

if (distance > this.swipeThreshold) {
SmartScrollView.isScrolling = true;
}
}

this._lastPos = currentPos;
}, this);

// 触摸结束
content.on(cc.Node.EventType.TOUCH_END, () => {
this._isTouching = false;
this.scheduleOnce(() => {
if (!this._isTouching) {
SmartScrollView.isScrolling = false;
SmartScrollView.scrollEndTime = Date.now();
}
}, this.protectionTime);
}, this);

// 触摸取消
content.on(cc.Node.EventType.TOUCH_CANCEL, () => {
this._isTouching = false;
SmartScrollView.isScrolling = false;
}, this);
}

/**
* 检查是否正在滚动
*/
static isCurrentlyScrolling(): boolean {
return SmartScrollView.isScrolling;
}

/**
* 检查是否在保护时间内
*/
static isInProtectionTime(): boolean {
const elapsed = Date.now() - SmartScrollView.scrollEndTime;
return elapsed < 100; // 100ms 保护时间
}
}

// 按钮使用示例
// SafeButton.ts
const { ccclass } = cc._decorator;

@ccclass
export default class SafeButton extends cc.Component {

onButtonClick(event: cc.Event.EventTouch) {
// 检查是否正在滚动
if (SmartScrollView.isScrolling || SmartScrollView.isInProtectionTime()) {
cc.log('Button click ignored: scrolling in progress');
return;
}

// 处理点击
cc.log('Button clicked successfully');
}
}

方案对比

方案 优点 缺点 适用场景
方案一:移动距离判断 简单直接,无需额外组件 每个按钮都需要单独处理 按钮数量少,逻辑简单
方案二:事件拦截 统一管理,自动处理 可能影响其他触摸事件 复杂界面,多层级 ScrollView
方案三:自定义按钮 复用性强,配置灵活 需要替换现有按钮组件 需要精细控制的场景
方案四:全局标志 全局状态,使用方便 需要静态变量 多个 ScrollView,复杂交互

小结

解决 ScrollView 滚动时按钮误触的核心思路:

  1. 识别意图:通过触摸移动距离区分滚动和点击操作
  2. 设置阈值:定义合理的滑动距离阈值
  3. 状态管理:在滚动过程中控制按钮响应
  4. 选择方案:根据项目复杂度选择合适的实现方案

这几个方案我都用过,简单项目用方案一,复杂项目用方案四。