黑科技:八行代码解决 ScrollView 嵌套事件拦截问题!

前言

各位小伙伴大家好,你在开发排行榜或复杂列表时是否遇到过这样的绝望场景:

  • 😫 排行榜主列表是垂直滚动,每个 Item 里有横向奖励列表
  • 🤔 横向滑动奖励列表正常,但纵向滑动排行榜却完全失效
  • 💢 触摸事件被莫名其妙"劫持",用户体验极差

这是游戏开发中最常见的嵌套滚动问题

今天,我将深入 Cocos Creator 源码,分享一个革命性的解决方案,用短短几行代码彻底解决这个痛点!


🎯 问题场景重现

排行榜 ScrollView (垂直滚动)

排行榜 ScrollView (垂直)
├── 排行榜 Item 1
│   └── 奖励列表 ScrollView (横向滚动)
├── 排行榜 Item 2  
│   └── 奖励列表 ScrollView (横向滚动)
└── 排行榜 Item 3
    └── 奖励列表 ScrollView (横向滚动)

现象:

  • ✅ 横向滑动奖励列表 → 正常工作
  • ❌ 在奖励列表纵向滑动排行榜 → 完全无响应

🔍 问题根源:ViewGroup 的事件拦截机制

// ScrollView 的继承结构
export class ScrollView extends ViewGroup {
    // ViewGroup 是所有可交互UI组件的基类
}

关键方法 _hasNestedViewGroup

protected _hasNestedViewGroup(event: Event, captureListeners?: Node[]): boolean {
    if (!event || event.eventPhase !== Event.CAPTURING_PHASE) {
        return false;
    }

    if (captureListeners) {
        for (const listener of captureListeners) {
            const item = listener;

            if (this.node === item) {
                if (event.target && (event.target as Node).getComponent(ViewGroup)) {
                    return true;
                }
                return false;
            }

            if (item.getComponent(ViewGroup)) {
                return true;  
            }
        }
    }
    return false;
}

事件在捕获阶段被嵌套的 ViewGroup 拦截,导致外层 ScrollView 无法响应。


📍 事件注册的关键:useCapture

protected _registerEvent(): void {
    this.node.on(NodeEventType.TOUCH_START, this._onTouchBegan, this, true);
    this.node.on(NodeEventType.TOUCH_MOVE, this._onTouchMoved, this, true);
    this.node.on(NodeEventType.TOUCH_END, this._onTouchEnded, this, true);
    this.node.on(NodeEventType.TOUCH_CANCEL, this._onTouchCancelled, this, true);
    //                                                                      ↑
    //                                                               useCapture = true
}

🔍 ScrollView 的 _onTouchMoved

protected _onTouchMoved(event: EventTouch, captureListeners?: Node[]): void {
    if (!this.enabledInHierarchy || !this._content) return;

    if (this._hasNestedViewGroup(event, captureListeners)) {
        return; // 👽 事件被拦截
    }

    const touch = event.touch!;
    this._handleMoveLogic(touch);

    if (!this.cancelInnerEvents) return;

    const deltaMove = touch.getUILocation(_tempVec2);
    deltaMove.subtract(touch.getUIStartLocation(_tempVec2_1));

    if (deltaMove.length() > 7) {
        if (!this._touchMoved && event.target !== this.node) {
            const cancelEvent = new EventTouch(event.getTouches(), event.bubbles, SystemEventType.TOUCH_CANCEL);
            cancelEvent.touch = event.touch;
            cancelEvent.simulate = true;
            (event.target as Node).dispatchEvent(cancelEvent);
            this._touchMoved = true;
        }
    }

    this._stopPropagationIfTargetIsMe(event);
}

💡 解决方案:事件转发机制

import { _decorator, Component, EventTouch, NodeEventType } from 'cc';

const { ccclass } = _decorator;

interface CustomEventTouch extends EventTouch {
    mock?: boolean;
}

@ccclass('NestTouchCmp')
export class NestTouchCmp extends Component {
    protected onLoad(): void {
        this.node.on(NodeEventType.TOUCH_START, this.OnNestTouchEvent, this, true);
        this.node.on(NodeEventType.TOUCH_MOVE, this.OnNestTouchEvent, this, true);
        this.node.on(NodeEventType.TOUCH_END, this.OnNestTouchEvent, this, true);
        this.node.on(NodeEventType.TOUCH_CANCEL, this.OnNestTouchEvent, this, true);
    }

    private OnNestTouchEvent(event: CustomEventTouch): void {
        if (event.mock || event.simulate || event.target === this.node) return;

        const copyEvent: CustomEventTouch = new EventTouch(event.getTouches(), event.bubbles, event.getType());
        copyEvent.type = event.type;
        copyEvent.touch = event.touch;
        copyEvent.mock = true;

        this.scheduleOnce(() => {
            this.node.dispatchEvent(copyEvent);
        });
    }
}

🧠 原理总结

  1. 捕获阶段提前监听事件(useCapture: true)
  2. 过滤模拟事件和自身事件,防止死循环
  3. 克隆原事件并打上 mock 标记
  4. 异步在下一帧重新派发事件

🚀 实战应用示意图

用户纵向滑动奖励列表区域
        ↓
NestTouchCmp 捕获阶段拦截事件
        ↓
克隆事件 → 标记为 mock
        ↓
下一帧异步重新派发事件
        ↓
外层排行榜 ScrollView 成功响应

🎯 适用场景

  • ✅ 排行榜 + 奖励列表
  • ✅ 商城 + 商品横向滑动
  • ✅ 新闻 + 图片轮播
  • ✅ 聊天 + 表情包横滑面板

🎉 最佳实践

✅ 建议

  • useCapture: true
  • 使用 mock 标记防止重复处理
  • 异步分发确保安全
  • 精准挂载需要处理的 ScrollView

❌ 避免

  • 所有 ScrollView 一律使用(只在嵌套场景使用)
  • 忽略事件类型检查

🎁 福利推荐:高性能虚拟列表插件

📌 插件地址:
👉 https://store.cocos.com/app/detail/7408
🎮 体验链接:
👉 https://xingkong.asia/virtualList160/

✨ 插件特色

功能 原生 ScrollView 虚拟列表插件
支持数据量 < 100 > 10000
内存占用 线性增长 恒定不变
滚动流畅度 卡顿明显 丝般顺滑
嵌套支持 需要 hack 原生支持

❤️ 总结

  • 理解 ScrollView 源码,精准定位嵌套滚动冲突根因
  • 提出克隆+转发事件的优雅解决方式
  • 实战中稳定、高效、易用!

📢 如果你觉得这个方案有帮助:

  • 👍 点赞 让更多开发者看到这个方案
  • 🔄 转发 给遇到类似问题的朋友
  • 💬 评论 分享你的嵌套滚动坑爹经历

关注我,获取更多 Cocos Creator 深度技术干货! 🚀

posted @ 2025-06-17 10:22  Mike丶  阅读(103)  评论(2)    收藏  举报