如果你在 Android 开发中使用过BottomSheet+GridLayout做底部菜单,那么你大概率会遇到这个让无数开发者崩溃的经典 BUG:
- 点击一个按钮,所有按钮一起触发
- 滑动菜单时,所有按钮同时变成蓝色高亮
- 点击菜单空白处,事件穿透到下面的 RecyclerView
我最近在开发一个语音调度系统时,就被这个问题折磨了整整两天。从加各种 XML 属性,到写代码强制清除状态,最后翻到 Android 源码才发现,这根本不是我的代码问题,而是 Android 系统本身一个存在了十几年、极其反直觉的设计缺陷。
这篇文章会从现象到源码,彻底拆解这个问题的本质,并给出唯一真正一劳永逸的解决方案。
一、问题现象
先看一下这个经典的 BUG 现象:当你滑动底部弹出的 GridLayout 菜单时,所有按钮会同时变成按下状态的蓝色,就像被集体选中了一样。手指松开后,部分按钮还会残留高亮状态,非常影响用户体验。
更诡异的是:
- 即使你把所有按钮的
clickable和focusable都设置为false,它们还是会高亮 - 即使你加了
splitMotionEvents="false",滑动时还是会全亮 - 即使你加了
duplicateParentState="false",快速滑动时仍然会有残留
二、问题的三层根源
这个问题之所以这么难解决,是因为它是三层问题叠加的结果,每一层都藏得很深。
第一层:事件穿透问题
现象:点击 / 滑动菜单时,下面的 RecyclerView 会跟着滑动,或者下面的列表项会被点击。
根源:BottomSheet的根布局默认clickable="false",这意味着它不会消费任何触摸事件。根据 Android 触摸事件分发机制,事件会一直向下传递,直到被某个 View 消费。
所以你点击菜单,实际上是点击了下面的 RecyclerView。
解决方案:给 BottomSheet 的根布局加上:
xml
android:clickable="true" android:focusable="true"这两行会让 BottomSheet 自己吃掉所有触摸事件,阻止事件向下传递。
第二层:多点触发问题
现象:点击一个按钮,多个按钮同时触发点击事件。
根源:GridLayout默认开启了splitMotionEvents="true",这个属性的意思是:允许将一次触摸事件拆分给多个子 View。
当你的手指按下时,如果触摸区域覆盖了多个按钮,GridLayout 会把这次触摸事件复制多份,发给所有被覆盖的按钮,导致多个按钮同时触发。
解决方案:给 GridLayout 加上:
xml
android:splitMotionEvents="false"这会告诉 GridLayout:一次触摸事件,只发给第一个被接触到的子 View。
第三层:滑动全高亮问题(最坑的一层)
现象:滑动菜单时,所有按钮同时变成蓝色高亮。
这是最隐蔽、最反直觉的一层,也是无数开发者踩坑的地方。我翻了 Android 源码才找到真相。
源码真相
所有 ViewGroup(包括 GridLayout、LinearLayout 等)都有一个dispatchSetPressed方法,这个方法的作用是:当父容器被按下时,把按下状态传递给子 View。
我们来看 Android 官方源码:
java
运行
@Override protected void dispatchSetPressed(boolean pressed) { final View[] children = mChildren; final int count = mChildrenCount; for (int i = 0; i < count; i++) { final View child = children[i]; // 重点!重点!重点! if (!pressed || (!child.isClickable() && !child.isLongClickable())) { child.setPressed(pressed); } } }翻译成人话
当我(父容器)被按下时,我会把我的 "按下状态" 强制传给所有子 View传给谁?✅所有 clickable=false 的子 View❌ 所有 clickable=true 的子 View
这意味着什么?
- 如果你把按钮设置为
clickable=false,那么正好满足了父容器传递 pressed 状态的条件 - 只要你的手指碰到 GridLayout,GridLayout 就会进入 pressed 状态
- 然后 GridLayout 会强制把这个状态传给所有
clickable=false的按钮 - 所以你看到的结果就是:一滑动,所有按钮全亮了
这就是为什么你加了所有能加的 XML 属性,还是解决不了这个问题的根本原因。
三、解决方案演进
1. 中间补丁方案(治标不治本)
网上大部分文章给出的解决方案都是这些:
- 加
android:duplicateParentState="false":告诉子 View 不要继承父容器的状态 - 代码强制清除 pressed 状态:在滑动时遍历所有子 View,调用
setPressed(false)
这些方案确实能部分解决问题,但都有缺陷:
duplicateParentState="false"在快速滑动时仍然会有残留高亮- 代码强制清除状态会影响性能,而且可能会影响正常的点击效果
2. 终极根治方案(治本)
既然问题的根源是dispatchSetPressed方法,那么最彻底的解决方案就是:重写 GridLayout,让它不要传递 pressed 状态。
四、终极解决方案
第一步:创建自定义 GridLayout
kotlin
package com.your.package.widget import android.content.Context import android.util.AttributeSet import android.widget.GridLayout /** * 解决 GridLayout 滑动时所有子 View 集体高亮的 BUG * 原理:重写 dispatchSetPressed 方法,不向子 View 传递父容器的 pressed 状态 */ class NoPressedGridLayout : GridLayout { constructor(context: Context?) : super(context) constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super( context, attrs, defStyleAttr ) // 核心:什么都不做,不把父容器的 pressed 状态传给任何子 View override fun dispatchSetPressed(pressed: Boolean) {} }第二步:替换布局中的 GridLayout
把你 XML 中所有的<GridLayout>标签,替换成自定义的NoPressedGridLayout:
xml
<com.your.package.widget.NoPressedGridLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:columnCount="4" android:splitMotionEvents="false" android:descendantFocusability="blocksDescendants"> <!-- 你的按钮 --> </com.your.package.widget.NoPressedGridLayout>第三步:清理所有多余的补丁
现在你可以删掉所有之前加的补丁了:
- 删掉所有按钮上的:
xml
android:duplicateParentState="false" android:clickable="false" android:focusable="false" - 删掉所有代码里的:
- 清除 pressed 状态的触摸监听器
- 遍历子 View 清除状态的辅助方法
五、最终效果
✅ 滑动菜单 → 没有任何按钮高亮✅ 点击按钮 → 只有被点击的那个高亮✅ 快速滑动 → 无任何残留高亮✅ 事件 100% 不穿透到下面的列表✅ 按钮点击功能完全正常✅ 不需要任何额外补丁和兜底代码
六、总结
这个问题困扰了 Android 开发者十几年,本质上是 Android View 系统一个非常糟糕的设计决策。Google 设计dispatchSetPressed方法的初衷,可能是为了实现 "点击父容器,子 View 一起高亮" 的效果,但在 GridLayout 这种多按钮布局的场景下,就变成了一场灾难。
核心要点回顾
- 事件穿透:给 BottomSheet 根布局加
clickable="true"+focusable="true" - 多点触发:给 GridLayout 加
splitMotionEvents="false" - 滑动全亮:重写 GridLayout 的
dispatchSetPressed方法,不传递 pressed 状态
如果你也遇到了这个问题,直接复制上面的自定义 GridLayout 代码,替换掉原来的,问题就会永远、彻底地解决。
希望这篇文章能帮你少走弯路,如果你觉得有用,欢迎点赞收藏,转发给更多需要的开发者。