AppBarLayout各版本問題探究及解決

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();
     }
}

我查看了幾個有分頁加載的頁面,最終發現凡是使用了AppBarLayoutRecycleView的地方會有這種問題。那麼我就寫了個簡單的頁面來驗證一下我的猜測。

頁面佈局的代碼很普通,類似下面這種。

<?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中並沒有dispatchNestedPreScrolldispatchNestedScrollhasNestedScrollingParentstopNestedScroll這部分代碼。其實這部分的作用是爲了解決一個滑動不同步的bug。如下圖:(圖傳上來有點。。。詳細可以參看:對design庫中AppBarLayout嵌套滾動問題的修復

這裏寫圖片描述

簡單的描述一下問題原因:RecyclerViewfling 過程中並沒有通知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.其他

  1. 如果你是26以下的版本,那麼建議還是升級到26以上吧!畢竟官方已經解決了這個問題。爲此升級了NestedScrollingParent2NestedScrollingChild2接口,添加了NestedScrollType用來區分是手動觸發的滑動還是非手動(慣性)觸發的滑動。

  2. 爲什麼不從RecyclerView下手解決呢?我想了想道理和滑動衝突類似,有外部攔截、內部攔截。將主動權交給父類,比較合理,處理起來更加靈活方便。

3.參考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章