把 RecycleView 嵌进 ScrollView的正确姿势

2,120 阅读3分钟

背景

想必接触 Android 开发不久的开发者应该都听说过类似的经验之谈:RecycleView 放在 ScrollView 内会导致内存溢出、RecycleView 内不能嵌套其他 RecycleView 等等。但其实实际业务场景中这类需求也不是没有的,比如:垂直滑动的列表中,某部分的数据需要支持横向滑动;列表滚动到一定位置后,顶部 Tab 栏自动吸顶;侧边菜单中某部分需要可以展开收起且支持滑动,该部分数据来自接口等等。

以上场景各有处理方案,限于篇幅本文只讨论:当同滚动方向的 RecycleView 嵌套在 ScrollView 内时,处理内存溢出和内外滑动冲突的问题。

注:滑动方向不同的 RecycleView 嵌套属于滑动冲突处理,通常会根据不同方向的滑动距离来判断交互意图进而分配相应组件;NestedScrollView 支持另一套机制更优雅地处理联动(如吸顶效果),后续会放在另外的文章中讨论

方案

以下处理 ScrollView 内嵌套 RecycleView 的思路,同样适用于两个 RecycleView 嵌套的场景

从问题来看,要解决嵌套主要是 2 个问题:(1)控制内部 RecycleView 的 ItemView 复用,不能有多少数据就渲染多少控件,进而防止内存溢出。(2)处理上下滑动到内层 RecycleView 边界时的交互效果:当滑动到内层 RecycleView 时得让内层 RecycleView 响应事件,但当内层 RecycleView 内容滚动完毕后要及时把事件交给外部处理。

问题(1)好解决,只需根据业务需要限制好内层 RecycleView 的尺寸(此处以垂直滚动为例,即为限制高度,下同)即可;问题(2)则需根据具体的业务场景需要来控制事件响应,本文以抽屉菜单列表中嵌套二级可滚动菜单为例(通常这类场景中二级菜单的数据来自网络)

record-scroll.gif

布局

<com.chavin.test.OuterScrollView android:id="@+id/outer">
    <LinearLayout>
        <TextView android:text="固定菜单项1" />
        <TextView android:text="固定菜单项2" />
        <TextView android:text="固定菜单项3" />

        <!-- 滑动后吸顶部分,高度:50dp -->
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="50dp"
            android:orientation="vertical">

            <TextView
                android:layout_width="match_parent"
                android:layout_height="50dp"
                android:background="#F99"
                android:gravity="center_vertical|start"
                android:text="点击展开收起二级菜单" />
        </LinearLayout>
        <!-- 内层RecycleView -->
        <com.chavin.test.InnerRecyclerView
            android:id="@+id/inner"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />
    </LinearLayout>
</com.chavin.test.OuterScrollView>

限制 RecycleView 大小

// 初始化最大高度
val displayMetrics = resources.displayMetrics
mInnerRecyclerView.init(findViewById(R.id.outer), displayMetrics.heightPixels - dp2px(50F))

// 保存最大高度,Measure是做限制
class InnerRecyclerView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : RecyclerView(context, attrs) {

    private var mLastY: Float = 0F

    private lateinit var mParentView: ViewGroup
    private var maxH: Int = 0

    fun init(parentView: ViewGroup, maxH: Float) {
        this.mParentView = parentView;
        this.maxH = maxH.toInt();
    }

    override fun onMeasure(widthSpec: Int, heightSpec: Int) {
        // 控制高度
        val hSpec = MeasureSpec.makeMeasureSpec(maxH, MeasureSpec.AT_MOST);
        super.onMeasure(widthSpec, hSpec)
    }
}

处理滑动冲突

由于朴素的事件分发有其局限性,在双层滑动边界处有顿挫感,可考虑外层换用NestedScrollView,利用Nested事件共享机制改进滑动体验

class OuterScrollView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : ScrollView(context, attrs) {

    private var mLastY: Float = 0F

    override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
        if (ev.action == MotionEvent.ACTION_MOVE) {
            // 向下滚动,尽量拦截
            if (ev.y < mLastY && canScrollVertically(1)) {
                super.onInterceptTouchEvent(ev)
                mLastY = ev.y
                return true
            }
        }
        mLastY = ev.y;
        return super.onInterceptTouchEvent(ev)
    }
}

class InnerRecyclerView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : RecyclerView(context, attrs) {

    private var mLastY: Float = 0F

    override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
        if (ev.action == MotionEvent.ACTION_MOVE) {
            // 向上滚动,若已到头,直接放弃
            if (ev.y > mLastY && !canScrollVertically(-1)) {
                mLastY = ev.y
                super.dispatchTouchEvent(ev)
                return false
            }
        }
        mLastY = ev.y
        return super.dispatchTouchEvent(ev)
    }
}