作者:Oz
v 信号: Mojitok8275
版权声明:本文图文为博主原创,转载请注明出处。
吸顶效果是各家 App 或多或少都会用的一个交互,这种交互也常见于 PC、H5,可以说是一种通用性很强的前端交互体验,相比较来说京东首页的嵌套滑动吸顶效果是各个类似效果中体验比较好的一个,因为在嵌套布局中滑动连贯性处理得非常好,今天我们就来实现同样的交互效果。
这篇文章我会讲些什么
对于吸附效果实现的思路
3 个版本的 NestedScrollingParent、NestedScrollingChild 简单介绍
嵌套组件滑动连贯性(一致性)的处理(事件分发、Fling 等)
交互的优化问题
首先,先看一下我们实现的效果图:
这里只介绍我们实现过程中的思路,以及部分代码,源码请查看 NestedCeilingEffect ,欢迎建议、Issues、Star
实现滑动吸顶效果的简单分析 对于吸顶效果,首先我们要先创造出 “顶”
来,那么如何创造 “顶”
呢?常见的实现方式有很多,比如:
通过两个相同的顶部控件显示或隐藏来实现
通过动态 layout 顶部控件来实现
通过重写 ItemDecoration 来实现
通过 CoordinatorLayout 协调子布局来实现
这些方式或多或少在某些方面有一些场景上的限制,这次我们采用另外一种方式来实现 “顶”
的效果,这里先卖一个关子。
如果我们已经成功的创造出 “顶”
来,那么接下来就是处理好控件的滑动效果,应该就可以实现我们想要的效果了,说来简单,我们不妨通过 Layout Inspector
工具来看一下相关 App 的布局,帮助我们整理思路,我们查看京东首页的布局,发现的确是采用两层 RecyclerView
嵌套来实现的,所以我们同样可以构建这样一个布局结构:
那么接下来我们就来实现它。
创造 “顶”
的效果 我们上面提到了几种构建 “顶”
的方式,我们这里采用固定高度的方式来实现。在我们的布局中整个蓝色区域其实就是最后一个 item
,那么我们只需要对最后整个 item
固定高度为父布局的高度即可,对于 NestedParentRecyclerView
来说它滑动到底部时不能再向上滑动了,而此时蓝色部分正好充满父布局,从而实现了 “顶”
的效果。
相比较来说,我们确定最后一个 item
高度的时机选择在 onChildAttachedToWindow
比较合适。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 public class NestedParentRecyclerView extends RecyclerView { ... @Override public void onChildAttachedToWindow (@NonNull View child) { if (isTargetPosition(child)) { ViewGroup.LayoutParams lp = child.getLayoutParams(); lp.height = getMeasuredHeight(); child.setLayoutParams(lp); mContentView = (ViewGroup) child; } } @Override public void onChildDetachedFromWindow (@NonNull View child) { if (isTargetPosition(child)) { mContentView = null ; } } protected boolean isTargetPosition (View child) { if (mLayoutManager != null && mAdapter != null ) { int position = mLayoutManager.getPosition(child); return position + 1 == mAdapter.getItemCount(); } return false ; } }
因为 RecyclerView
本身的复用回收效果所以我们在 onChildDetachedFromWindow
时要将引用置空。
嵌套布局滑动连贯性的处理 在开始介绍滑动连贯性的处理之前,我们先简单介绍一下 NestedScrollingParent
、NestedScrollingChild
的使用,随着不断的发展这套 API 已经有了三
个版本,目前主要用 2和3
NestedScrollingChild
NestedScrollingParent
说明
startNestedScroll
onStartNestedScroll
child
的调用会触发 parent
回调, onStartNestedScroll
返回值决定了后续嵌套滑动事件是否传递给 parent
处理
onNestedScrollAccepted
如果 onStartNestedScroll
返回 true,则回调此方法
dispatchNestedPreScroll
onNestedPreScroll
child
滑动前触发 parent
回调,parent
根据自身情况决定是否要滑动
dispatchNestedScroll
onNestedScroll
dispatchNestedPreFling
onNestedPreFling
child
Fling 前触发 parent
回调
dispatchNestedFling
onNestedFling
stopNestedScroll
onStopNestedScroll
getNestedScrollAxes
获得滑动方向,此方法为主动调用的方法
而调用关系其实并不复杂,所有的调用都跟 Touch
事件脱离不开,这里附上一张流程图,方便大家理解
子布局的滑动传递处理 本身 RecyclerView
已经实现了 NestedScrollingChild
,而我们选择外层布局也使用了 RecyclerView
为了能够处理子布局的嵌套滑动时间,我们让 NestedParentRecyclerView
实现 NestedScrollingParent3
以及 NestedScrollingParent2
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 public class NestedParentRecyclerView extends RecyclerView implements NestedScrollingParent3 , NestedScrollingParent2 { ... @Override public void onNestedScroll (@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type, @NonNull int [] consumed) { onNestedScrollInternal(dyUnconsumed, type, consumed); } private void onNestedScrollInternal (int dyUnconsumed, int type, @Nullable int [] consumed) { final int oldScrollY = computeVerticalScrollOffset(); scrollBy(0 , dyUnconsumed); final int myConsumed = computeVerticalScrollOffset() - oldScrollY; if (consumed != null ) { consumed[1 ] += myConsumed; } final int myUnconsumed = dyUnconsumed - myConsumed; dispatchNestedScroll(0 , myConsumed, 0 , myUnconsumed, null , type, consumed); } @Override public void onNestedPreScroll (@NonNull View target, int dx, int dy, @NonNull int [] consumed, int type) { boolean isParentScroll = dispatchNestedPreScroll(dx, dy, consumed, null , type); if (!isParentScroll) { boolean needKeepScroll = dy > 0 && !isScrollEnd(); if (needKeepScroll) { scrollBy(0 , dy); consumed[1 ] = dy; } } } ... }
经过以上的处理, NestedParentRecyclerView
作为父嵌套布局已经能够处理子嵌套布局传递过来的事件,整体连贯性上就比较像一个整体的控件。
父布局的滑动传递处理 而对于父布局的滑动传递处理,我们就需要覆写 onTouchEvent
单独处理滑动传递了,我们的目的是让两个控件滑动连贯性更好,所以当父控件已经滑动到底部,需要让子嵌套布局滑动剩余的距离,其实代码也比较简单:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 public class NestedParentRecyclerView extends RecyclerView { ... @Override public boolean onTouchEvent (MotionEvent e) { if (e.getAction() == MotionEvent.ACTION_DOWN) { mLastY = e.getY(); mNestedYOffsets = 0 ; mVelocityY = 0 ; stopScroll(); } RecyclerView child = FindTarget.findChildScrollTarget(mContentView); boolean handle = false ; if (child != null ) { int deltaY = (int ) (mLastY - e.getY()); if (isScrollEnd() || (handle = !isChildScrollTop(child))) { child.scrollBy(0 , deltaY); } } mLastY = e.getY(); return handle || super .onTouchEvent(e); } }
当然,除了以上对滑动连贯性的处理还有对于 Fling
连贯性的处理,详细的部分见源码,这里就不在一一展开。
准确的状态回调 通常在触顶或者脱离的时候我们需要处理 UI 的变化,例如触顶时,显示返回 Top 的按钮等,这个时候就需要我们的控件能够提供准确的状态回调,所以这里我们定义一个接口 OnChildAttachStateListener
:
1 2 3 4 5 6 7 8 9 10 11 12 public interface OnChildAttachStateListener { void onChildAttachedToTop () ; void onChildDetachedFromTop () ; }
那我们接下来就要思考如何准确的判断这个状态了, RecyclerView
滑动时判断会不断的触发 onScrolled
的回调,这里监听状态的变化最合适不过。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 public class NestedParentRecyclerView extends RecyclerView { ... private boolean mIsChildAttachedToTop = false ; private boolean mIsChildDetachedFromTop = true ; private final ArrayList<OnChildAttachStateListener> mOnChildAttachStateListeners = new ArrayList <>(); ... public void addOnChildAttachStateListener (OnChildAttachStateListener listener) { mOnChildAttachStateListeners.add(listener); } ... @Override public void onScrolled (int dx, int dy) { ... boolean attached = dy > 0 && isScrollEnd(); if (attached && mIsChildDetachedFromTop) { mIsChildAttachedToTop = true ; mIsChildDetachedFromTop = false ; final int listenerCount = mOnChildAttachStateListeners.size(); for (int i = 0 ; i < listenerCount; i++) { OnChildAttachStateListener listener = mOnChildAttachStateListeners.get(i); listener.onChildAttachedToTop(); } } boolean detached = dy < 0 && !isScrollEnd(); if (detached && mIsChildAttachedToTop) { RecyclerView child = FindTarget.findChildScrollTarget(mContentView); if (child == null || isChildScrollTop(child)) { mIsChildDetachedFromTop = true ; mIsChildAttachedToTop = false ; final int listenerCount = mOnChildAttachStateListeners.size(); for (int i = 0 ; i < listenerCount; i++) { OnChildAttachStateListener listener = mOnChildAttachStateListeners.get(i); listener.onChildDetachedFromTop(); } } } } }
其他优化问题 临界状态父布局触摸事件分发后再次接管滑动时的跳动问题处理 为了保证控件的滑动连贯性,我们对滑动事件进行了分发,这个问题出现的原因在于父布局向子布局分发向上滑动的事件后,手指又向下滑动,这是由于事件又回到本身处理时,在 super.onTouchEvent(e)
中 MotionEvent
的坐标与手指出现了偏离,导致了跳帧的现象。所以,我们只需要对 MotionEvent
偏移量进行更新即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public class NestedParentRecyclerView extends RecyclerView { @Override public boolean onTouchEvent (MotionEvent e) { ... RecyclerView child = FindTarget.findChildScrollTarget(mContentView); boolean handle = false ; if (child != null ) { if (isScrollEnd() || (handle = !isChildScrollTop(child))) { int deltaY = (int ) (mLastY - e.getY()); child.scrollBy(0 , deltaY); if (handle) { mNestedYOffsets += deltaY; } } } mLastY = e.getY(); e.offsetLocation(0 , mNestedYOffsets); return handle || super .onTouchEvent(e); } }
加速度问题的优化 为了避免过快加载内容,一定程度上能减少 App 卡顿掉帧,我们同样对于加速度进行了限制
1 2 3 4 5 6 7 8 9 10 11 public final class FlingHelper { public FlingHelper (Context context, float factor) { ... mMaxFlingVelocity = (int ) (ViewConfiguration.get(context).getScaledMaximumFlingVelocity() * factor); } public int getFlingVelocity (int velocity) { return Math.max(-mMaxFlingVelocity, Math.min(velocity, mMaxFlingVelocity)); } }
子布局脱离顶部时的状态恢复 这个优化点是为了保证众多 Tab 中从顶部脱离时,所有的 Tab 都要回归起始位置,当然这部分需要在状态监听里去处理了。
1 2 3 4 5 6 fun resetToTop () { recyclerView?.let { val mLayoutManager = it.layoutManager as StaggeredGridLayoutManager mLayoutManager.scrollToPositionWithOffset(0 , 0 ) } }
其他已知问题
Fling 状态传递偶有略微延迟(跟递归查找滑动子 View 有关,如果要优化可从外部传入状态,省去查找时间,为了保持灵活性暂未处理)
总结 对于设计一个复用性较强的控件,需要考虑的问题比较多,有些比较细节的问题处理得当的话对于交互体验会有舒适感上升的感觉,虽说这对用户体验是一个隐性的提升,但是就是众多细节的优化积累使得整体的用户体验得到大幅提示。