手机大厂桌面负一屏 Overlay 触摸事件冲突修复笔记

一、问题背景

昨天把一直没有时间搞的负一屏功能进行了实现,把桌面和负一屏的滑动交互流程彻底打通了,具体的效果如下:
在这里插入图片描述
上面方案已经完全实现了:

1、负一屏独立的app service,独立进程窗口绘制

2、桌面和负一屏都可以和相互滑动交互。

整体设计框架图
在这里插入图片描述

但是负一屏的内容部分还没有做的丰富,只是一个简单的TextView展示,而且滑动事件也没有任何事件冲突等问题。

今天把负一屏内容丰富成如下页面情况:

在这里插入图片描述

内容这些布局都比较简单,但是发现控件多了后,遇到滑动事件冲突的相关严重问题,导致页面丰富后负一屏和桌面的滑动基本上无法按照预想的方式进行滑动。

负一屏 overlay 通过 ILauncherOverlay AIDL 与 Launcher3 集成。overlay 完全显示后,用户可以在 overlay 上左滑关闭。但 overlay 内容区包含多个交互控件:

控件触摸行为
CheckBox (待办卡片)点击切换选中态,按下时有 pressed 状态
RecyclerView (快捷功能/日程/新闻)水平/垂直滑动
ImageView (功能图标)可点击

现象:手指在这些控件上左滑时,触摸事件被控件消费,overlay 无法收到滑动事件,导致左滑关闭手势失效

修复后成果成果展示如下:

在这里插入图片描述
可以看出触摸相关智能卡片的控件时候,只要是整体上有滑动情况下负一屏的Overlay窗口还是会整体进行滑动,而不会因为事件被控件吃掉后,导致整体卡片无法滑动情况。

其实这种事件冲突问题在以前app的复杂画面开发时候还是比较常见的。

下面是帮我修复事件冲突的全程笔记:

二、Android 触摸事件分发机制回顾

Activity.dispatchTouchEvent()
  └─ ViewGroup.dispatchTouchEvent()
       ├─ onInterceptTouchEvent()   ← 父容器判断是否拦截
       └─ child.dispatchTouchEvent() ← 子 View 处理

关键点:

  • onTouchListener:在子 View 的 dispatchTouchEvent 之后才执行。如果子 View 在 onTouchEvent 中返回了 true,父 ViewGroup 的 onTouchListener 根本看不到事件。
  • onInterceptTouchEvent:子 View 可通过 requestDisallowInterceptTouchEvent(true) 阻止父容器拦截(RecyclerView/ScrollView 在滚动时会自动调用此方法)。

三、方案演进:三次尝试

尝试 1:setOnTouchListener(❌ 失败)

overlayView.setOnTouchListener { v, event ->
    // 处理滑动逻辑...
    true
}

失败原因:Android 事件分发是"子优先"。CheckBox/RecyclerView 等子控件在其 onTouchEvent 中返回 true 后,ACTION_DOWN 就已经被子控件消费,后续 ACTION_MOVE 直接发给子控件,父 View 的 OnTouchListener 永远收不到事件

尝试 2:onInterceptTouchEvent(❌ 失败)

class SwipeInterceptLayout : FrameLayout {
    override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
        // 判断水平滑动则拦截
    }

    override fun requestDisallowInterceptTouchEvent(disallow: Boolean) {
        // 永远不允许子 View 禁止拦截
        super.requestDisallowInterceptTouchEvent(false)
    }
}

失败原因

  1. requestDisallowInterceptTouchEvent 返回 false 的时机不对 — 子 View 在 ACTION_DOWN 时就已设置 disallow,导致初次 onInterceptTouchEvent 被跳过。
  2. 垂直滑动时仍会错误触发拦截(axis 判断不够严谨)。
  3. 拦截后的 ACTION_CANCEL 发给子 View 后,子 View 内部状态可能异常。

尝试 3:dispatchTouchEvent 覆写(✅ 成功)

核心思路:dispatchTouchEvent 中于 children 之前检查每个 MOVE 事件,判断为水平左滑时直接拦截返回 true

dispatchTouchEvent(event)
  ├─ 判断条件 → 水平左滑?
  │   ├─ 是 → 拦截,交给 SwipeCallback
  │   └─ 否 → super.dispatchTouchEvent(event) → 透传给子 View

为什么能绕开 requestDisallowInterceptTouchEventdispatchTouchEvent 是事件进入 ViewGroup 的第一道门,比 onInterceptTouchEvent 更早执行,子 View 的 disallow 标记无法影响它。

四、核心代码

4.1 SwipeInterceptLayout.kt(新增)

class SwipeInterceptLayout @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0,
) : FrameLayout(context, attrs, defStyleAttr) {

    var isSwipeEnabled: Boolean = false
    var swipeCallback: SwipeCallback? = null

    private var startRawX = 0f
    private var startRawY = 0f
    private var isTrackingSwipe = false
    private val touchSlop by lazy {
        ViewConfiguration.get(context).scaledTouchSlop
    }
    private var injectingCancel = false  // 防递归 guard

    interface SwipeCallback {
        fun onSwipeStart()
        fun onSwipeMove(dx: Float)
        fun onSwipeEnd(dx: Float)
    }

    override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
        // 未启用 / 无回调 / 正在注入 CANCEL → 直接透传
        if (!isSwipeEnabled || swipeCallback == null || injectingCancel) {
            return super.dispatchTouchEvent(ev)
        }

        when (ev.actionMasked) {
            MotionEvent.ACTION_DOWN -> {
                startRawX = ev.rawX
                startRawY = ev.rawY
                isTrackingSwipe = false
                return super.dispatchTouchEvent(ev)  // 始终透传 DOWN
            }

            MotionEvent.ACTION_MOVE -> {
                if (isTrackingSwipe) {
                    swipeCallback?.onSwipeMove(ev.rawX - startRawX)
                    return true  // 已开始追踪,持续拦截
                }

                val dx = ev.rawX - startRawX
                val dy = ev.rawY - startRawY
                val absDx = Math.abs(dx)
                val absDy = Math.abs(dy)

                // touchSlop 范围内,透传
                if (absDx <= touchSlop && absDy <= touchSlop) {
                    return super.dispatchTouchEvent(ev)
                }

                // 轴判断:水平位移 > 垂直位移 × 1.5,且方向向左
                if (absDx > absDy * 1.5f && dx < -touchSlop) {
                    isTrackingSwipe = true
                    sendSyntheticCancel(ev)     // 清理子 View 触摸状态
                    swipeCallback?.onSwipeStart()
                    swipeCallback?.onSwipeMove(dx)
                    return true
                }

                // 垂直滑动 / 右滑 / 不足够水平 → 透传
                return super.dispatchTouchEvent(ev)
            }

            MotionEvent.ACTION_UP -> {
                if (isTrackingSwipe) {
                    swipeCallback?.onSwipeEnd(ev.rawX - startRawX)
                    isTrackingSwipe = false
                    return true
                }
                isTrackingSwipe = false
                return super.dispatchTouchEvent(ev)
            }

            MotionEvent.ACTION_CANCEL -> {
                if (isTrackingSwipe) {
                    swipeCallback?.onSwipeEnd(ev.rawX - startRawX)
                }
                isTrackingSwipe = false
                return super.dispatchTouchEvent(ev)
            }
        }
        return super.dispatchTouchEvent(ev)
    }

    /**
     * 注入合成 CANCEL 事件清理子 View 触摸状态。
     * injectingCancel guard 防止递归进入 dispatchTouchEvent。
     */
    private fun sendSyntheticCancel(ev: MotionEvent) {
        injectingCancel = true
        try {
            val cancel = MotionEvent.obtain(ev)
            cancel.action = MotionEvent.ACTION_CANCEL
            super.dispatchTouchEvent(cancel)
            cancel.recycle()
        } finally {
            injectingCancel = false
        }
    }
}

4.2 overlay_content.xml 改动

改动前:root 是 <NestedScrollView>

改动后:root 替换为 <SwipeInterceptLayout> 包裹 NestedScrollView

<com.example.helloscreen.SwipeInterceptLayout
    android:id="@+id/swipe_intercept_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/gray_50">

    <androidx.core.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <!-- 原有内容不变 -->
    </androidx.core.widget.NestedScrollView>

</com.example.helloscreen.SwipeInterceptLayout>

4.3 OverlayController.kt 改动

改动前setOnTouchListener 方式

private fun buildOverlayView(): View = FrameLayout(context).apply {
    setBackgroundColor(Color.BLACK)
    addView(TextView(context).apply { ... })
}

改动后:SwipeInterceptLayout + SwipeCallback 方式

private fun buildOverlayView(): View {
    val view = inflater.inflate(R.layout.overlay_content, null)
    // ...

    val swipeLayout = view as SwipeInterceptLayout
    swipeLayout.swipeCallback = object : SwipeInterceptLayout.SwipeCallback {
        override fun onSwipeStart() {
            snapAnimator?.cancel()
            swipeTranslationStart = view.translationX
        }

        override fun onSwipeMove(dx: Float) {
            if (dx < 0f) {
                view.translationX = swipeTranslationStart + dx
                val progress = (-view.translationX / view.width).coerceIn(0f, 1f)
                callback?.overlayScrollChanged(progress)
            }
        }

        override fun onSwipeEnd(dx: Float) {
            val viewWidth = view.width.toFloat()
            val currentProgress = (-view.translationX / viewWidth).coerceIn(0f, 1f)
            val dismiss = dx < -viewWidth * 0.3f   // 超过 30% 宽度触发关闭
            val target = if (dismiss) 1f else 0f

            snapAnimator = ValueAnimator.ofFloat(currentProgress, target).apply {
                duration = 150
                addUpdateListener { view.translationX = -viewWidth * it.animatedValue }
                addListener(object : AnimatorListenerAdapter() {
                    override fun onAnimationEnd(a: Animator) {
                        if (dismiss) setOverlayTouchable(false)
                    }
                })
                start()
            }
        }
    }
    return view
}

五、关键设计决策

5.1 轴判断公式

absDx > absDy × 1.5  且  dx < -touchSlop
条件作用
absDx > absDy * 1.5确保水平分量至少是垂直分量的 1.5 倍,防止垂直滑动误判
dx < -touchSlop仅拦截左滑(关闭方向),右滑留给 Launcher 处理
absDx <= touchSlop && absDy <= touchSlopslop 区域内全部透传,避免抖动

5.2 合成 CANCEL 的必要性

横滑被拦截时,子 View(如 CheckBox)可能正处在 pressed 状态。直接拦截 MOVE 事件而不发 CANCEL,会导致:

  • CheckBox 卡在 pressed 高亮态
  • RecyclerView 的滚动惯性未终止
  • 触摸状态泄漏

sendSyntheticCancel() 向子 View 注入 ACTION_CANCEL,优雅清理所有子 View 的触摸状态。

5.3 injectingCancel guard

合成 CANCEL 通过 super.dispatchTouchEvent(cancel) 发出,会再次进入自身的 dispatchTouchEventinjectingCancel 标志位防止递归处理。

5.4 touchSlop 适配

private val touchSlop by lazy {
    ViewConfiguration.get(context).scaledTouchSlop
}

使用系统 scaledTouchSlop(典型值 ~28px),而非硬编码。适配不同密度屏幕。

5.5 Overlay 触摸开关时机

状态FLAG_NOT_TOUCHABLEisSwipeEnabled
Launcher 滚动驱动 overlay 显示/隐藏设置(透传触摸给 Launcher)false
overlay 完全可见,用户可交互清除(overlay 接收触摸)true

原文源码干货获取地址:
https://mp.weixin.qq.com/s/sdME3JyfFePQp86pfvBpCw

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

千里马学框架

帮助你了,就请我喝杯咖啡

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值