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
下手解決呢?我想了想道理和滑動衝突類似,有外部攔截、內部攔截。將主動權交給父類,比較合理,處理起來更加靈活方便。