BottomSheet里RecyclerView滑不动? 三种方案解决滑动冲突
2025-05-04 14:09:54
搞定 BottomSheet 里的 RecyclerView 滑不动
接手老项目,总会遇到些“惊喜”。这次碰到的就是个挺经典的:一个展示大量列表数据的 BottomSheet
,里面的 RecyclerView
滑不动了。
咋回事呢?原先的实现是在 BottomSheet
里放了个 NestedScrollView
,NestedScrollView
里面才是 RecyclerView
。但这种搞法有个大坑:NestedScrollView
里如果 RecyclerView
设置了 nestedScrollingEnabled="false"
(或者没设置,依赖旧版本默认值),RecyclerView
的回收机制就废了,会一次性加载所有数据,列表稍微长点就直接 OutOfMemoryError
崩给你看。
为了解决 OOM,我把 NestedScrollView
干掉了,让 RecyclerView
直接干活,发挥它的回收优势。嗯,OOM 是不崩了,但新的问题来了——RecyclerView
用手势滑不动了!奇怪的是,代码里调用 scrollToPosition
是能滚动的。
看看出问题的布局长啥样:
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/bottomSheetContainer"
app:layout_behavior="@string/bottom_sheet_behavior" <!-- 注意这里,行为应用在了 CoordinatorLayout 上 -->
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
android:id="@+id/dragHandleView"
style="?bottomSheetDragHandleStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent" />
<!-- [...] 其他非滑动视图 -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/bottomSheetContent"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="@dimen/medium"
android:focusableInTouchMode="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/tilSearch"
tools:itemCount="3"
tools:listitem="@layout/cell_guest" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
这可咋整?是不是哪里姿势不对?
咋回事?剖析原因
问题的核心在于 滑动事件的冲突 。
CoordinatorLayout
配合 BottomSheetBehavior
使用时,BottomSheetBehavior
会拦截处理垂直方向的触摸滑动事件,用来控制 BottomSheet
的展开、收起和拖拽状态。
另一方面,RecyclerView
本身也需要处理垂直滑动事件,来实现内部列表的滚动。
当你把 RecyclerView
直接放在 BottomSheet
的布局里(更准确地说,是放在那个被 BottomSheetBehavior
控制的直接子 View 里),并且 BottomSheet
本身可以被拖动时,就出现了“抢事件”的情况。BottomSheetBehavior
和 RecyclerView
都想响应你的手指滑动。
很多情况下,CoordinatorLayout
和 BottomSheetBehavior
的事件处理机制会“优先”响应,把滑动识别为拖动 BottomSheet
本身,而不是 RecyclerView
内部的滚动。尤其是当 BottomSheet
还没完全展开时,或者手指滑动开始的位置靠近 RecyclerView
边缘时,更容易触发 BottomSheet
的拖动,RecyclerView
就收不到滑动事件了。
之前用 NestedScrollView
包裹 RecyclerView
时(虽然引起了 OOM),是由 NestedScrollView
作为 BottomSheetBehavior
的直接滚动子视图来协商处理滑动事件的。移除了 NestedScrollView
,RecyclerView
就成了“一线”处理滑动的角色,直接面临与 BottomSheetBehavior
的冲突。
再加上原始布局文件里 app:layout_behavior="@string/bottom_sheet_behavior"
这个属性 错误地 加在了 CoordinatorLayout
根布局上,而不是加在它那个应该被当作 BottomSheet 的直接子 View(也就是 ConstraintLayout
)上,这会让 BottomSheetBehavior
无法正确识别和协调其内部可滚动子视图(RecyclerView
)的滚动行为。系统不知道哪个 View 才是真正的 Bottom Sheet 内容区。
解决 RecyclerView 滑动冲突:几种靠谱方案
别慌,这问题很常见,有几种方法可以解决。
方案一:修正布局,正确应用 Behavior
这是最可能解决问题,也是代码改动最小、最“根治”的方法,优先尝试。
原理:
BottomSheetBehavior
需要明确知道哪个 View 是它要控制的“抽屉”。这个控制是通过在 CoordinatorLayout
的 直接子 View 上设置 app:layout_behavior
属性来实现的。你的原始布局错误地把 layout_behavior
放到了 CoordinatorLayout
上。需要把它移到 ConstraintLayout
上。同时,确保 RecyclerView
开启了嵌套滚动。
操作步骤:
-
移动
layout_behavior
属性:
把app:layout_behavior="@string/bottom_sheet_behavior"
从CoordinatorLayout
标签 移动 到ConstraintLayout
标签上。<?xml version="1.0" encoding="utf-8"?> <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/rootCoordinatorLayout" <!-- ID 建议换一个,避免和内部混淆 --> android:layout_width="match_parent" android:layout_height="match_parent"> <!-- 这个 ConstraintLayout 才是真正的 Bottom Sheet View --> <androidx.constraintlayout.widget.ConstraintLayout android:id="@+id/bottomSheetContainer" <!-- ID 移到这里比较合适 --> android:layout_width="match_parent" android:layout_height="match_parent" app:layout_behavior="@string/bottom_sheet_behavior"> <!-- Behavior 作用于此! --> <com.google.android.material.bottomsheet.BottomSheetDragHandleView android:id="@+id/dragHandleView" style="?bottomSheetDragHandleStyle" android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent" /> <!-- [...] 其他非滑动视图 --> <androidx.recyclerview.widget.RecyclerView android:id="@+id/bottomSheetContent" android:layout_width="match_parent" android:layout_height="0dp" android:layout_marginTop="@dimen/medium" android:focusableInTouchMode="true" android:nestedScrollingEnabled="true" <!-- 明确开启嵌套滚动 --> app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintTop_toBottomOf="@id/tilSearch" <!-- 确认 tilSearch ID 正确 --> tools:itemCount="3" tools:listitem="@layout/cell_guest" /> </androidx.constraintlayout.widget.ConstraintLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>
-
确保 RecyclerView 开启嵌套滚动:
虽然RecyclerView
默认开启nestedScrollingEnabled
,但显式加上android:nestedScrollingEnabled="true"
是个好习惯,确保嵌套滚动机制是工作的。CoordinatorLayout
和BottomSheetBehavior
依赖这个机制来协调父子视图的滚动。 -
检查 RecyclerView 高度:
确保RecyclerView
的android:layout_height="0dp"
和它的约束条件 (app:layout_constraintBottom_toBottomOf="parent"
,app:layout_constraintTop_toBottomOf
) 能让它在ConstraintLayout
内部正确地填充预期的滚动区域。
进阶使用技巧:
-
BottomSheetBehavior
的配置: 在代码中获取BottomSheetBehavior
实例后,可以调整一些属性,比如isFitToContents
。behavior.isFitToContents = true
: Sheet 的高度会包裹内容,最大不超过屏幕高度。如果你的RecyclerView
自身内容不足以撑满BottomSheet
需要占据的空间,可能需要这个设置。behavior.isFitToContents = false
: Sheet 展开时会尝试填充父CoordinatorLayout
的全部高度。如果希望RecyclerView
在一个固定(比如全屏)高度的 Sheet 内滚动,这个设置更常用。
// 在 Fragment 或 Activity 中 val bottomSheetView = view.findViewById<ConstraintLayout>(R.id.bottomSheetContainer) // 获取你的 ConstraintLayout val behavior = BottomSheetBehavior.from(bottomSheetView) // 根据需要设置 behavior.isFitToContents = false // 尝试设置为 false,看是否解决问题 // 可以设置 peekHeight 等其他属性 // behavior.peekHeight = resources.getDimensionPixelSize(R.dimen.your_peek_height)
安全建议:
这种方法通常是最干净、性能最好的,因为它依赖 Android 系统内置的滚动协调机制。改动也最小。
方案二:再次请回 NestedScrollView(但要用对!)
如果方案一因为某些原因(比如复杂的布局或特殊交互)效果不佳,或者你确实需要在 BottomSheet
内部、RecyclerView
旁边放其他也需要滚动的内容,那么可以考虑重新使用 NestedScrollView
,但必须正确配置以避免 OOM。
原理:
关键在于,让 NestedScrollView
负责与 BottomSheetBehavior
的滚动协调,同时确保其内部的 RecyclerView
开启 嵌套滚动 (nestedScrollingEnabled="true"
)。这样,RecyclerView
会通知 NestedScrollView
自己内部是否还能滚动,NestedScrollView
再决定是自己滚动还是让 BottomSheetBehavior
处理。重要的是,RecyclerView
在这种模式下,只要高度设置为 match_parent
或固定值(相对于 NestedScrollView
),它的回收机制就能正常工作。
操作步骤:
-
布局修改: 在
ConstraintLayout
内部,用NestedScrollView
包裹RecyclerView
。<!-- ... ConstraintLayout 内部 ... --> <androidx.core.widget.NestedScrollView android:id="@+id/nestedScrollViewContainer" android:layout_width="match_parent" android:layout_height="0dp" <!-- 让 NestedScrollView 填充可用空间 --> android:fillViewport="true" <!-- 重要:让内部子 View (RecyclerView) 可以撑满高度 --> android:layout_marginTop="@dimen/medium" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintTop_toBottomOf="@id/tilSearch"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/bottomSheetContent" android:layout_width="match_parent" android:layout_height="match_parent" <!-- 高度设为 match_parent 或 wrap_content 都行,取决于 fillViewport 和效果 --> android:nestedScrollingEnabled="true" <!-- 必须开启,让 RecyclerView 和 NestedScrollView 协作 --> android:focusableInTouchMode="true" tools:itemCount="3" tools:listitem="@layout/cell_guest" /> </androidx.core.widget.NestedScrollView> <!-- ... ConstraintLayout 结束 ... -->
-
关键属性:
NestedScrollView
的android:layout_height="0dp"
(或match_parent
,取决于约束布局如何设置) 让它填满分配给它的空间。NestedScrollView
的android:fillViewport="true"
属性非常重要。它允许NestedScrollView
的直接子元素(这里是RecyclerView
)在内容不足以填满NestedScrollView
视口时,也能够将其高度拉伸至NestedScrollView
的高度。这有助于确保滚动行为一致。RecyclerView
的android:nestedScrollingEnabled="true"
必须设置 (或确认默认值是true
)。这是避免 OOM 的关键,也是让RecyclerView
能正确滚动的关键。RecyclerView
的android:layout_height
可以是match_parent
(如果NestedScrollView
设置了fillViewport="true"
) 或者wrap_content
。如果设为match_parent
,它会填充NestedScrollView
,滚动由RecyclerView
内部驱动,直到列表尽头再传递给NestedScrollView
。
安全建议:
虽然避免了 OOM,但这种方法引入了额外的布局层级 (NestedScrollView
),可能会有轻微的性能开销。确保理解 fillViewport
和 nestedScrollingEnabled
的作用。
方案三:终极武器——自定义 Behavior
如果上面两种方法都搞不定,或者你需要对滑动行为进行非常精细的控制(比如,只有当 RecyclerView
滚动到顶部时才允许 BottomSheet
向下拖动收起),那就得上自定义 BottomSheetBehavior
了。
原理:
继承 BottomSheetBehavior
,重写它的触摸事件处理方法,比如 onInterceptTouchEvent
和 onTouchEvent
。在这些方法里,判断触摸点是否在 RecyclerView
区域内,以及 RecyclerView
当前是否可以垂直滚动(使用 canScrollVertically(1)
向上滚动,canScrollVertically(-1)
向下滚动)。根据这些条件,决定是让 RecyclerView
处理事件(返回 false
,不拦截),还是让 BottomSheetBehavior
的默认逻辑处理(调用 super
方法或返回 true
拦截)。
操作步骤:
-
创建自定义 Behavior 类:
import android.content.Context import android.util.AttributeSet import android.view.MotionEvent import android.view.View import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.ViewCompat import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetBehavior class CooperativeBottomSheetBehavior<V : View>(context: Context, attrs: AttributeSet?) : BottomSheetBehavior<V>(context, attrs) { // 需要自己想办法获取到 RecyclerView 实例,可以通过 ID 查找,或者其他方式传入 private var recyclerView: RecyclerView? = null private var recyclerViewId: Int = -1 // 可以通过自定义属性传入 ID // ... 可以在构造函数或自定义属性解析时获取 recyclerViewId ... // 你也可以提供一个公共方法 setRecyclerView(rv: RecyclerView) private fun findRecyclerView(parent: CoordinatorLayout, child: V): RecyclerView? { if (recyclerView != null) return recyclerView // 这里仅为示例,实际查找逻辑可能需要根据你的布局结构调整 // 假设 RecyclerView 是 V (被 Behavior 控制的 View) 的子 View recyclerView = child.findViewById(R.id.bottomSheetContent) // 使用你 RecyclerView 的真实 ID return recyclerView } override fun onInterceptTouchEvent(parent: CoordinatorLayout, child: V, event: MotionEvent): Boolean { val rv = findRecyclerView(parent, child) ?: return super.onInterceptTouchEvent(parent, child, event) if (event.action == MotionEvent.ACTION_DOWN) { // 按下时,需要判断触摸点是否在 RecyclerView 上 val touchX = event.x.toInt() val touchY = event.y.toInt() if (isPointInsideView(touchX, touchY, rv)) { // 点在 RV 内部,接下来看 MOVE 事件 } else { // 点在 RV 外部,按默认行为处理 return super.onInterceptTouchEvent(parent, child, event) } } if (event.action == MotionEvent.ACTION_MOVE) { // 如果 Sheet 不是展开状态,优先让 Sheet 拖动 if (state != STATE_EXPANDED) { return super.onInterceptTouchEvent(parent, child, event) } // 如果 Sheet 已经展开,检查触摸点是否在 RecyclerView 上,并且 RV 能不能滚动 val touchX = event.x.toInt() val touchY = event.y.toInt() if (isPointInsideView(touchX, touchY, rv)) { // 判断 RV 是否能滚动 (假设是垂直列表) // -1 代表检查是否能向上滚(内容在下方), 1 代表检查是否能向下滚(内容在上方) // 这里可能需要根据滑动方向判断,简化处理:只要 RV 能滚动,就不拦截 val canScrollUp = rv.canScrollVertically(-1) val canScrollDown = rv.canScrollVertically(1) if (canScrollUp || canScrollDown) { // 如果 RecyclerView 能滚动,把事件交给它,Behavior 不拦截 return false } } } // 其他情况,交给默认行为处理 return super.onInterceptTouchEvent(parent, child, event) } // 简单的判断点是否在 View 内部的方法(需要考虑滚动偏移) private fun isPointInsideView(x: Int, y: Int, view: View): Boolean { val location = IntArray(2) view.getLocationOnScreen(location) val viewX = location[0] val viewY = location[1] return x >= viewX && x <= (viewX + view.width) && y >= viewY && y <= (viewY + view.height) // 注意:这里的坐标是屏幕绝对坐标,需要 event.rawX/rawY 对比, // 或者将 View 的 rect 转换为父布局坐标系对比 event.x/y。 // 一个更简单的方法可能是: // val rect = Rect() // view.getGlobalVisibleRect(rect) // 获取View在屏幕上的可见矩形 // return rect.contains(event.rawX.toInt(), event.rawY.toInt()) // 另一种方式是检查 view 的局部坐标 // val localX = event.x - view.left // 这假设 event 坐标系和 view.left 是相对于同一个父级 // val localY = event.y - view.top // return localX >= 0 && localX < view.width && localY >= 0 && localY < view.height // 上述实现需要精确理解坐标系,实际应用中可能需要调试调整 // 最稳妥的方法可能是直接检查RecyclerView的滚动能力,如果能滚动就不拦截 // 只要在MOVE时,触摸点大致在RV区域内即可. val rect = android.graphics.Rect() view.getHitRect(rect) // 获取相对于父布局的点击区域 return rect.contains(x, y) } }
-
在 XML 中使用自定义 Behavior:
<androidx.constraintlayout.widget.ConstraintLayout android:id="@+id/bottomSheetContainer" android:layout_width="match_parent" android:layout_height="match_parent" app:layout_behavior=".CooperativeBottomSheetBehavior"> <!-- 使用你的自定义 Behavior --> <!-- ... RecyclerView 等内容 ... --> </androidx.constraintlayout.widget.ConstraintLayout>
确保
.CooperativeBottomSheetBehavior
的路径相对于你的应用包名是正确的。
安全建议与进阶技巧:
- 这是最复杂的方法,需要你对 Android 的触摸事件分发机制有比较深的理解。
isPointInsideView
的实现需要小心处理坐标系转换,否则判断会不准确。获取RecyclerView
实例的方式也需要设计好。- 你可以在
onInterceptTouchEvent
和onTouchEvent
中加入更复杂的逻辑,比如结合BottomSheet
的当前状态 (state
) 来决定是否拦截。
选哪个?简单总结下
- 优先尝试【方案一:修正布局,正确应用 Behavior】 。大概率是
layout_behavior
位置错了。这个改动最小,效果最好。别忘了检查RecyclerView
的nestedScrollingEnabled
和高度设置。顺便调整下BottomSheetBehavior
的isFitToContents
试试。 - 如果方案一不行,或者你的场景确实复杂(比如
RecyclerView
旁边还有其他滚动元素),可以试试【方案二:用对 NestedScrollView 】。记得nestedScrollingEnabled="true"
和fillViewport="true"
这俩兄弟是关键。 - 【方案三:自定义 Behavior 】是最后的手段,适用于疑难杂症和需要高度定制滑动交互的场景。代码量和复杂度都上去了。
搞定这个滚动冲突,BottomSheet
和 RecyclerView
又能愉快地合作了。