背景
在电商App的页面中经常遇到这样的需求: 顶部一些运营位+底部tab的推荐流,并且tab是可以吸顶的(例如京东的首页), 例如得物频道页就是这样结构
为了实现这样的结构我们很自然想到使用CoordinatorLayout+AppBarLayout+ViewPager的方式实现,在一般情况下,这种方式是没有问题,但是时业务反正过程中,这中结构渐渐不够用了,并且有一些缺陷,主要有一下几点:
- 头部如果内容比较长的话,AppBarLayout内部的View会一直加载到内存中,对页面性能会有影响
- 吸顶部分如果要更换的比较麻烦,因为吸顶部分都是固定写在布局中的,不利于扩展
- 上下滑动不连贯,吸顶部分和AppBarLayout由于是割裂的两部分, 上下滑动过程中在吸顶瞬间会丢失滚动状态
- 滑动时上下两部分,滚动状态无法正确监听
使用方法简介
github地址: github.com/ToryCrox/Ne…
基本使用
- 定义父布局中的NestedParentRecyclerView,使用方法和普通RecyclerView一致
<com.tory.nestedceiling.widget.NestedParentRecyclerView
android:id="@+id/nested_rv"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
- 设置子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
}
-
配置子View容器,这里指的是NestedParentRecyclerView的直接子View,用来标记哪个子View可以嵌套滑动,需要以下两个关键步骤:
- 设置容器的宽高都为MATCH_PARENT
- 调用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的高度的,离顶部有一段距离,尤其例如标题栏透明的情况:
- NestedChildRecyclerView设置topOffset
toolbar.doOnPreDraw {
Log.d("TransparentToolbar", "topOffset " + toolbar.measuredHeight)
recyclerView.topOffset = toolbar.measuredHeight
}
- 子View重写onMeasure, 需要调用NestedCeilingHelper.wrapContainerMeasureHeight
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, NestedCeilingHelper.wrapContainerMeasureHeight(this, heightMeasureSpec))
}
状态保存
子View如果使用到了Fragment,需要格外注意Fragment状态保存和恢复会导致泄漏,需要我们自行处理状态保存和恢复的过程
- savedStateRegistry注册保存事件
- 在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上传递的过程.
NestedScrollingChild | NestedScrollingParent | 说明 |
---|---|---|
startNestedScroll | onStartNestedScroll | child 的调用会触发 parent 回调,onStartNestedScroll 返回值决定了后续嵌套滑动事件是否传递给 parent 处理 |
onNestedScrollAccepted | 如果 onStartNestedScroll 返回 true,则回调此方法。 | |
dispatchNestedPreScroll | onNestedPreScroll | child 滑动前触发 parent 回调,parent 根据自身情况决定是否要滑动,如果消耗,就将消耗的值回传给child |
scrollBy | child 自身滚动,滚动距离要减去上一步的消耗 | |
dispatchNestedScroll | onNestedScroll | child 滚动之后触发parent 回调,parent 接收到child 未消耗的滚动距离,根据自身情况决定是否滑动,一般此时为消耗的距离不为0表示child已经无法滑动 |
dispatchNestedPreFling | onNestedPreFling | child Fling 前触发 parent 回调 |
dispatchNestedFling | onNestedFling | |
stopNestedScroll | onStopNestedScroll | |
getNestedScrollAxes | 获得滑动方向,此方法为主动调用的方法 |
说明
- 嵌套滚动的Api迭代了好多个版本,目前有2和3几个版本
- 要接收嵌套滑动父View必须实现NestedScrollingParent,而子View不是必须的,但是建议实现
NestedScrollingChild
,并使用NestedScrollingChildHelper
来传递事件 - NestedScrollingParent和NestedScrollingChild之间不一定要是直接的父子View的关系,因为NestedScrollingChild会逐级向上查找Parent的
- 在Child进行Fling的过程中,也是不断的会回调onNestedPreScroll-> onNestedScroll的,此时和手滑动的区别在于回调参数type,
type==ViewCompat.``TYPE_TOUCH
表示触摸滚动,type==ViewCompat.``TYPE_NON_TOUCH
表示Fling的滚动 - 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的处理分为两种状态需要处理
- 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);
}
}
}
- 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的结构,应该吧。。。。