1.AppBarLayout嵌套滑动问题
前一阵将support库版本从25.4.0升级到了27.1.1后发现了这个问题。发现RecyclerView在滑动到底部后,会有近一秒的停滞,之后再去加载下一页数据。我们知道上拉加载实现方案基本都是监听滑动状态,当滑动停止时,再去加载下一页。代码基本如下:
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
onLoadNextPage();
}
}
我查看了几个有分页加载的页面,最终发现凡是使用了AppBarLayout
与 RecycleView
的地方会有这种问题。那么我就写了个简单的页面来验证一下我的猜测。
页面布局的代码很普通,类似下面这种。
<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<android.support.design.widget.AppBarLayout
app:elevation="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<View
android:background="@color/colorAccent"
app:layout_scrollFlags="scroll|enterAlways"
android:layout_width="match_parent"
android:layout_height="150dp"/>
<View
android:background="@color/colorPrimary"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="50dp"/>
</android.support.design.widget.AppBarLayout>
<android.support.v7.widget.RecyclerView
app:layout_behavior="@string/appbar_scrolling_view_behavior"
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</android.support.design.widget.CoordinatorLayout>
我首先使用25.4.0版本,我很快的滑动了一下来看下正常的结果:
0就是滑动停止。下来就是27.1.1版本,代码什么都没有变。
好吧,2.5秒,比我感觉的时间还长。。。那么这就说明虽然滑动停止了,但其实状态还是滑动中。当然这个时间不是固定的,完全取决于你的手速。你滑动的越快这个时间越长,这不禁让我想到了惯性滑动。下来先看看27.1.1的RecyclerView
是怎么样实现惯性滑动的。
惯性滑动,那么首先你要在滑动时,放手。也就是onTouchEvent
方法中的 ACTION_UP
:
@Override
public boolean onTouchEvent(MotionEvent e) {
...
switch (action) {
...
case MotionEvent.ACTION_UP: {
mVelocityTracker.addMovement(vtev);
// 计算一秒时间内移动了多少个像素, mMaxFlingVelocity为速度上限(测试机为22000)
mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
final float xvel = canScrollHorizontally
? -mVelocityTracker.getXVelocity(mScrollPointerId) : 0;
final float yvel = canScrollVertically
? -mVelocityTracker.getYVelocity(mScrollPointerId) : 0;
// fling方法判断是否有抛动,也就是惯性滑动,如果为true,则滑动状态就不会直接为SCROLL_STATE_IDLE。
if (!((xvel != 0 || yvel != 0) && fling((int) xvel, (int) yvel))) {
setScrollState(SCROLL_STATE_IDLE);
}
resetTouch();
}
break;
}
...
return true;
}
fling方法实现:
public boolean fling(int velocityX, int velocityY) {
...
if (!dispatchNestedPreFling(velocityX, velocityY)) {
final boolean canScroll = canScrollHorizontal || canScrollVertical;
dispatchNestedFling(velocityX, velocityY, canScroll);
if (canScroll) {
int nestedScrollAxis = ViewCompat.SCROLL_AXIS_NONE;
if (canScrollHorizontal) {
nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL;
}
if (canScrollVertical) {
nestedScrollAxis |= ViewCompat.SCROLL_AXIS_VERTICAL;
}
startNestedScroll(nestedScrollAxis, TYPE_NON_TOUCH);
velocityX = Math.max(-mMaxFlingVelocity, Math.min(velocityX, mMaxFlingVelocity));
velocityY = Math.max(-mMaxFlingVelocity, Math.min(velocityY, mMaxFlingVelocity));
// 核心在这里,将计算出的最大速度传入ViewFlinger来实现滚动
mViewFlinger.fling(velocityX, velocityY);
return true;
}
}
return false;
}
ViewFlinger代码很多,我精简一下:
static final Interpolator sQuinticInterpolator = new Interpolator() {
@Override
public float getInterpolation(float t) {
t -= 1.0f;
return t * t * t * t * t + 1.0f;
}
};
class ViewFlinger implements Runnable {
private OverScroller mScroller;
Interpolator mInterpolator = sQuinticInterpolator;
ViewFlinger() {
mScroller = new OverScroller(getContext(), sQuinticInterpolator);
}
@Override
public void run() {
final OverScroller scroller = mScroller;
// 判断是否完成了整个滑动
if (scroller.computeScrollOffset()) {
if (dispatchNestedPreScroll(dx, dy, scrollConsumed, null, TYPE_NON_TOUCH)) {}
if (!dispatchNestedScroll(hresult, vresult, overscrollX, overscrollY, null, TYPE_NON_TOUCH){}
if (scroller.isFinished()) {
// 惯性滑动结束,状态设为SCROLL_STATE_IDLE
setScrollState(SCROLL_STATE_IDLE);
stopNestedScroll(TYPE_NON_TOUCH);
}
}
}
// 惯性滑动,状态设为SCROLL_STATE_SETTLING
public void fling(int velocityX, int velocityY) {
setScrollState(SCROLL_STATE_SETTLING);
mScroller.fling(0, 0, velocityX, velocityY,
Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
}
...
}
sQuinticInterpolator
插值器是惯性滑动时间与距离的曲线,大致如下(速度先快后慢):
OverScroller
中的fling
方法,可以通过传入的速度值,计算出需要滑动的距离与时间。速度越大,对应的值就越大。 我的测试机最大速为22000,所以计算出的最长时间是 2544ms。这个也符合我们一开始打印出的信息。计算方法有兴趣的可以去看看源码一探究竟。
说了这么多,问题到底在哪?我对比了一下两版本的ViewFlinger
代码部分。
发现在25.4.0中并没有dispatchNestedPreScroll
、dispatchNestedScroll
、hasNestedScrollingParent
,stopNestedScroll
这部分代码。其实这部分的作用是为了解决一个滑动不同步的bug。如下图:(图传上来有点。。。详细可以参看:对design库中AppBarLayout嵌套滚动问题的修复)
简单的描述一下问题原因:RecyclerView
在 fling
过程中并没有通知AppBarLayout
,所以在fling
结束之后,AppBarLayout
不知道当前RecyclerView
的滑动到的位置,所以导致了这个滑动被打断的问题。其实相关的滑动卡顿问题,病因都是这里。
所以在26+开始修复了这个问题,也就是上面看到的变化。不过新问题也就诞生了,就是我一开始提到的停滞问题。问题出在了hasNestedScrollingParent
这个方法,判断是父View是否支持嵌套滑动
。显然在这个嵌套滑动场景始终是支持嵌套滑动,所以在判断中只有当滑动完成后才能在onScrollStateChanged
收到 SCROLL_STATE_IDLE
状态。
if (scroller.isFinished() || (!fullyConsumedAny && !true)) {}
--->
if (scroller.isFinished() || false) {}
这也就是在25.4.0版本和无AppBarLayout嵌套滑动的情况下,没有相关问题的原因。
2.解决方法
知道了原因,怎么去解决呢?
1. 升级版本
升级到28.0.0以上,以上问题一并解决。我看了一下当前最新的28.0.0-rc02版本,发现针对这个问题官方做了修改。我们对比一下:
27.1.1
28.0.0-rc02
可以看到添加了stopNestedScrollIfNeeded
方法,在向上滑动到顶和向下滑动到底时,停止view的滚动。
2. 思路借鉴
如果你是26 和 28 之间 ,可以参考官方解决的思路
public class FixAppBarLayoutBehavior extends AppBarLayout.Behavior {
public FixAppBarLayoutBehavior() {
super();
}
public FixAppBarLayoutBehavior(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, AppBarLayout child,
View target, int dx, int dy, int[] consumed, int type) {
super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type);
stopNestedScrollIfNeeded(dy, child, target, type);
}
@Override
public void onNestedScroll(CoordinatorLayout coordinatorLayout, AppBarLayout child, View target,
int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) {
super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type);
stopNestedScrollIfNeeded(dyUnconsumed, child, target, type);
}
private void stopNestedScrollIfNeeded(int dy, AppBarLayout child, View target, int type) {
if (type == ViewCompat.TYPE_NON_TOUCH) {
final int currOffset = getTopAndBottomOffset();
if ((dy < 0 && currOffset == 0) || (dy > 0 && currOffset == -child.getTotalScrollRange())) {
ViewCompat.stopNestedScroll(target, ViewCompat.TYPE_NON_TOUCH);
}
}
}
}
使用:
<android.support.design.widget.AppBarLayout
...
app:layout_behavior="yourPackage.FixAppBarLayoutBehavior">
或:
AppBarLayout mAppBarLayout = findViewById(R.id.app_bar);
((CoordinatorLayout.LayoutParams) mAppBarLayout.getLayoutParams()).setBehavior(new FixAppBarLayoutBehavior());
3.其他
-
如果你是26以下的版本,那么建议还是升级到26以上吧!毕竟官方已经解决了这个问题。为此升级了
NestedScrollingParent2
和NestedScrollingChild2
接口,添加了NestedScrollType
用来区分是手动触发的滑动还是非手动(惯性)触发的滑动。 -
为什么不从
RecyclerView
下手解决呢?我想了想道理和滑动冲突类似,有外部拦截、内部拦截。将主动权交给父类,比较合理,处理起来更加灵活方便。