一、问题背景
昨天把一直没有时间搞的负一屏功能进行了实现,把桌面和负一屏的滑动交互流程彻底打通了,具体的效果如下:

上面方案已经完全实现了:
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)
}
}
失败原因:
requestDisallowInterceptTouchEvent返回false的时机不对 — 子 View 在ACTION_DOWN时就已设置 disallow,导致初次onInterceptTouchEvent被跳过。- 垂直滑动时仍会错误触发拦截(axis 判断不够严谨)。
- 拦截后的
ACTION_CANCEL发给子 View 后,子 View 内部状态可能异常。
尝试 3:dispatchTouchEvent 覆写(✅ 成功)
核心思路:在 dispatchTouchEvent 中于 children 之前检查每个 MOVE 事件,判断为水平左滑时直接拦截返回 true。
dispatchTouchEvent(event)
├─ 判断条件 → 水平左滑?
│ ├─ 是 → 拦截,交给 SwipeCallback
│ └─ 否 → super.dispatchTouchEvent(event) → 透传给子 View
为什么能绕开 requestDisallowInterceptTouchEvent:dispatchTouchEvent 是事件进入 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 <= touchSlop | slop 区域内全部透传,避免抖动 |
5.2 合成 CANCEL 的必要性
横滑被拦截时,子 View(如 CheckBox)可能正处在 pressed 状态。直接拦截 MOVE 事件而不发 CANCEL,会导致:
- CheckBox 卡在 pressed 高亮态
- RecyclerView 的滚动惯性未终止
- 触摸状态泄漏
sendSyntheticCancel() 向子 View 注入 ACTION_CANCEL,优雅清理所有子 View 的触摸状态。
5.3 injectingCancel guard
合成 CANCEL 通过 super.dispatchTouchEvent(cancel) 发出,会再次进入自身的 dispatchTouchEvent。injectingCancel 标志位防止递归处理。
5.4 touchSlop 适配
private val touchSlop by lazy {
ViewConfiguration.get(context).scaledTouchSlop
}
使用系统 scaledTouchSlop(典型值 ~28px),而非硬编码。适配不同密度屏幕。
5.5 Overlay 触摸开关时机
| 状态 | FLAG_NOT_TOUCHABLE | isSwipeEnabled |
|---|---|---|
| Launcher 滚动驱动 overlay 显示/隐藏 | 设置(透传触摸给 Launcher) | false |
| overlay 完全可见,用户可交互 | 清除(overlay 接收触摸) | true |
原文源码干货获取地址:
https://mp.weixin.qq.com/s/sdME3JyfFePQp86pfvBpCw


2万+

被折叠的 条评论
为什么被折叠?



