Android - 有趣的嵌套滑动(Demo不定期更新)
写在前面
博客中的demo都上传到了github:NestedScrollTest,欢迎各位同学下载。
一、demo1-吸顶效果,及RecyclerView源码简析
吸顶效果是CoordinatorLayout中的一个基础功能,它的本质就是嵌套滑动,因此我们可以自己尝试去实现它。同时本章将会对RecyclerView源码中的嵌套滑动部分进行分析,深入理解嵌套滑动事件的分发与回调。
1.1 吸顶效果展示
效果展示.gif1.2 嵌套滑动API介绍
上面所展示的界面是一个线性布局,如图所示:
布局文件.png外部父LinearLayout包裹ImageView、TextView和RecyclerView,如果我们希望滑动RecyclerView的时候能先将ImageView滑动上去,我们该怎么做呢?
这就可以使用嵌套滑动,假设当前用户手指上滑RecyclerView,我们需要将RecyclerView的滑动事件先传递给父布局,如果父布局发现头部的ImageView还在显示,那么先消耗该事件并将整个父布局LinearLayout向上移动;如果图片已经上滑至消失,那么将滑动事件交给RecyclerView处理。
手指在RecyclerView上滑时如图所示,此时整个LinearLayout会向上滚动,直到TextView吸顶,再开始滑动RecyclerView。注意:RecyclerView的高度其实是界面的高度减去TexView的高度,比布局文件图中画的高度要高。
根据上面的流程不难发现,嵌套滑动由RecyclerView主动发起,父Layout被动接受,并且父Layout可以先于子View处理滑动事件。举个栗子,假设在一次事件中手指在RecyclerView向上滑动dy
,那么大体的流程如下:
① RecyclerView判断是否有父Layout能接受嵌套滑动,如果有,则将事件传递给父Layout。
② 父Layout收到该滑动事件,此时父Layout判断当前图片是否还在展示,如果还在展示,则父Layout向上滑动。但是父Layout不一定会在每次事件中都将dy
全部消耗掉(例如滑动到边缘的时候),这里通过一个值consumed
来保存父Layout消耗的值,并计算出剩余的值dy-consumed
③ 如果dy-consumed
不为0,则由RecyclerView自己处理。
④ 如果RecyclerView消耗完之后剩余的距离还不为0,则再交由父Layout处理。
想要实现嵌套滑动的子View需要实现NestedScrollingChild
接口,里面包含的方法如下。
public interface NestedScrollingChild {
// 设置当前子View是否支持嵌套滑动
void setNestedScrollingEnabled(boolean enabled);
// 当前子View是否支持嵌套滑动
boolean isNestedScrollingEnabled();
// 开始嵌套滑动,对应Parent的onStartNestedScroll
boolean startNestedScroll(@ScrollAxis int axes);
// 停止本次嵌套滑动,对应Parent的onStopNestedScroll
void stopNestedScroll();
// true表示这个子View有一个支持嵌套滑动的父View
boolean hasNestedScrollingParent();
// 通知父Layout即将开始滑动了,由父Layout先处理,对应父View的onNestedPreScroll方法
boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
@Nullable int[] offsetInWindow);
// 子View处理完事件再交给父Layout,对应父Layout的onNestedScroll方法
boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);
// 通知父View开始Fling了,对应Parent的onNestedFling方法
boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);
// 通知父View要开始fling了,对应Parent的onNestedPreFling方法
boolean dispatchNestedPreFling(float velocityX, float velocityY);
}
想要实现嵌套滑动的父Layout需要实现NestedScrollingParent
接口,里面包含的方法如下。
public interface NestedScrollingParent {
// 当子View开始滑动时调用,返回true表示接受嵌套滑动
boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes);
// 接受嵌套滑动后进行准备工作
void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes);
// 嵌套滑动结束时回调
void onStopNestedScroll(@NonNull View target);
// 父Layout先处理滑动距离dx或dy,consumed[0]保存父Layout在x轴上消耗的距离,consumed[1]保存父Layout在y轴上消耗的距离
void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed);
// 父Layout处理子View消耗完后剩余的距离
void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed);
// 当子View fling时,会触发这个回调,consumed代表速度是否被子View消耗
boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed);
// 当子View要开始fling时,会先询问父View是否要拦截本次fling,返回true表示要拦截,那么子View就不会惯性滑动了
boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY);
// 表示目前正在进行的嵌套滑动的方向,值有:
// ViewCompat.SCROLL_AXIS_HORIZONTAL
// ViewCompat.SCROLL_AXIS_VERTICAL、SCROLL_AXIS_NONE
@ScrollAxis
int getNestedScrollAxes();
}
可以看到这两个接口的方法名都很通俗易懂,子View主动触发嵌套滑动,父Layout被动接受触发回调。以RecyclerView为例,一次滑动事件的执行顺序如下所示:
-> child.ACTION_DOWN
-> child.startNestedScroll
-> parent.onStartNestedScroll (如果返回false,则流程终止)
-> parent.onNestedScrollAccepted
-> child.ACTION_MOVE
-> child.dispatchNestedPreScroll
-> parent.onNestedPreScroll
-> child.ACTION_UP
-> chid.stopNestedScroll
-> parent.onStopNestedScroll
-> child.fling
-> child.dispatchNestedPreFling
-> parent.onNestedPreFling
-> child.dispatchNestedFling
-> parent.onNestedFling
那么问题来了,子View主动开启嵌套滑动之后父Layout是怎么接收到的呢?
那就不得不提两个工具类NestedScrollingChildHelper
和NestedScrollingParentHelper
了,这两个工具类的作用就是连接父Layout和子View并完成一些基础工作。当子View调用startNestedScroll()
方法时,内部究竟做了什么呢?来看一下RecyclerView里的写法。
@Override
public boolean startNestedScroll(int axes) {
return getScrollingChildHelper().startNestedScroll(axes);
}
emmmm...直接调用了NestedScrollingChildHelper
的startNestedScroll(axes)
方法,这里的axes
表示方向,点进去看下。
public boolean startNestedScroll(@ScrollAxis int axes) {
return startNestedScroll(axes, TYPE_TOUCH);
}
这方法是个套娃,再点进去看下。
public boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type) {
if (hasNestedScrollingParent(type)) {
// Already in progress
return true;
}
if (isNestedScrollingEnabled()) {
ViewParent p = mView.getParent();
View child = mView;
while (p != null) {
if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)) {
setNestedScrollingParentForType(type, p);
ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes, type);
return true;
}
if (p instanceof View) {
child = (View) p;
}
p = p.getParent();
}
}
return false;
}
终于看到方法本体了,type
参数表示什么下面再谈,看一下方法做了什么。
mView
表示当前这个子View,方法里一层一层向上寻找mView的父Layout,直到ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)
返回true,也就是此时的父Layout实现了NestedScrollingParent
接口并接受此次嵌套滑动。看一下ViewParentCompat的onStartNestedScroll(...)
方法。
public static boolean onStartNestedScroll(ViewParent parent, View child, View target,
int nestedScrollAxes, int type) {
if (parent instanceof NestedScrollingParent2) {
return ((NestedScrollingParent2) parent).onStartNestedScroll(child, target,
nestedScrollAxes, type);
} else if (type == ViewCompat.TYPE_TOUCH) {
if (Build.VERSION.SDK_INT >= 21) {
// ......
} else if (parent instanceof NestedScrollingParent) {
return ((NestedScrollingParent) parent).onStartNestedScroll(child, target,
nestedScrollAxes);
}
}
return false;
}
从这里可以看出来,嵌套滑动的parent
不一定是child
的直接父Layout,它们中间可能隔了好几层。仔细看一下上面的方法,你会发现除了NestedScrollingParent
接口外还有NestedScrollingParent2
接口,那么相比于第1代,NestedScrollingParent2
升级了什么呢?
还记得上面提到的type
参数吗?第2代嵌套滑动接口通过该参数区分当前触发嵌套滑动的是SCROLL事件还是FLING事件,父Layout可以统一在onNestedPreScroll()
或onNestedScroll()
方法中进行处理。至于这是怎么做到的,我们接着往下看。
1.3 RecyclerView嵌套滑动源码简析(版本androidx-1.1.0)
现在先让我们来探究一下嵌套滑动的源头,上面提到,嵌套滑动是由子View发起,父Layout接收的,那么子View究竟在什么时候开启嵌套滑动呢?
RecyclerView在嵌套滑动中经常作为子View,这里以RecyclerView为例,来分析其处理嵌套滑动的逻辑,该逻辑主要在onTouchEvent()
方法中,来看一下精简后的代码。
@Override
public boolean onTouchEvent(MotionEvent e) {
// 省略部分代码......
switch (action) {
case MotionEvent.ACTION_DOWN: {
// 手指按下时尝试开启嵌套滑动, 寻找可以嵌套滑动的父Layout
startNestedScroll(nestedScrollAxis, TYPE_TOUCH);
} break;
case MotionEvent.ACTION_MOVE: {
final int x = (int) (e.getX(index) + 0.5f);
final int y = (int) (e.getY(index) + 0.5f);
int dx = mLastTouchX - x;
int dy = mLastTouchY - y;
if (mScrollState == SCROLL_STATE_DRAGGING) {
mReusableIntPair[0] = 0;
mReusableIntPair[1] = 0;
// 根据当前的滑动方向开始嵌套滑动, 由父Layout先scroll
if (dispatchNestedPreScroll(
canScrollHorizontally ? dx : 0, canScrollVertically ? dy : 0,
mReusableIntPair, mScrollOffset, TYPE_TOUCH)) {
// 减去父Layout消耗掉的距离
dx -= mReusableIntPair[0];
dy -= mReusableIntPair[1];
// 更新offsets, 不常用到
mNestedOffsets[0] += mScrollOffset[0];
mNestedOffsets[1] += mScrollOffset[1];
// 滑动已经初始化, 阻止父Layout拦截事件
getParent().requestDisallowInterceptTouchEvent(true);
}
mLastTouchX = x - mScrollOffset[0];
mLastTouchY = y - mScrollOffset[1];
// RecyclerView内部的scroll
if (scrollByInternal(
canScrollHorizontally ? dx : 0, canScrollVertically ? dy : 0, e)) {
getParent().requestDisallowInterceptTouchEvent(true);
}
}
} break;
case MotionEvent.ACTION_UP: {
// 手指抬起时计算速度, 开启fling
mVelocityTracker.addMovement(vtev);
mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
final float xvel = canScrollHorizontally
? -mVelocityTracker.getXVelocity(mScrollPointerId) : 0;
final float yvel = canScrollVertically
? -mVelocityTracker.getYVelocity(mScrollPointerId) : 0;
// 如果某个方向上的速度不为0就调用fling方法, 否则设置RecyclerView的状态为IDLE
if (!((xvel != 0 || yvel != 0) && fling((int) xvel, (int) yvel))) {
setScrollState(SCROLL_STATE_IDLE);
}
} break;
case MotionEvent.ACTION_CANCEL: {
cancelScroll();
} break;
}
return true;
}
将onTouchEvent()
中的代码进行精简,只留下处理嵌套滑动的部分,整体的逻辑就清晰了起来。这里主要是对scroll的处理,关于fling的待会再看。
① ACTION_DOWN
的时候RecyclerView调用startNestedScroll()
方法开始寻找可以进行嵌套滑动的父Layout,其实内部就是调用了NestedScrollingChildHelper
的startNestedScroll()
方法向上寻找最近的实现了NestedScrollingParent
接口的父Layout并将其记录。
② ACTION_MOVE
中执行了嵌套滑动关键的3步:一是由父Layout最先消耗滚动距离dx
或dy
;二是子View消耗剩余距离dx - mReusableIntPair[0]
或dy - mReusableIntPair[1]
;三是如果还有滚动距离未消耗完,则再交给父Layout消耗。
onTouchEvent()
中进行了第1步,第2和第3步的逻辑在scrollByInternal()
方法中:即首先让RecyclerView自身滚动,再通过dispatchNestedScroll()
将剩余的距离分发给父Layout,源码精简后如下。
boolean scrollByInternal(int x, int y, MotionEvent ev) {
int unconsumedX = 0; int unconsumedY = 0;
int consumedX = 0; int consumedY = 0;
if (mAdapter != null) {
mReusableIntPair[0] = 0;
mReusableIntPair[1] = 0;
// RecyclerView本身的滑动, 最终调用了LayoutManager的scrollHorizontallyBy()或scrollVerticallyBy()
scrollStep(x, y, mReusableIntPair);
consumedX = mReusableIntPair[0]; consumedY = mReusableIntPair[1];
unconsumedX = x - consumedX; unconsumedY = y - consumedY;
}
// ......
dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset,
TYPE_TOUCH, mReusableIntPair);
// ......
return consumedNestedScroll || consumedX != 0 || consumedY != 0;
}
③ ACTION_UP
的时候计算速度并调用fling()
方法。一般来说我们通过Scroller
来实现惯性滑动,在computeScroll()
方法中不断计算当前的坐标并移动。不了解Scroller的可以看参考的[1~3]。
但是实现了NestedScrollingChild2
接口的View有所不同,上面提到,这种View的Scroll和Fling事件都可以由dispatchNestedPreScroll()传递,由type参数区分事件类型,TYPE_TOUCH
为Scroll事件,TYPE_NON_TOUCH
为Fling事件。
是不是感觉怪怪的?按照方法的名字,dispatchNestedPreScroll()
方法应该只传递Scroll事件,而Fling事件由dispatchNestedPreFling()
方法比较合理。确实,对于只实现了NestedScrollingChild
接口的View就是这么处理的,但是用这种方式传递速率比较粗暴,在滑动到边界时可能存在卡顿现象。而实现了NestedScrollingChild2
接口的View用了新的方式传递Fling事件,来看一下RecyclerView作为子View是怎么传递Fling事件给父Layout的。
public boolean fling(int velocityX, int velocityY) {
// ......
if (!dispatchNestedPreFling(velocityX, velocityY)) {
final boolean canScroll = canScrollHorizontal || canScrollVertical;
dispatchNestedFling(velocityX, velocityY, canScroll);
if (mOnFlingListener != null && mOnFlingListener.onFling(velocityX, velocityY)) {
return true;
}
if (canScroll) {
// ......
startNestedScroll(nestedScrollAxis, TYPE_NON_TOUCH);
velocityX = Math.max(-mMaxFlingVelocity, Math.min(velocityX, mMaxFlingVelocity));
velocityY = Math.max(-mMaxFlingVelocity, Math.min(velocityY, mMaxFlingVelocity));
mViewFlinger.fling(velocityX, velocityY);
return true;
}
}
return false;
}
代码中虽然调用了dispatchNestedPreFling()
和dispatchNestedFling()
方法,但是对于实现了NestedScrollingParent2
的父Layout来说,对应的回调方法都不用实现。
我们重点来看下面的mViewFlinger.fling(velocityX, velocityY)
,这句代码实现了RecyclerView本身的惯性滑动,mViewFlinger
是RecyclerView内部类ViewFlinger的对象。该类精简后的源码如下:
class ViewFlinger implements Runnable {
@Override
public void run() {
// ......
final OverScroller scroller = mOverScroller;
if (scroller.computeScrollOffset()) {
// Nested Pre Scroll
mReusableIntPair[0] = 0;
mReusableIntPair[1] = 0;
if (dispatchNestedPreScroll(unconsumedX, unconsumedY, mReusableIntPair, null,
TYPE_NON_TOUCH)) {
unconsumedX -= mReusableIntPair[0];
unconsumedY -= mReusableIntPair[1];
}
// ......
// Nested Post Scroll
mReusableIntPair[0] = 0;
mReusableIntPair[1] = 0;
dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, null,
TYPE_NON_TOUCH, mReusableIntPair);
unconsumedX -= mReusableIntPair[0];
unconsumedY -= mReusableIntPair[1];
// ......
}
void postOnAnimation() {
if (mEatRunOnAnimationRequest) {
mReSchedulePostAnimationCallback = true;
} else {
internalPostOnAnimation();
}
}
private void internalPostOnAnimation() {
removeCallbacks(this);
ViewCompat.postOnAnimation(RecyclerView.this, this);
}
public void fling(int velocityX, int velocityY) {
setScrollState(SCROLL_STATE_SETTLING);
mLastFlingX = mLastFlingY = 0;
// Because you can't define a custom interpolator for flinging, we should make sure we
// reset ourselves back to the teh default interpolator in case a different call
// changed our interpolator.
if (mInterpolator != sQuinticInterpolator) {
mInterpolator = sQuinticInterpolator;
mOverScroller = new OverScroller(getContext(), sQuinticInterpolator);
}
mOverScroller.fling(0, 0, velocityX, velocityY,
Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
postOnAnimation();
}
public void smoothScrollBy(int dx, int dy, int duration,
@Nullable Interpolator interpolator) {
// ......
postOnAnimation();
}
public void stop() {
removeCallbacks(this);
mOverScroller.abortAnimation();
}
}
还记得我们平时通过Scroller是怎么实现惯性滑动的吗?由于View每次draw()
时会调用computeScroll()
,如果Scroller的滑动尚未结束,就在computeScroll()
中计算当前View应该所处的scroll位置并移动至该处,最后调用invalidate()
继续触发draw()
形成一个循环,直到惯性滑动结束。
RecyclerView实现惯性滑动和Fling事件传递的方式与之类似,都是使用Scroller计算惯性滑动的滑动距离。但是并没有重写computeScroll()
,那么循环调用的机制是在哪儿实现的呢?
这里就不得不提postOnAnimation()
的作用了,其内部调用了ViewCompat.postOnAnimation(View, Runnable)
,它会将当前这个Runnable对象,也就是mViewFlinger添加到执行队列中,等到下一帧到来的时候会执行该Runnnable
对象的run()
方法。
也就是说,每一帧刷新的时候都会通过Scroller计算这一帧应该滑动的距离dx
或dy
,然后开启嵌套滑动,只不过此时的type不是TYPE_TOUCH
,而是TYPE_NON_TOUCH
。
1.4 吸顶效果代码
上面说了这么多,可以发现RecyclerView本身为嵌套滑动做了很多事情,如果以RecyclerView作为嵌套滑动的子View,父Layout实现onNestedPreScroll()
就可以实现初步的嵌套滑动效果。
public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
if (dy > 0 && getScrollY() < mBannerHeight) {
// 手指向上, 内容向下, 可上滑的距离为 Banner 高度
consumed[1] = dy;
scrollBy(0, dy);
} else if (dy < 0 && !mRecyclerView.canScrollVertically(-1) && getScrollY() > 0) {
// 手指向下, 内容向上, 当 RecyclerView 无法上滑时可以开始显示 Banner
consumed[1] = dy;
scrollBy(0, dy);
}
}
当然只滑动RecyclerView是不够的,用户可能会滑动上方的图片,此时父Layout本身就需要实现惯性滑动,并且父Layout在滑动到图片消失时需要将速度传递给RecyclerView。而示例中的图片其实是一个Banner,由于Banner是可以左右滑动的,如果父Layout需要接收Banner区域上下滑动的事件,需要重写onInterceptTouchEvent()
方法将其拦截。
public boolean onInterceptTouchEvent(MotionEvent ev) {
int action = ev.getAction();
switch (action & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_DOWN:
mIsBeingDragged = false;
mLastMotionY = (int) ev.getRawY();
break;
case MotionEvent.ACTION_MOVE:
int y = (int) ev.getRawY();
int diff = Math.abs(mLastMotionY - y);
mLastMotionY = y;
boolean canRecyclerViewScroll = true;
if (mRecyclerView != null) {
canRecyclerViewScroll = mRecyclerView.canScrollVertically(-1);
}
if (diff > TOUCH_SLOP && !canRecyclerViewScroll
&& !isInNestedArea((int) ev.getRawX(), (int) ev.getRawY())) {
mIsBeingDragged = true;
ViewParent parent = getParent();
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(true);
}
}
break;
case MotionEvent.ACTION_UP:
mIsBeingDragged = false;
break;
}
return mIsBeingDragged;
}
剩下的我就不多说了,大家下载源码查看吧。
二、demo2-列表回弹
2.1 效果展示
列表回弹的效果如下所示,在列表滑动到边缘时可以超过RecyclerView的滑动边界,并在用户松手后回弹至原本的边界,这种行为也被称为OverScroll。
gif-列表回弹.gif2.2 实现原理
从现象上来看是用户在滑动到RecyclerView的边界之后还可以多滑动一段距离,并在用户松手时触发回弹,但实际上被OverScroll的不是RecyclerView本身,而是它的父Layout,我们不需要对RecyclerView做任何改变,只需要在它外面套一个支持OverScroll的BounceLayout即可。
布局如下所示,蓝色框表示包裹了RecyclerView的BounceLayout,黑色框表示BounceLayout的父Layout。当用户下拉RecyclerView到边界时,BounceLayout开始向下移动,产生OverScroll的效果。
RecyclerView本身实现了嵌套滑动,当它滑动到边界时,经常会产生未消耗的滑动距离,也就是dyUnconsumed
,并通过dispatchNestedScroll(...)
将这段距离分发给BounceLayout进行处理,BounceLayout即可通过scrollBy(...)
滑动自己来达到OverScroll的效果。用户松手时RecyclerView会调用stopNestedScroll()
,此时BounceLayout进行回弹即可。
上面说的是用户拖动RecyclerView时的情况,在惯性滑动下,如果fling到了边界,那么BounceLayout需要在RecyclerView fling到边界时计算当前的速率,根据速率向外弹出一段距离,最终在速度为0时回弹。
了解原理之后可以发现BounceLayout不仅仅可以用于实现RecyclerView的回弹,任何像RV一样实现了嵌套滑动子View功能的视图都可以实现该功能,因此这种实现方式具有很好的解耦性。下面来看具体实现。
2.3 具体实现
2.3.1 最大OverScroll距离
先来讨论一下如何限制OverScroll的滑动距离,定义当前OverScroll的距离为OverScrollDistance,最大可滑动距离为MaxOverScrollDistance。假设当前用户下拉y,则BounceLayout调用scrollBy(-y)
使其整体向下移动,当BounceLayout的Math.abs(scrollY)
== MaxOverScrollDistance时,不管用户怎么下拉,BounceLayout也不该再移动了。
上面描述的是OverScrollDistance=scrollY,也就是线性关系时的效果:用户下拉dy,BounceLayout移动dy。不过如果你使用过OverScroll的功能你就知道,你下拉的距离和BounceLayout移动的距离并不是线性关系:当你下拉y时,当前OverScrollDistance越大,BounceLayout的实际移动距离就越小,说得通俗一点:当前已经滑动的距离越大,你越难滑动它。
想要实现这样的效果并不难,我们为OverScrollDistance和scrollY定义一个插值器OverScrollerInterpolator
:
input = OverScrollDistance/MaxOverScrollDistance
output = scrollY/MaxOverScrollDistance,公式为:output = (1 - factor ^ (input * 2))
,当factor为0.6时,函数图如下所示。
该函数是先快后慢的效果,越临近最大值,用户越难拖动,这能给用户带来较好的体验。而且不管input多大,output始终<1,因此Math.abs(scrollY)
永远<MaxOverScrollDistance。根据该公式,我们可以定义如下插值器:
private class OverScrollerInterpolator implements Interpolator {
private float mFactor;
public OverScrollerInterpolator(float factor) {
mFactor = factor;
}
public float getInterpolationBack(float input) {
return (float) (Math.log(1 - input) / Math.log(mFactor) / 2);
}
@Override
public float getInterpolation(float input) {
return (float) (1 - Math.pow(mFactor, input * 2));
}
}
令x = OverScrollDistance/MaxOverScrollDistance
令y = scrollY/MaxOverScrollDistance
当已知x时,可以通过getInterpolation()
计算y,那么已知y时,该怎么计算x呢?我们来算一下:
y = 1 - factor ^ x* 2
=>x * 2 = log(factor, 1 - y)
=>x * 2 = log(2, 1 - y) / log(2, factor)
=>x = (log(2, 1 - y) / log(2, factor)) / 2
得到的结果就是getInterpolationBack()
里的算式。
至此,我们可以通过OverScrollerInterpolator中的两个方法建立scrollY和overScrollDistance之间的函数关系,demo中取factor为0.6,新建插值器如下:
private OverScrollerInterpolator mInterpolator = new OverScrollerInterpolator(0.6f);
对于这个插值器的用法,我们举个例子:
用户滑动RecyclerView到边界时,BounceLayout可以在onNestedScroll(...)
方法中处理dyUnconsumed,如下所示,我们将未消耗的滑动距离dyUnconsumed加到mOverScrollDistance并通过mInterpolator的getInterpolation()方法将其转化成scrollY,再调用scrollTo()
移动到最终的位置。
@Override
public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) {
if (mOrientation == ViewCompat.SCROLL_AXIS_VERTICAL && dyUnconsumed != 0) {
if (type == ViewCompat.TYPE_TOUCH) {
startOverScroll(dyUnconsumed);
} else {
// ......
}
}
}
private void startOverScroll(int dy) {
updateOverScrollDistance(mOverScrollDistance + dy);
}
private void updateOverScrollDistance(int distance) {
mOverScrollDistance = distance;
if (mOverScrollDistance < 0) {
scrollTo(0, (int) (-mMaxOverScrollDistance * mInterpolator.getInterpolation(
Math.abs((float) mOverScrollDistance / mOverScrollBorder))));
} else {
scrollTo(0, (int) (mMaxOverScrollDistance * mInterpolator.getInterpolation(
Math.abs((float) mOverScrollDistance / mOverScrollBorder))));
}
}
2.3.2 SpringBack
SpringBack指回弹,表现为将当前处于OverScroll状态下的BounceLayout恢复到初始状态,我们选择使用ValueAnimator实现该功能,当需要回弹时,调用startScrollBackAnimator ()
方法即可,相关代码如下。
private ValueAnimator mSpringBackAnimator;
private int mMaxOverScrollDistance = 200;
// mOverScrollBorder为mMaxOverScrollDistance的n倍
// 主要用于优化滑动体验,n越大,滑动阻力越大
private int mOverScrollBorder = mMaxOverScrollDistance * 3;
public void startScrollBackAnimator() {
if (mSpringBackAnimator != null) {
mSpringBackAnimator.cancel();
}
mSpringBackAnimator = ValueAnimator.ofInt(mOverScrollDistance, 0);
mSpringBackAnimator.setInterpolator(new DecelerateInterpolator());
mSpringBackAnimator.setDuration(250);
mSpringBackAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
updateOverScrollDistance((Integer) animation.getAnimatedValue());
}
});
mSpringBackAnimator.start();
}
private void updateOverScrollDistance(int distance) {
mOverScrollDistance = distance;
if (mOverScrollDistance < 0) {
scrollTo(0, (int) (-mMaxOverScrollDistance * mInterpolator.getInterpolation(
Math.abs((float) mOverScrollDistance / mOverScrollBorder))));
} else {
scrollTo(0, (int) (mMaxOverScrollDistance * mInterpolator.getInterpolation(
Math.abs((float) mOverScrollDistance / mOverScrollBorder))));
}
}
当回弹时,建立一个value从mOverScrollDistance到0的ValueAnimator,更新value时调用updateOverScrollDistance()
,通过mInterpolator将mOverScrollDistance转化成scrollY并移动至该位置。
可以看到实现回弹效果的逻辑比较简单,有难度的点在于我们应该在什么时候触发回弹。有如下3种场景:
第1种场景:用户拖动RecyclerView至OverScrollDistance>0后松手。
此时RecyclerViewACTION_UP
,调用stopNestedScroll()
,回调至BounceLayout中的onStopNestedScroll()
方法,在该方法中即可进行回弹。
@Override
public void onStopNestedScroll(@NonNull View target, int type) {
mNestedScrollingParentHelper.onStopNestedScroll(target);
if (mOverScrollDistance != 0) {
startScrollBackAnimator();
}
}
第2种场景:用户拖动RecyclerView至OverScrollDistance>0后,再触发fling后松手。
此时BounceLayout应该再顺着fling滑动很小一段距离后开始回弹,我们来看一下代码实现。
@Override
public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) {
if (mOrientation == ViewCompat.SCROLL_AXIS_VERTICAL && dyUnconsumed != 0) {
if (type == ViewCompat.TYPE_TOUCH) { // 用户在拖动RV
startOverScroll(dyUnconsumed);
} else { // RV在fling状态
if (mOverScrollDistance == 0) { // Bounce,下节说明
startOverScroll(dyUnconsumed);
mScroller.computeScrollOffset();
startBounceAnimator(mScroller.getCurrVelocity() * mLastSign);
} else { // 当前场景
// 顺着当前fling的方向再滑动一小段距离
startOverScroll(dyUnconsumed);
}
// 让RecyclerView主动停止嵌套滑动
ViewCompat.stopNestedScroll(target, type);
}
}
}
可以看到在当前场景下,BounceLayout会再移动一小段距离,随后主动调用ViewCompat.stopNestedScroll(target, type)
,此时会回调至BounceLayout的onStopNestedScroll(...)
开始回弹。
第3种场景:RecyclerView惯性滑动至边界,BounceLayout根据当前速率外弹出一段距离,直到速率为0时回弹,这种行为就被称为Bounce。上一段代码中,当滑动到边界且mOverScrollDistance == 0
时触发Bounce,具体的逻辑来看下一节。
2.3.3 Bounce
在上一节的代码中我们看到触发Bounce的代码如下:
startOverScroll(dyUnconsumed);
mScroller.computeScrollOffset();
startBounceAnimator(mScroller.getCurrVelocity() * mLastSign);
通过startBounceAnimator()
触发Bounce需要初速度和方向,我们可以在onNestedPreFling()
中得到RecyclerView惯性滑动时的初速度velocityY和方向mLastSign。
@Override
public boolean onNestedPreFling(View target, float velocityX, float velocityY) {
mLastSign = velocityY < 0 ? -1 : 1;
mScroller.forceFinished(true);
mScroller.fling(0, 0, 0, (int) velocityY, 0, Integer.MAX_VALUE, 0, Integer.MAX_VALUE);
return false;
}
但是RecyclerView惯性滑动的初速度很显然不等于触发Bounce时的初速度,因此我们通过mScroller.fling()
计算速度,在滑动至边界时调用mScroller.computeScrollOffset()
计算当前时间点的速度,再通过mScroller.getCurrVelocity()
即可得到触发Bounce时的初速度。
得到初速度和方向后我们来看看startBounceAnimator(...)做了什么。
private void startBounceAnimator(float velocity) {
if (mBounceRunnable != null) {
mBounceRunnable.cancel();
}
mBounceRunnable = new BounceAnimRunnable(velocity, mOverScrollDistance);
mBounceRunnable.start();
}
该方法启动了BounceAnimRunnable,来看一下它的代码。
在构造函数中,首先根据初速度mVelocity➗减速度mDeceleration计算duration。启动BounceAnimRunnable后每隔FRAME_TIME毫秒计算一次当前的mOverScrollDistance,当duration结束时通过startScrollBackAnimator ()
回弹。
private class BounceAnimRunnable implements Runnable {
private static final int FRAME_TIME = 14;
private final float mDeceleration;
private float mVelocity;
private int mStartY;
private int mRuntime = 0;
private int mDuration = 0;
private boolean cancel;
public void cancel() {
cancel = true;
removeCallbacks(this);
}
public BounceAnimRunnable(float velocity, int startY) {
// BOUNCE_BACK_DECELERATION为减速度
mDeceleration = mVelocity < 0 ? BOUNCE_BACK_DECELERATION : -BOUNCE_BACK_DECELERATION;
mVelocity = velocity;
mStartY = startY;
mDuration = (int) ((-mVelocity / mDeceleration) * 1000);
}
public void start() {
postDelayed(this, FRAME_TIME);
}
@Override
public void run() {
if (cancel) {
return;
}
mRuntime += FRAME_TIME;
float t = (float) mRuntime / 1000;
int distance = (int) (mStartY + mVelocity * t + 0.5 * mDeceleration * t * t);
updateOverScrollDistance(distance);
if (mRuntime < mDuration && Math.abs(distance) < mMaxOverScrollDistance * 2) {
postDelayed(this, FRAME_TIME);
} else {
startScrollBackAnimator();
}
}
}
至此,列表回弹的基本逻辑就讲完了,限于篇幅,有些细节并未全部列出。完整的源代码就不贴了,感兴趣的同学可以去文章开头的地址下载。
三、总结与推荐博客
读到这里是不是发现以前觉得很酷炫的功能实现起来也不是太难?其实嵌套滑动还能实现一些很实用的东西······
这里推荐一篇WebView和RecyclerView嵌套滑动实现详情页的博客:文章详情页,大家感兴趣可以研究一下。