实现流畅的嵌套RecyclerView

4,294 阅读8分钟

背景

在电商App的页面中经常遇到这样的需求: 顶部一些运营位+底部tab的推荐流,并且tab是可以吸顶的(例如京东的首页), 例如得物频道页就是这样结构

为了实现这样的结构我们很自然想到使用CoordinatorLayout+AppBarLayout+ViewPager的方式实现,在一般情况下,这种方式是没有问题,但是时业务反正过程中,这中结构渐渐不够用了,并且有一些缺陷,主要有一下几点:

  1. 头部如果内容比较长的话,AppBarLayout内部的View会一直加载到内存中,对页面性能会有影响
  2. 吸顶部分如果要更换的比较麻烦,因为吸顶部分都是固定写在布局中的,不利于扩展
  3. 上下滑动不连贯,吸顶部分和AppBarLayout由于是割裂的两部分, 上下滑动过程中在吸顶瞬间会丢失滚动状态
  4. 滑动时上下两部分,滚动状态无法正确监听

使用方法简介

github地址: github.com/ToryCrox/Ne…

基本使用

  1. 定义父布局中的NestedParentRecyclerView,使用方法和普通RecyclerView一致
<com.tory.nestedceiling.widget.NestedParentRecyclerView
    android:id="@+id/nested_rv"
    android:layout_width="match_parent"
    android:layout_height="match_parent"/>
  1. 设置子View,嵌套内部的RecyclerView必须使用NestedChildRecyclerView,例如此时的子View是ViewPager+Fragment的结构
override fun onCreateView(inflater: LayoutInflater, 
    container: ViewGroup?, savedInstanceState: Bundle?): View? {
    val recyclerView = NestedChildRecyclerView(requireContext())
    recyclerView.id = R.id.recyclerView
    return recyclerView
}
  1. 配置子View容器,这里指的是NestedParentRecyclerView的直接子View,用来标记哪个子View可以嵌套滑动,需要以下两个关键步骤:

    1. 设置容器的宽高都为MATCH_PARENT
    2. 调用NestedCeilingHelper.setNestedChildContainerTag(view)

class LastViewPager2ItemView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : AbsModuleView<LastViewPager2Model>(context, attrs) {

    val tabLayout = findViewById<TabLayout>(R.id.tab_layout)
    val viewPager = findViewById<ViewPager2>(R.id.view_pager)

    init {
        layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
        NestedCeilingHelper.setNestedChildContainerTag(this)
    }

}

配置吸顶距离

有时候嵌套的View并不是占满父View的高度的,离顶部有一段距离,尤其例如标题栏透明的情况:

  1. NestedChildRecyclerView设置topOffset
toolbar.doOnPreDraw {

 Log.d("TransparentToolbar", "topOffset " + toolbar.measuredHeight)

    recyclerView.topOffset = toolbar.measuredHeight

 }
  1. 子View重写onMeasure, 需要调用NestedCeilingHelper.wrapContainerMeasureHeight


override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {

    super.onMeasure(widthMeasureSpec, NestedCeilingHelper.wrapContainerMeasureHeight(this, heightMeasureSpec))

}

状态保存

子View如果使用到了Fragment,需要格外注意Fragment状态保存和恢复会导致泄漏,需要我们自行处理状态保存和恢复的过程

  1. savedStateRegistry注册保存事件
  2. 在bindView时调用savedStateRegistry.consumeRestoredStateForKey消费掉保存的内容

init {
    registerSaveState()
}


fun onBind() {
    consumeRestoreState()
}


 /**

 * 注册保存状态,前提是能保证唯一, 放在View init中使用

 */

fun registerSaveState() {
    val viewName = this.javaClass.simpleName
 requireActivity().savedStateRegistry.registerSavedStateProvider("save_state_item_$viewName") {
 val bundle = Bundle()
        val container = SparseArray<Parcelable>()
        bundle.putSparseParcelableArray("save_state_item_view_$viewName", container)
        saveHierarchyState(container)
        bundle
    }
}



 /**

 * 消费State状态

 */

fun consumeRestoreState() {
    val activity = requireActivity()
    if (!activity.savedStateRegistry.isRestored) {
        if (ChannelHelper.DEBUG) {
            throw IllegalStateException("必须在restore之后调用consumeRestoreState,建议在onBind中调用")
        }
        return
    }

    val viewName = this.javaClass.simpleName
    val bundle = requireActivity().savedStateRegistry.consumeRestoredStateForKey("save_state_item_$viewName") ?: return
    val savedState = bundle.getSparseParcelableArray<Parcelable>("save_state_item_view_$viewName")
    if (savedState != null) {
        restoreHierarchyState(savedState)
    }
}

嵌套滚动原理简介

嵌套滚动我们一般使用NestedScrollingParent和NestedScrollingChild,本质上是Child将滑动状态往Parent上传递的过程.

NestedScrollingChildNestedScrollingParent说明
startNestedScrollonStartNestedScrollchild的调用会触发 parent 回调,onStartNestedScroll返回值决定了后续嵌套滑动事件是否传递给 parent处理
onNestedScrollAccepted如果 onStartNestedScroll返回 true,则回调此方法。
dispatchNestedPreScrollonNestedPreScrollchild滑动前触发 parent 回调,parent根据自身情况决定是否要滑动,如果消耗,就将消耗的值回传给child
scrollBychild自身滚动,滚动距离要减去上一步的消耗
dispatchNestedScrollonNestedScrollchild滚动之后触发parent回调,parent接收到child未消耗的滚动距离,根据自身情况决定是否滑动,一般此时为消耗的距离不为0表示child已经无法滑动
dispatchNestedPreFlingonNestedPreFlingchild Fling 前触发 parent 回调
dispatchNestedFlingonNestedFling
stopNestedScrollonStopNestedScroll
getNestedScrollAxes获得滑动方向,此方法为主动调用的方法

说明

  1. 嵌套滚动的Api迭代了好多个版本,目前有2和3几个版本
  2. 要接收嵌套滑动父View必须实现NestedScrollingParent,而子View不是必须的,但是建议实现NestedScrollingChild,并使用NestedScrollingChildHelper来传递事件
  3. NestedScrollingParent和NestedScrollingChild之间不一定要是直接的父子View的关系,因为NestedScrollingChild会逐级向上查找Parent的
  4. 在Child进行Fling的过程中,也是不断的会回调onNestedPreScroll-> onNestedScroll的,此时和手滑动的区别在于回调参数type,type==ViewCompat.``TYPE_TOUCH表示触摸滚动,type==ViewCompat.``TYPE_NON_TOUCH表示Fling的滚动
  5. RecyclerView只实现了NestedScrollingChild,并没有实现NestedScrollingParent,这导致RecyclerView只能作为嵌套滚动过程中的Child,而不能作为Parent

嵌套问题处理

寻找嵌套子View

虽然在嵌套滚动过程中Child会将滚动状态传递上来,但父RecyclerView在滑动是会直接拦截子view的触摸事件,所以需要对嵌套子View进行标记识别,这里我们使用Tag对View进行标记

view.setTag(R.id.nested_child_item_container, Boolean.TRUE);

在父View的NestedParentRecyclerView中onChildAttachedToWindow中对嵌套的view进行识别


@Override
public void onChildAttachedToWindow(@NonNull View child) {
    if (isTargetContainer(child)) {
        mContentView = (ViewGroup) child;
        ViewGroup.LayoutParams lp = child.getLayoutParams();
    }
}

@Override
public void onChildDetachedFromWindow(@NonNull View child) {
    if (child == mContentView) {
        mContentView = null;
        log("onChildDetachedFromWindow....");
    }
}

然而这样还不够,因为NestedScrollingChild往往不是NestedScrollingParent的直接子View,中间有嵌套,所以有了mContentView后还要对mContentView中继续寻找,这里规定NestedScrollingChild必须为NestedChildRecyclerView,以方便后续的滚动传递


public static NestedChildRecyclerView findChildScrollTarget(@Nullable View sourceView) {
    if(sourceView == null || sourceView.getVisibility() != View.VISIBLE) {
        return null;
    }

    if (sourceView instanceof NestedChildRecyclerView) {
        return (NestedChildRecyclerView) sourceView;
    }

    if (!(sourceView instanceof ViewGroup)) {
        return null;
    }

    ViewGroup contentView = (ViewGroup) sourceView;
    int childCount = contentView.getChildCount();

    for (int i = childCount - 1; i >= 0; i--) {
        View view = contentView.getChildAt(i);
        int centerX = (view.getLeft() + view.getRight()) / 2;
        int contentLeft = contentView.getScrollX();
        if (centerX <= contentLeft || centerX >= contentLeft + contentView.getWidth()) {
            continue;
        }

        NestedChildRecyclerView target = findChildScrollTarget(view);
        if(target != null){
            return target;
        }
    }
    return null;

}
  • 在ViewPager中,NestedChildRecyclerView可能不是ViewPager的第一个Child,所以需要计算View是否在Parent的可视区域内

子View触摸事件拦截

嵌套滚动要解决的第一个问题就是让子View可以触摸滑动,需要父RecyclerView能拦截触摸到子View上的事件

// NestedParentRecyclerView.java
@Override
public boolean onInterceptTouchEvent(MotionEvent e) {
    boolean isTouchInChildArea =  (mContentView != null) && (e.getY() > mContentView.getTop())
            && (e.getY() < mContentView.getBottom())
            && FindTarget.findChildScrollTarget(mContentView) != null;
    // 此控件滑动到底部或者触摸区域在子嵌套布局不拦截事件
    if (isTouchInChildArea) {
        if (getScrollState() == SCROLL_STATE_SETTLING) {
            // 上划fling过程中,停止,否则会抖动
           stopScroll();
        }
        return false;
    }
    return super.onInterceptTouchEvent(e);
}

以上是解决的TOUCH_DOWN事件落到子view的情况,如果TOUCH_DOWN事件开始没有落到子View上,由父View开始控制滑动了,滑动过程中手指落到了子View了,这时由于子View没有接管到滑动事件,就导致正式滑动事件中断了,体验很不好,在CoordinateLayout+AppBarLayout+ViewPager+RecyclerView的布局结构中就有这种问题: 从头部往下滑,在不断触的情况下,滑动到RecyclerView时,整个滑动就中断了,RecyclerView无法继续滑动。

既然子view无法接管触摸事件,可以由父view来控制NestedChildRecyclerView的滚动事件

// NestedParentRecyclerView.java

public boolean onTouchEvent(MotionEvent e) {
    final int action = e.getActionMasked();
    switch (action) {
        case MotionEvent.ACTION_DOWN:
            mLastY = e.getY();
            mActivePointerId = e.getPointerId(0);
            mVelocityY = 0;
            stopScroll();
            break;
        case MotionEvent.ACTION_POINTER_DOWN:
            final int index = e.getActionIndex();
            mLastY = e.getY(index);
            mActivePointerId = e.getPointerId(index);
            break;
        case MotionEvent.ACTION_POINTER_UP:
            onSecondaryPointerUp(e);
            break;
        case MotionEvent.ACTION_MOVE:
            final int activePointerIndex = e.findPointerIndex(mActivePointerId);
            if (activePointerIndex == -1) {
                log("Invalid pointerId=" + mActivePointerId + " in onTouchEvent");
                break;
            }
            final float y = e.getY(activePointerIndex);
            if (isScrollEnd()) {
                // 如果此控件已经滑动到底部,需要让子嵌套布局滑动剩余的距离
                // 或者子嵌套布局向下还未到顶部,也需要让子嵌套布局先滑动一段距离
                NestedChildRecyclerView child = FindTarget.findChildScrollTarget(mContentView);
                if (child != null) {
                    int deltaY = (int) (mLastY - y);
                    mTempConsumed[1] = 0;
                    child.doScrollConsumed(0, deltaY, mTempConsumed);
                    int consumedY = mTempConsumed[1];
                    if (consumedY != 0 && NestedCeilingHelper.DEBUG) {
                        log("onTouch scroll consumed: " + consumedY);
                    }
                }
            }
            mLastY = y;
            break;
    }

    return super.onTouchEvent(e);

}
  • doScrollConsumed方法是用来直接滚动RecyclerView的方法,下面会单独讲

子View传递滚动事件

看过嵌套滚动的原理后,我们知道要实现嵌套滚动,最重要的是实现两个方法

  • onNestedPreScroll: 子view滚动之前

在嵌套子RecyclerView的模型中,如果父RecyclerView还可以滚动,那么就需要实现该方法,并将消耗的距离回传给consumed数组



@Override

public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
    // 向上滑动且此控件没有滑动到底部时,需要让此控件继续滑动以保证滑动连贯一致性
    boolean needKeepScroll = dy > 0 && !isScrollEnd();
    if (needKeepScroll) {
        mTempConsumed[1] = 0;
        doScrollConsumed(0, dy, mTempConsumed);
        consumed[1] = mTempConsumed[1];
    }
}
  • onNestedScroll: 子View滚动之后

首先要明白,若该方法回传的dyUnconsumed参数不为0,表示嵌套的子View已经无法在滚动了,这时候就可以轮到父view接着滚动了



@Override

public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
                           int dxUnconsumed, int dyUnconsumed, int type, @NonNull int[] consumed) {
    onNestedScrollInternal(target, dyUnconsumed, type, consumed);
}



 /**

 * dyUnconsumed != 0时,嵌套的子view,表示嵌套的子view不能滑动,也就是到顶了,大于0表示下滑,小于0表示上划
 *  @param target
 *  @param dyUnconsumed
 *  @param type
 *  @param consumed
 */

private void onNestedScrollInternal(@NonNull View target, int dyUnconsumed, int type, @NonNull int[] consumed) {

    if (dyUnconsumed == 0) {
        return;
    }

    mTempConsumed[1] = 0;
    doScrollConsumed(0, dyUnconsumed, mTempConsumed);
    int consumedY = mTempConsumed[1];
    consumed[1] += consumedY;
    final int myUnconsumedY = dyUnconsumed - consumedY;
    dispatchNestedScroll(0, consumedY, 0, myUnconsumedY, null, type, consumed);
}

RecyclerView内部滚动状态的获取

上述滚动状态的处理时,都需要直接控制RecyclerView的滚动,scrollBy方法是有局限的它无法获取滚动之后实际消耗了多少,通过查看源码发现RecyclerView都是通过scrollStep方法进行真正的滚动的,但是这个方法不是public的,无法直接获取,这里需要一点小技巧,可以实现一个自定义的RecyclerView,并将它的包名和RecyclerView设置为同一个:

// NestedPublicRecyclerView

public class NestedPublicRecyclerView extends RecyclerView {

    @Override
    void scrollStep(int dx, int dy, @Nullable int[] consumed) {
        super.scrollStep(dx, dy, consumed);
    }

    public void doScrollConsumed(int dx, int dy, @NonNull int[] consumed) {
        consumed[0] = 0;
        consumed[1] = 1;
        scrollStep(dx, dy, consumed);
        int consumedX = consumed[0];
        int consumedY = consumed[1];
        if (consumedX != 0 || consumedY != 0) {
            // 分发滚动状态
            dispatchOnScrolled(consumedX, consumedY);
        }
    }

 }

Fling状态处理

为了使整个滚动过程更加连贯,需要传递嵌套RecyclerView之间的Fling状态,然而它的Fling状态也是不对外暴露的,所以我们也需要将它暴露出来


/**
* 可以获取Fling过程中的速度信息
*/
@Nullable
public OverScroller getFlingOverScroll() {
    return mViewFlinger.mOverScroller;
}


 /**
 * Fling到边缘时回调
 *  @param velocityX
 *  @param velocityY
 */

@Override
void absorbGlows(int velocityX, int velocityY) {
    //super.absorbGlows(velocityX, velocityY);
    onFlingEnd(velocityX, velocityY);
}

 /**
 * Fling到边缘时回调
 *  @param velocityX
 *  @param velocityY
 */
protected void onFlingEnd(int velocityX, int velocityY) {

}

Fing的处理分为两种状态需要处理

  1. NestedParentRecyclerView向下Fling,滚动到边缘时传递给NestedChildRecyclerView,由于我们重写了RecyclerView的absorbGlows的方法,所以实现非常简单

@Override
protected void onFlingEnd(int velocityX, int velocityY) {
    super.onFlingEnd(velocityX, velocityY);
    if (velocityY > 0 && NestedCeilingHelper.USE_OVER_SCROLL) {
        // 通过OverScroll传递滚动状态
        RecyclerView child = FindTarget.findChildScrollTarget(mContentView);
        if (child != null) {
            if (NestedCeilingHelper.DEBUG) {
                log("onFlingEnd fling child velocityY: " + velocityY);
            }
            child.fling(0, velocityY);
        }
    }
}
  1. NestedChildRecyclerView向上Fling的过程中,碰触到边缘,需要将Fling的滚动状态传递给NestedParentRecyclerView继续Fling。虽然我们知道子View的Fling状态会通过onNestedPreFling传递过来,不过那是Fling的一瞬间产生的事件,此时NestedChildRecyclerView可能可以继续Fling也可能不能继续Fling,所以需要分两种情况:
  • Fling时NestedChildRecyclerView已经无法继续Fling,此时直接将Fing事件给NestedParentRecyclerView即可

@Override
public boolean onNestedFling(
        @NonNull View target, float velocityX, float velocityY, boolean consumed) {
    if (!consumed) {
        dispatchNestedFling(0, velocityY, true);
        fling(0, (int) velocityY);
        return true;
    }
    return false;

}
  • NestedChildRecyclerView在Fling过程中碰触到边缘,再将Fling状态传递给NestedParentRecyclerView,这时我采用的方法是在onNestedScroll中判断Fling的状态,然后接管将NestedChildRecyclerView给停止掉

private void onNestedScrollInternal(@NonNull View target, int dyUnconsumed, int type, @NonNull int[] consumed) {
     // 省略部分
    // dyUnconsumed 大于0是下滑,小于0是上划,type为TYPE_NON_TOUCH表示Fling状态
    if (dyUnconsumed < 0 && type == ViewCompat.TYPE_NON_TOUCH && target instanceof NestedChildRecyclerView) {
        NestedChildRecyclerView nestedView = (NestedChildRecyclerView) target;
        if (nestedView != FindTarget.findChildScrollTarget(mContentView)) {
            log("onNestedScrollInternal nestedView is changed, return");
            return;
        }

        OverScroller overScroller = nestedView.getFlingOverScroll();
        if (overScroller == null) {
            return;
        }

        float absVelocity = overScroller.getCurrVelocity();
        // nestedView.stopScroll();
        // 停止,但不更新状态,因为fling时在onStateChanged中要更新子view的状态
        nestedView.stopScrollWithoutState();
        float myVelocity = absVelocity * -1;
        fling(0, Math.round(myVelocity));

        if (NestedCeilingHelper.DEBUG) {
            log("onNestedScrollInternal start fling from child, absVelocity:" + absVelocity + ", myVelocity:" + myVelocity);
        }

    }

}

总结

总体上实现是参照了改项目github.com/solartcc/Ne…, 但是在clone下来后发现了不少问题,无法做为一个商业上的app使用,索性自己改实现了。在实现的过程中也逐渐加深了对NestedScrolling原理的理解,整理清楚了嵌套RecyclerView中各种状态,能达到一个完美的嵌套RecyclerView的结构,应该吧。。。。

参考