问题描述
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
| 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); }
this.node.addChild(countryCell); } }
onButtonClick(event: cc.Event.EventTouch, customEventData: string) { const touch = event.currentTouch; const startPoint = touch._startPoint; const currentPoint = touch._point;
const deltaY = Math.abs(startPoint.y - currentPoint.y);
if (deltaY > this.swipeThreshold) { cc.log('Swiping detected, ignoring button click'); 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
| 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); }
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
| 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(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 的滚动事件,在检测到滚动时设置全局标志。
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
| 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; } }
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 滚动时按钮误触的核心思路:
- 识别意图:通过触摸移动距离区分滚动和点击操作
- 设置阈值:定义合理的滑动距离阈值
- 状态管理:在滚动过程中控制按钮响应
- 选择方案:根据项目复杂度选择合适的实现方案
这几个方案我都用过,简单项目用方案一,复杂项目用方案四。