写在前面
博客中的demo上传到了github NestedScrollingProject,欢迎各位同学下载&star。
一、吸顶效果&RecyclerView源码简析
吸顶效果是CoordinatorLayout中的一个基础功能,它的本质就是嵌套滑动,因此我们可以自己尝试去实现它。同时本章将会对RecyclerView源码中的嵌套滑动部分进行分析,深入理解嵌套滑动事件的分发与回调。
1.1 吸顶效果展示
1.2 嵌套滑动API介绍
上面所展示的界面是一个线性布局,如图所示:
外部父Layout包裹ImageView、TextView和RecyclerView,如果我们希望滑动RecyclerView的时候能先将ImageView滑动上去,随后使TextView吸顶,我们该怎么做呢?
这里就用到嵌套滑动,假设当前用户手指在RecyclerView向上划动,我们需要将RecyclerView的滑动事件先传递给父布局,如果父布局发现头部的ImageView还在显示,那么先消耗该事件并将整个父布局中的所有内容向上移动;如果图片已经上滑至不显示,那么将滑动事件交给RecyclerView处理。
手指在RecyclerView上划时如图所示,此时LinearLayout中的所有内容都会向上滚动,直到TextView吸顶,再开始滑动RecyclerView。注意:RecyclerView的高度其实是界面的高度减去TexView的高度,比布局文件图中画的高度要高。
根据上面的流程不难发现,嵌套滑动由RecyclerView主动发起,父View被动接受,并且父View可以先于子View处理滑动事件。举个栗子,假设在一次事件中手指在RecyclerView向上滑动dy
,那么大体的流程如下:
① RecyclerView判断是否有父View能接受嵌套滑动,如果有,则将事件传递给父View。
② 父View收到该滑动事件,此时父View判断当前图片是否还在展示,如果还在展示,则父View向上滑动。但是父View不一定会在每次事件中都将dy
全部消耗掉(例如滑动到边缘的时候),这里通过一个值consumed
来保存父Layout消耗的值。
③ 子View根据父View消耗的距离,计算出剩余的值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即将开始滑动了,由父View先处理,对应父View的onNestedPreScroll方法
boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
@Nullable int[] offsetInWindow);
// 子View处理完事件再交给父View,对应父View的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);
}
想要实现嵌套滑动的父View需要实现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);
// 父View先处理滑动距离dx或dy,consumed[0]保存父Layout在x轴上消耗的距离,consumed[1]保存父Layout在y轴上消耗的距离
void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed);
// 父View处理子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主动触发嵌套滑动,父View被动接受触发回调,每一个嵌套滑动事件都会经历一个"父-子-父"的分发流程。以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主动开启嵌套滑动之后父View是怎么接收到的呢?
那就不得不提两个工具类NestedScrollingChildHelper
和NestedScrollingParentHelper
了,这两个工具类的作用就是连接父View和子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的父View,直到ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)
返回true,也就是此时的父View实现了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
的直接父View,它们中间可能隔了好几层。仔细看一下上面的方法,你会发现除了NestedScrollingParent
接口外还有NestedScrollingParent2
接口,那么相比于第1代,NestedScrollingParent2
升级了什么呢?
还记得上面提到的type
参数吗?第2代嵌套滑动接口通过该参数区分当前触发嵌套滑动的是SCROLL事件还是FLING事件,父View可以统一在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()
方法开始寻找可以进行嵌套滑动的父View,其实内部就是调用了NestedScrollingChildHelper
的startNestedScroll()
方法向上寻找最近的实现了NestedScrollingParent
接口的父View并保存父View的引用。
② ACTION_MOVE
中执行了嵌套滑动关键的3步:一是由父View最先消耗滚动距离dx
或dy
;二是子View消耗剩余距离dx - mReusableIntPair[0]
或dy - mReusableIntPair[1]
;三是如果还有滚动距离未消耗完,则再交给父Layout消耗。
onTouchEvent()
中进行了第1步,第2和第3步的逻辑在scrollByInternal()
方法中:即首先让RecyclerView自身滚动,再通过dispatchNestedScroll()
将剩余的距离分发给父View,源码精简后如下。
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事件给父View的。
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
的父View来说,对应的回调方法都不实现也可以。
我们重点来看下面的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对象post到Choreographer的执行队列中,等到下一帧到来的时候会执行该Runnnable
对象的run()
方法。也就是说,每一帧刷新的时候都会通过Scroller计算这一帧应该滑动的距离dx
或dy
,然后开启嵌套滑动,只不过此时的type不是TYPE_TOUCH
,而是TYPE_NON_TOUCH
,对于60帧的手机,一秒会分发60次的嵌套滑动事件。
1.4 吸顶效果代码
上面说了这么多,可以发现RecyclerView本身为嵌套滑动做了很多事情,如果以RecyclerView作为嵌套滑动的子View,父View实现onNestedPreScroll()
就可以实现初步的嵌套滑动效果。想要实现吸顶效果的代码,我们自定义继承自LinearLayout的SimpleNestedLinearLayout作为父View并重写onNestedPreScroll()
方法如下。
override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
if (dy > 0 && scrollY < imageViewHeight) {
var actualDy = dy
if (scrollY + dy >= imageViewHeight) {
actualDy = imageViewHeight - scrollY
}
consumed[1] = actualDy
scrollBy(0, actualDy)
} else if (dy < 0 && recyclerView?.canScrollVertically(-1) == false && scrollY > 0) {
var actualDy = dy
if (scrollY + dy < 0) {
actualDy = -scrollY
}
consumed[1] = actualDy
scrollBy(0, actualDy)
}
}
这里还需要重写onMeasure()
方法,因为LinearLayout本身的measure流程不符合吸顶效果的需求:LinearLayout会依次measure子View,然后将剩余的高度作为之后子View的最大高度,如果这里不重写onMeasure()
方法,RecyclerView的高度就=(LinearLayout高度-ImageView高度-TextView高度),但是RecyclerView的高度应该=(父View的高度-TextView高度)。
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
setMeasuredDimension(
getDefaultSize(suggestedMinimumWidth, widthMeasureSpec),
getDefaultSize(suggestedMinimumHeight, heightMeasureSpec)
)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
for (index in 0 until childCount) {
val child = getChildAt(index) ?: continue
val layoutParams = child.layoutParams
if (layoutParams.height == LayoutParams.MATCH_PARENT) {
val rvHeight = measuredHeight - resources
.getDimensionPixelSize(R.dimen.simple_nested_title_height)
val childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(rvHeight, heightMode)
measureChild(child, widthMeasureSpec, childHeightMeasureSpec)
} else {
val childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(layoutParams.height, heightMode)
measureChild(child, widthMeasureSpec, childHeightMeasureSpec)
}
}
}
剩下的我就不多说了,大家下载源码查看吧。
二、WebView与RecyclerView混合布局
2.1 效果展示
WebView与RecyclerView的混合布局经常用于新闻APP的新闻页,其中WebView展示的新闻本身的网页,下方RecyclerView负责展示相关推荐、广告、评论等内容。
2.2 实现原理
可以发现这个父View也是一个类似LinearLayout的垂直布局,WebView和RecyclerView的高度都与父View相等。
当用户划动WebView时,WebView本身并没有移动,而是调用WebView.scrollBy(...)
移动WebView里面的内容。直到WebView的内容滑动到底部时,调用父View的scrollBy(...)
将WebView和RecyclerView向上移动。此时的布局如下所示,黑色框表示父View,是用户的可见区域。
WebView本身并不支持嵌套滑动,因此我们需要自定义继承自WebView的NestedScrollWebView,并重写它的onTouchEvent()
方法,将它的滑动事件向外分发,这部分逻辑参照RecyclerView即可,这里不再赘述,可以参考本文开头的链接。值得注意的是,WebView需要判断自己的内容是否已经滑动到底部,因此在NestedScrollWebView添加如下方法。
fun canWebViewScrollDown(): Boolean {
val range = computeVerticalScrollRange()
return ((scrollY + measuredHeight) < range)
}
在WebView与RecyclerView混合布局中,主要关注父View的逻辑。我们定义一个继承自ViewGroup的MixedLayout,首先重写它的onMeasure()
方法,把WebView和RecyclerView的高度都设置为父View的高度,再重写它的onLayout()
方法,按照垂直线性布局的方式去排布。
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
setMeasuredDimension(
getDefaultSize(suggestedMinimumWidth, widthMeasureSpec),
getDefaultSize(suggestedMinimumHeight, heightMeasureSpec)
)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
val height = measuredHeight
for (i in 0 until childCount) {
val child = getChildAt(i) ?: continue
// 其实WebView的高度不一定为父View的高度, 因为有些短新闻的高度不足一屏
// 这里为了方便, 假定新闻都是超过一屏的
if (child == webView) {
val childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(height, heightMode)
measureChild(webView, widthMeasureSpec, childHeightMeasureSpec)
} else if (child == recyclerView) {
val childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(height, heightMode)
measureChild(recyclerView, widthMeasureSpec, childHeightMeasureSpec)
} else {
measureChild(child, widthMeasureSpec, heightMeasureSpec)
}
}
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
for (i in 0 until childCount) {
val child = getChildAt(i)
if (child == webView) {
child.layout(0, 0, measuredWidth, measuredHeight)
} else if (child == recyclerView) {
child.layout(0, measuredHeight, measuredWidth, measuredHeight * 2)
}
}
layoutMaxScrollY = measuredHeight
}
下面来看MixedLayout处理嵌套滑动的逻辑,首先来看onNestedPreScroll()
方法。该方法使用actualDy表示实际移动的距离,用于处理边界滑动。主要逻辑为判断当前是哪个View在分发嵌套滑动事件,再根据滑动方向分别进行处理。
override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
var actualDy = dy
if (target == webView) {
if (dy > 0 && !webView.canWebViewScrollDown()) {
// WebView内容向下滑动
if (scrollY + actualDy > layoutMaxScrollY) {
actualDy = layoutMaxScrollY - scrollY
}
scrollBy(0, actualDy)
consumed[1] = actualDy
} else if (dy < 0 && scrollY > 0) {
// WebView内容向上滑动
if (scrollY + actualDy < 0) {
actualDy = -scrollY
}
scrollBy(0, actualDy)
consumed[1] = actualDy
}
} else if (target == recyclerView) {
if (dy > 0 && scrollY < layoutMaxScrollY) {
if (scrollY + actualDy > layoutMaxScrollY) {
actualDy = layoutMaxScrollY - scrollY
}
scrollBy(0, actualDy)
consumed[1] = actualDy
} else if (dy < 0) {
if (!recyclerView.canScrollVertically(-1)) {
if (scrollY + actualDy < 0) {
actualDy = -scrollY
webView.stopFling()
}
scrollBy(0, actualDy)
consumed[1] = actualDy
}
}
}
}
还有一种情况是在WebView触发一个速度较大的fling,这时WebView的内容会滑动到底部,随后MixedLayout也会滑动到底部,最后开始滑动RecyclerView。这种情况下的滑动事件会分发到onNestedScroll()
中进行处理,具体如下所示。
/**
* RecyclerView的Fling事件传递到了WebView 或 WebView的Fling事件传递到了RecyclerView
*/
override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int,
dxUnconsumed: Int, dyUnconsumed: Int, type: Int, consumed: IntArray) {
if (dyUnconsumed == 0 || nestedScrollAxes != ViewCompat.SCROLL_AXIS_VERTICAL) {
return
}
consumed[1] = dyUnconsumed
if (target == webView && dyUnconsumed > 0) {
if (scrollY >= layoutMaxScrollY) {
if (scrollY > layoutMaxScrollY) {
scrollTo(0, layoutMaxScrollY)
}
if (recyclerView.canScrollVertically(1)) {
recyclerView.scrollBy(0, dyUnconsumed)
} else {
webView.stopNestedScroll(type)
}
}
} else if (target == recyclerView && dyUnconsumed < 0) {
if (scrollY <= 0) {
if (scrollY < 0) {
scrollTo(0, 0)
}
if (webView.scrollY + dyUnconsumed > 0) {
webView.scrollBy(0, dyUnconsumed)
} else {
recyclerView.stopNestedScroll(ViewCompat.TYPE_NON_TOUCH)
webView.scrollTo(0, 0)
}
}
}
}
三、回弹布局
3.1 效果展示
列表回弹的效果如下所示,在列表滑动到边缘时可以超过RecyclerView的滑动边界,并在用户松手后回弹至原本的边界,这种行为也被称为OverScroll。
3.2 实现原理
从现象上来看是用户在滑动到RecyclerView的边界之后还可以多滑动一段距离,并在用户松手时触发回弹,但实际上实现OverScroll的不是RecyclerView本身,而是它的父View,我们不需要对RecyclerView做任何改变,只需要在它外面套一个支持OverScroll的BounceLayout即可。布局如下所示,黑色框为BounceLayout,蓝色框为RecyclerView。
RecyclerView本身实现了嵌套滑动,当它滑动到边界时,经常会产生未消耗的滑动距离,也就是dyUnconsumed
,并通过dispatchNestedScroll(...)
将这段距离分发给BounceLayout进行处理,BounceLayout即可通过scrollBy(...)
滑动自己来达到OverScroll的效果。用户松手时RecyclerView会调用stopNestedScroll()
,此时BounceLayout进行回弹即可。
上面说的是用户拖动RecyclerView时的情况,在惯性滑动下,如果fling到了边界,那么BounceLayout需要在RecyclerView fling到边界时计算当前的速率,根据速率向外弹出一段距离,最终在速度为0时回弹。
了解原理之后可以发现BounceLayout不仅仅可以用于实现RecyclerView的回弹,任何像RV一样实现了嵌套滑动子View功能的视图都可以实现该功能,因此这种实现方式具有很好的解耦性。下面来看具体实现。
3.3 具体实现
3.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。根据该公式,我们可以定义如下插值器:
inner class OverScrollerInterpolator(private var factor: Float) : Interpolator {
fun getInterpolationBack(input: Float) : Float {
return (ln(1 - input) / ln(factor) / 2)
}
override fun getInterpolation(input: Float): Float {
return (1 - factor.pow(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 val mInterpolator: OverScrollerInterpolator = OverScrollerInterpolator(0.6f)
对于这个插值器的用法,我们举个例子:用户滑动RecyclerView到边界时,BounceLayout可以在onNestedScroll(...)
方法中处理dyUnconsumed,如下所示,我们将未消耗的滑动距离dyUnconsumed加到mOverScrollDistance并通过mInterpolator的getInterpolation()方法将其转化成scrollY,再调用scrollTo()
移动到最终的位置。
override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int,
dxUnconsumed: Int, dyUnconsumed: Int, type: Int) {
if (mOrientation == ViewCompat.SCROLL_AXIS_VERTICAL && dyUnconsumed != 0) {
if (type == ViewCompat.TYPE_TOUCH) {
startOverScroll(dyUnconsumed)
} else {
......
}
}
}
private fun startOverScroll(dy: Int) {
updateOverScrollDistance(mOverScrollDistance + dy)
}
private fun updateOverScrollDistance(distance: Int) {
mOverScrollDistance = distance
if (mOverScrollDistance < 0) {
scrollTo(
0, (-mMaxOverScrollDistance * mInterpolator.getInterpolation(
abs(mOverScrollDistance.toFloat() / mOverScrollBorder)
)).toInt()
)
} else {
scrollTo(
0, (mMaxOverScrollDistance * mInterpolator.getInterpolation(
abs(mOverScrollDistance.toFloat() / mOverScrollBorder)
)).toInt()
)
}
}
3.3.2 SpringBack
SpringBack指回弹,表现为将当前处于OverScroll状态下的BounceLayout恢复到初始状态,我们选择使用ValueAnimator实现该功能,当需要回弹时,调用startScrollBackAnimator ()
方法即可,相关代码如下。
private var mSpringBackAnimator: ValueAnimator? = null
private val mMaxOverScrollDistance = 300
// mOverScrollBorder为mMaxOverScrollDistance的n倍
// 主要用于优化滑动体验,n越大,滑动阻力越大
private val mOverScrollBorder = mMaxOverScrollDistance * 3
fun startScrollBackAnimator() {
mSpringBackAnimator?.cancel()
mSpringBackAnimator = ValueAnimator.ofInt(mOverScrollDistance, 0)
mSpringBackAnimator?.interpolator = DecelerateInterpolator()
mSpringBackAnimator?.duration = SPRING_BACK_DURATION.toLong()
mSpringBackAnimator?.addUpdateListener { animation ->
updateOverScrollDistance(
animation.animatedValue as Int
)
}
mSpringBackAnimator?.start()
}
private fun updateOverScrollDistance(distance: Int) {
......
}
当回弹时,建立一个value从mOverScrollDistance到0的ValueAnimator,更新value时调用updateOverScrollDistance()
,通过mInterpolator将mOverScrollDistance转化成scrollY并移动至该位置。
可以看到实现回弹效果的逻辑比较简单,有难度的点在于我们应该在什么时候触发回弹。有如下3种场景:
第1种场景: 用户拖动RecyclerView至OverScrollDistance>0后松手。
此时RecyclerViewACTION_UP
,调用stopNestedScroll()
,回调至BounceLayout中的onStopNestedScroll()
方法,在该方法中即可进行回弹。
override fun onStopNestedScroll(target: View, type: Int) {
mNestedScrollingParentHelper.onStopNestedScroll(target)
if (mOverScrollDistance != 0) {
startScrollBackAnimator()
}
}
第2种场景: 用户拖动RecyclerView至OverScrollDistance>0后,再触发fling后松手。
此时BounceLayout应该再顺着fling滑动很小一段距离后开始回弹,我们来看一下代码实现。
override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int,
dxUnconsumed: Int, dyUnconsumed: Int, type: Int) {
if (mOrientation == ViewCompat.SCROLL_AXIS_VERTICAL && dyUnconsumed != 0) {
if (type == ViewCompat.TYPE_TOUCH) { // 用户在拖动RV
startOverScroll(dyUnconsumed)
} else { // RV在fling状态
if (mOverScrollDistance == 0) { // Bounce,下节说明
mScroller.computeScrollOffset()
startBounceAnimator(mScroller.currVelocity * mLastSign)
} else { // 当前场景
startOverScroll(dyUnconsumed) // 顺着当前fling的方向再滑动一小段距离
}
// 让RecyclerView主动停止嵌套滑动
ViewCompat.stopNestedScroll(target, type)
}
}
}
可以看到在当前场景下,BounceLayout会再移动一小段距离,随后主动调用ViewCompat.stopNestedScroll(target, type)
,此时会回调至BounceLayout的onStopNestedScroll(...)
开始回弹。
第3种场景: RecyclerView惯性滑动至边界,BounceLayout根据当前速率外弹出一段距离,直到速率为0时回弹,这种行为就被称为Bounce。上一段代码中,当滑动到边界且mOverScrollDistance == 0
时触发Bounce,具体的逻辑来看下一节。
3.3.3 Bounce
在上一节的代码中我们看到触发Bounce的代码如下:
mScroller.computeScrollOffset()
startBounceAnimator(mScroller.currVelocity * mLastSign)
通过startBounceAnimator()
触发Bounce需要初速度和方向,我们可以在onNestedPreFling()
中得到RecyclerView惯性滑动时的初速度velocityY和方向mLastSign。
override fun onNestedPreFling(target: View, velocityX: Float, velocityY: Float): Boolean {
mLastSign = if (velocityY < 0) -1 else 1
mScroller.forceFinished(true)
mScroller.fling(0, 0, 0, velocityY.toInt(), 0,
Int.MAX_VALUE, 0, Int.MAX_VALUE)
return false
}
但是RecyclerView惯性滑动的初速度很显然不等于触发Bounce时的初速度,因此我们通过mScroller.fling()
计算速度,在滑动至边界时调用mScroller.computeScrollOffset()
计算当前时间点的速度,再通过mScroller.getCurrVelocity()
即可得到触发Bounce时的初速度。
得到初速度和方向后我们来看看startBounceAnimator(...)做了什么。
private fun startBounceAnimator(velocity: Float) {
mBounceRunnable?.cancel()
mBounceRunnable = BounceAnimRunnable(velocity, mOverScrollDistance)
mBounceRunnable?.start()
}
该方法启动了BounceAnimRunnable,来看一下它的代码。在构造函数中,首先根据初速度mVelocity÷减速度mDeceleration计算duration。启动BounceAnimRunnable后每隔FRAME_TIME毫秒计算一次当前的mOverScrollDistance,当duration结束时通过startScrollBackAnimator ()
回弹。
inner class BounceAnimRunnable : Runnable {
/**
* 两帧之间的间隔
*/
private var frameInternalMillis = 10
private var mDeceleration = 0
private var mVelocity = 0f
private var mStartY = 0
private var mRuntime = 0
private var mDuration = 0
private var mHasCanceled = false
constructor(velocity: Float, startY: Int) {
mDeceleration = if (velocity < 0) {
BOUNCE_BACK_DECELERATION
} else {-BOUNCE_BACK_DECELERATION
}
mVelocity = velocity
mStartY = startY
mDuration = ((-mVelocity / mDeceleration) * 1000).toInt()
}
fun start() {
postDelayed(this, frameInternalMillis.toLong())
}
fun cancel() {
mHasCanceled = true
removeCallbacks(this)
}
override fun run() {
if (mHasCanceled) {
return
}
mRuntime += frameInternalMillis
val t = mRuntime.toFloat() / 1000
val distance = (mStartY + mVelocity * t + 0.5 * mDeceleration * t * t).toInt()
updateOverScrollDistance(distance)
if (mRuntime < mDuration && abs(distance) < mMaxOverScrollDistance * 2) {
removeCallbacks(this)
postDelayed(this, frameInternalMillis.toLong())
} else {
startScrollBackAnimator()
}
}
}
至此,列表回弹的基本逻辑就讲完了,限于篇幅,有些细节并未全部列出。完整的源代码就不贴了,感兴趣的同学可以去文章开头的地址下载。