返回

BottomSheet里RecyclerView滑不动? 三种方案解决滑动冲突

Android

搞定 BottomSheet 里的 RecyclerView 滑不动

接手老项目,总会遇到些“惊喜”。这次碰到的就是个挺经典的:一个展示大量列表数据的 BottomSheet,里面的 RecyclerView 滑不动了。

咋回事呢?原先的实现是在 BottomSheet 里放了个 NestedScrollViewNestedScrollView 里面才是 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 本身可以被拖动时,就出现了“抢事件”的情况。BottomSheetBehaviorRecyclerView 都想响应你的手指滑动。

很多情况下,CoordinatorLayoutBottomSheetBehavior 的事件处理机制会“优先”响应,把滑动识别为拖动 BottomSheet 本身,而不是 RecyclerView 内部的滚动。尤其是当 BottomSheet 还没完全展开时,或者手指滑动开始的位置靠近 RecyclerView 边缘时,更容易触发 BottomSheet 的拖动,RecyclerView 就收不到滑动事件了。

之前用 NestedScrollView 包裹 RecyclerView 时(虽然引起了 OOM),是由 NestedScrollView 作为 BottomSheetBehavior 的直接滚动子视图来协商处理滑动事件的。移除了 NestedScrollViewRecyclerView 就成了“一线”处理滑动的角色,直接面临与 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 开启了嵌套滚动。

操作步骤:

  1. 移动 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>
    
  2. 确保 RecyclerView 开启嵌套滚动:
    虽然 RecyclerView 默认开启 nestedScrollingEnabled,但显式加上 android:nestedScrollingEnabled="true" 是个好习惯,确保嵌套滚动机制是工作的。CoordinatorLayoutBottomSheetBehavior 依赖这个机制来协调父子视图的滚动。

  3. 检查 RecyclerView 高度:
    确保 RecyclerViewandroid: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),它的回收机制就能正常工作。

操作步骤:

  1. 布局修改: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 结束 ... -->
    
  2. 关键属性:

    • NestedScrollViewandroid:layout_height="0dp" (或 match_parent,取决于约束布局如何设置) 让它填满分配给它的空间。
    • NestedScrollViewandroid:fillViewport="true" 属性非常重要。它允许 NestedScrollView 的直接子元素(这里是 RecyclerView)在内容不足以填满 NestedScrollView 视口时,也能够将其高度拉伸至 NestedScrollView 的高度。这有助于确保滚动行为一致。
    • RecyclerViewandroid:nestedScrollingEnabled="true" 必须设置 (或确认默认值是 true)。这是避免 OOM 的关键,也是让 RecyclerView 能正确滚动的关键。
    • RecyclerViewandroid:layout_height 可以是 match_parent (如果 NestedScrollView 设置了 fillViewport="true") 或者 wrap_content。如果设为 match_parent,它会填充 NestedScrollView,滚动由 RecyclerView 内部驱动,直到列表尽头再传递给 NestedScrollView

安全建议:
虽然避免了 OOM,但这种方法引入了额外的布局层级 (NestedScrollView),可能会有轻微的性能开销。确保理解 fillViewportnestedScrollingEnabled 的作用。

方案三:终极武器——自定义 Behavior

如果上面两种方法都搞不定,或者你需要对滑动行为进行非常精细的控制(比如,只有当 RecyclerView 滚动到顶部时才允许 BottomSheet 向下拖动收起),那就得上自定义 BottomSheetBehavior 了。

原理:
继承 BottomSheetBehavior,重写它的触摸事件处理方法,比如 onInterceptTouchEventonTouchEvent。在这些方法里,判断触摸点是否在 RecyclerView 区域内,以及 RecyclerView 当前是否可以垂直滚动(使用 canScrollVertically(1) 向上滚动,canScrollVertically(-1) 向下滚动)。根据这些条件,决定是让 RecyclerView 处理事件(返回 false,不拦截),还是让 BottomSheetBehavior 的默认逻辑处理(调用 super 方法或返回 true 拦截)。

操作步骤:

  1. 创建自定义 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)
        }
    }
    
  2. 在 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 实例的方式也需要设计好。
  • 你可以在 onInterceptTouchEventonTouchEvent 中加入更复杂的逻辑,比如结合 BottomSheet 的当前状态 (state) 来决定是否拦截。

选哪个?简单总结下

  1. 优先尝试【方案一:修正布局,正确应用 Behavior】 。大概率是 layout_behavior 位置错了。这个改动最小,效果最好。别忘了检查 RecyclerViewnestedScrollingEnabled 和高度设置。顺便调整下 BottomSheetBehaviorisFitToContents 试试。
  2. 如果方案一不行,或者你的场景确实复杂(比如 RecyclerView 旁边还有其他滚动元素),可以试试【方案二:用对 NestedScrollView 】。记得 nestedScrollingEnabled="true"fillViewport="true" 这俩兄弟是关键。
  3. 方案三:自定义 Behavior 】是最后的手段,适用于疑难杂症和需要高度定制滑动交互的场景。代码量和复杂度都上去了。

搞定这个滚动冲突,BottomSheetRecyclerView 又能愉快地合作了。