淺析NestedScrolling嵌套滑動機制之基礎篇

嵌套系列導航

本文已在公衆號鴻洋原創發佈。未經許可,不得以任何形式轉載!java

概述

NestedScrolling是Android5.0推出的嵌套滑動機制,可以讓父View和子View在滑動時相互協調配合能夠實現連貫的嵌套滑動,它基於原有的觸摸事件分發機制上爲ViewGroup和View增長處理滑動的方法提供調用,後來爲了向前兼容到Android1.6,在Revision 22.1.0的android.support.v4兼容包中提供了從View、ViewGroup抽取出NestedScrollingChild、NestedScrollingParent兩個接口和NestedScrollingChildHelper、NestedScrollingParentHelper兩個輔助類來幫助控件實現嵌套滑動,CoordinatorLayout即是基於這個機制實現各類神奇的滑動效果。android

處理同向滑動事件衝突

若是兩個可滑動的容器嵌套,外部View攔截了內部View的滑動,可能形成滑動衝突,一般基於傳統的觸摸事件分發機制來解決:

1.外部攔截法

public class MyScrollView extends ScrollView {
    private int mLastY = 0;
    
    //此處省略構造方法
    ...

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean intercepted = false;
        int y = (int) ev.getY();
        switch (ev.getActionMasked()) {
            case MotionEvent.ACTION_DOWN:
                intercepted = false;
                //調用ScrollView的onInterceptTouchEvent()初始化mActivePointerId
                super.onInterceptTouchEvent(ev);
                break;
            case MotionEvent.ACTION_MOVE:
                int detY = y - mLastY;
                //這裏要找到子ScrollView
                View contentView = findViewById(R.id.my_scroll_inner);
                if (contentView == null) {
                    return true;
                }
                //判斷子ScrollView是否滑動到頂部或者頂部
                boolean isChildScrolledTop = detY > 0 && !contentView.canScrollVertically(-1);
                boolean isChildScrolledBottom = detY < 0 && !contentView.canScrollVertically(1);
                if (isChildScrolledTop || isChildScrolledBottom) {
                    intercepted = true;
                } else {
                    intercepted = false;
                }
                break;
            case MotionEvent.ACTION_UP:
                intercepted = false;
                break;
        }
        mLastY = y;
        return intercepted;
    }
}
複製代碼

2.內部攔截法

public class MyScrollView extends ScrollView {
    private int mLastY = 0;
    
    //此處省略構造方法
    ...

   @Override
   public boolean dispatchTouchEvent(MotionEvent ev) {
       int y = (int) ev.getY();
       switch (ev.getActionMasked()) {
           case MotionEvent.ACTION_DOWN:
               getParent().requestDisallowInterceptTouchEvent(true);
               break;
           case MotionEvent.ACTION_MOVE:
               int detY = y - mLastY;
               boolean isScrolledTop = detY > 0 && !canScrollVertically(-1);
               boolean isScrolledBottom = detY < 0 && !canScrollVertically(1);
               //根據自身是否滑動到頂部或者頂部來判斷讓父View攔截觸摸事件
               if (isScrolledTop || isScrolledBottom) {
                   getParent().requestDisallowInterceptTouchEvent(false);
               }
               break;
       }
       mLastY = y;
       return super.dispatchTouchEvent(ev);
   }

   @Override
   public boolean onInterceptTouchEvent(MotionEvent ev) {
       if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) {
           super.onInterceptTouchEvent(ev);
           return false;
       }
       return true;
   }
}
複製代碼

3.小結

上面經過兩種經典的解決方案,在內部View能夠滑動時,外部View不攔截,當內部View滑動到底部或者頂部時,讓外部消費滑動事件進行滑動。通常而言,外部攔截法和內部攔截法不能公用。 不然內部容器可能並無機會調用 requestDisallowInterceptTouchEvent方法。在傳統的觸摸事件分發中,若是不手動調用分發事件或者去發出事件,外部View最早拿到觸摸事件,一旦它被外部View攔截消費了,內部View沒法接收到觸摸事件,同理,內部View消費了觸摸事件,外部View也沒有機會響應觸摸事件。 而接下介紹的NestedScrolling機制,在一次滑動事件中外部View和內部View都有機會對滑動進行響應,這樣處理滑動衝突就相對方便許多。數組

NestedScrolling機制原理

NestedScrollingChild(下圖簡稱nc)、NestedScrollingParent(下圖簡稱np)邏輯上分別對應以前內部View和外部View的角色,之因此稱之爲邏輯上是由於View能夠同時扮演NestedScrollingChild和NestedScrollingParent,下面圖片就是NestedScrolling的交互流程。 app

NestedScrolling交互 流程示意圖.png

接下來詳細說明一下上圖的交互流程:ide

  • 1.當NestedScrollingChild接收到觸摸事件MotionEvent.ACTION_DOWN時,它會往外層佈局遍歷尋找最近的NestedScrollingParent請求配合處理滑動。因此它們之間層級不必定是直接上下級關係。函數

  • 2.若是NestedScrollingParent不配合NestedScrollingChild處理滑動就沒有接下來的流程,不然就會配合處理滑動。工具

  • 3.NestedScrollingChild要滑動以前,它先拿到MotionEvent.ACTION_MOVE滑動的dx,dy並將一個有兩個元素的數組(分別表明NestedScrollingParent要滑動的水平和垂直方向的距離)做爲輸出參數一同傳給NestedScrollingParent。佈局

  • 4.NestedScrollingParent拿到上面【3】NestedScrollingChild傳來的數據,將要消費的水平和垂直方向的距離傳進數組,這樣NestedScrollingChild就知道NestedScrollingParent要消費滑動值是多少了。post

  • 5.NestedScrollingChild將【2】裏拿到的dx、dy減去【4】NestedScrollingParent消費滑動值,計算出剩餘的滑動值;若是剩餘的滑動值爲0說明NestedScrollingParent所有消費了NestedScrollingChild不該進行滑動;不然NestedScrollingChild根據剩餘的滑動值進行消費,而後將本身消費了多少、還剩餘多少彙報傳遞給NestedScrollingParent。動畫

  • 6.若是NestedScrollingChild在滑動期間發生的慣性滑動,它會將velocityX,velocityY傳給NestedScrollingParent,並詢問NestedScrollingParent是否要所有消費。

  • 7.NestedScrollingParent收到【6】NestedScrollingChild傳來的數據,告訴NestedScrollingChild是否所有消費慣性滑動。

  • 8.若是在【7】NestedScrollingParent沒有所有消費慣性滑動,NestedScrollingChild會將velocityX,velocityY、自身是否須要消費所有慣性滑動傳給NestedScrollingParent,並詢問NestedScrollingParent是否要所有消費。

  • 9.NestedScrollingParent收到【8】NestedScrollingChild傳來的數據,告訴NestedScrollingChild是否所有消費慣性滑動。

  • 10.NestedScrollingChild中止滑動時通知NestedScrollingParent。

PS:

  • A.上面的【消費】是指可滑動View調用自身的滑動方法進行滑動來消耗滑動數值,好比scrollBy()、scrollTo()、fling()、offsetLeftAndRight()、offsetTopAndBottom()、layout()、Scroller、LayoutParams等,View實現NestedScrollingParent、NestedScrollingChild只僅僅是能將數值進行傳遞,須要配合Touch事件根據需求去調用NestScrolling的接口和輔助類,而自己不支持滑動的View即便有嵌套滑動的相關方法也不能進行嵌套滑動。
  • B.在【1】中外層實現NestedScrollingParent的View不應攔截NestedScrollingChild的MotionEvent.ACTION_DOWN;在【2】中若是NestedScrollingParent配合處理滑動時,實現NestedScrollingChild的View應該經過getParent().requestDisallowInterceptTouchEvent(true)往上遞歸關閉外層View的事件攔截機制,這樣確保【3】中NestedScrollingChild先拿到MotionEvent.ACTION_MOVE。具體能夠參考RecyclerView和NestedScrollView源碼的觸摸事件處理。

類與接口

前面提到Android 5.0及以上的View、ViewGroup自身分別就有NestedScrollingChild和NestedScrollingParent的方法,而方法邏輯就是對應的NestedScrollingChildHelper和NestedScrollingParentHelper的具體方法實現,因此本小節不講解View、ViewGroup的NestedScrolling機制相關內容,請自行查看源碼。

1.NestedScrollingChild

public interface NestedScrollingChild {
    /** * @param enabled 開啓或關閉嵌套滑動 */
    void setNestedScrollingEnabled(boolean enabled);

    /** * @return 返回是否開啓嵌套滑動 */    
    boolean isNestedScrollingEnabled();

    /** * 沿着指定的方向開始滑動嵌套滑動 * @param axes 滑動方向 * @return 返回是否找到NestedScrollingParent配合滑動 */
    boolean startNestedScroll(@ScrollAxis int axes);

    /** * 中止嵌套滑動 */
    void stopNestedScroll();

    /** * @return 返回是否有配合滑動NestedScrollingParent */
    boolean hasNestedScrollingParent();

    /** * 滑動完成後,將已經消費、剩餘的滑動值分發給NestedScrollingParent * @param dxConsumed 水平方向消費的距離 * @param dyConsumed 垂直方向消費的距離 * @param dxUnconsumed 水平方向剩餘的距離 * @param dyUnconsumed 垂直方向剩餘的距離 * @param offsetInWindow 含有View今後方法調用以前到調用完成後的屏幕座標偏移量, * 可使用這個偏移量來調整預期的輸入座標(即上面4個消費、剩餘的距離)跟蹤,此參數可空。 * @return 返回該事件是否被成功分發 */
    boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);

    /** * 在滑動以前,將滑動值分發給NestedScrollingParent * @param dx 水平方向消費的距離 * @param dy 垂直方向消費的距離 * @param consumed 輸出座標數組,consumed[0]爲NestedScrollingParent消耗的水平距離、 * consumed[1]爲NestedScrollingParent消耗的垂直距離,此參數可空。 * @param offsetInWindow 同上dispatchNestedScroll * @return 返回NestedScrollingParent是否消費部分或所有滑動值 */
    boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow);

    /** * 將慣性滑動的速度和NestedScrollingChild自身是否須要消費此慣性滑動分發給NestedScrollingParent * @param velocityX 水平方向的速度 * @param velocityY 垂直方向的速度 * @param consumed NestedScrollingChild自身是否須要消費此慣性滑動 * @return 返回NestedScrollingParent是否消費所有慣性滑動 */
    boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);

    /** * 在慣性滑動以前,將慣性滑動值分發給NestedScrollingParent * @param velocityX 水平方向的速度 * @param velocityY 垂直方向的速度 * @return 返回NestedScrollingParent是否消費所有慣性滑動 */
    boolean dispatchNestedPreFling(float velocityX, float velocityY);
}
複製代碼

2.NestedScrollingParent

public interface NestedScrollingParent {
    /** * 對NestedScrollingChild發起嵌套滑動做出應答 * @param child 佈局中包含下面target的直接父View * @param target 發起嵌套滑動的NestedScrollingChild的View * @param axes 滑動方向 * @return 返回NestedScrollingParent是否配合處理嵌套滑動 */
    boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes);

    /** * NestedScrollingParent配合處理嵌套滑動回調此方法 * @param child 同上 * @param target 同上 * @param axes 同上 */
    void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes);
   
    /** * 嵌套滑動結束 * @param target 同上 */
    void onStopNestedScroll(@NonNull View target);

    /** * NestedScrollingChild滑動完成後將滑動值分發給NestedScrollingParent回調此方法 * @param target 同上 * @param dxConsumed 水平方向消費的距離 * @param dyConsumed 垂直方向消費的距離 * @param dxUnconsumed 水平方向剩餘的距離 * @param dyUnconsumed 垂直方向剩餘的距離 */
    void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed);

    /** * NestedScrollingChild滑動完以前將滑動值分發給NestedScrollingParent回調此方法 * @param target 同上 * @param dx 水平方向的距離 * @param dy 水平方向的距離 * @param consumed 返回NestedScrollingParent是否消費部分或所有滑動值 */
    void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed);

    /** * NestedScrollingChild在慣性滑動以前,將慣性滑動的速度和NestedScrollingChild自身是否須要消費此慣性滑動分 * 發給NestedScrollingParent回調此方法 * @param target 同上 * @param velocityX 水平方向的速度 * @param velocityY 垂直方向的速度 * @param consumed NestedScrollingChild自身是否須要消費此慣性滑動 * @return 返回NestedScrollingParent是否消費所有慣性滑動 */
    boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed);
    
    /** * NestedScrollingChild在慣性滑動以前,將慣性滑動的速度分發給NestedScrollingParent * @param target 同上 * @param velocityX 同上 * @param velocityY 同上 * @return 返回NestedScrollingParent是否消費所有慣性滑動 */
    boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY);

    /** * @return 返回當前嵌套滑動的方向 */
    int getNestedScrollAxes();
}
複製代碼

3.方法調用流程圖:

4.NestedScrollingChildHepler

NestedScrollingChildHepler對NestedScrollingChild的接口方法作了代理,您能夠結合實際狀況藉助它來實現,如:

public class MyScrollView extends View implements NestedScrollingChild{
    ...
    @Override
    public boolean startNestedScroll(int axes) {
        return mChildHelper.startNestedScroll(axes);
    }
}
複製代碼

這裏只分析關鍵的方法,具體代碼請參考源碼。

4.1 startNestedScroll()

public boolean startNestedScroll(int axes) {
        //判斷是否找到配合處理滑動的NestedScrollingParent
        if (hasNestedScrollingParent()) {
            // Already in progress
            return true;
        }
        if (isNestedScrollingEnabled()) {//判斷是否開啓滑動嵌套
            ViewParent p = mView.getParent();
            View child = mView;
            //循環往上層尋找配合處理滑動的NestedScrollingParent
            while (p != null) {
                //ViewParentCompat.onStartNestedScroll()會判斷p是否實現NestedScrollingParent,
                //如果則將p轉爲NestedScrollingParent類型調用onStartNestedScroll()方法
                if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes)) {
                    mNestedScrollingParent = p;
                    //經過ViewParentCompat調用p的onNestedScrollAccepted()方法
                    ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes);
                    return true;
                }
                if (p instanceof View) {
                    child = (View) p;
                }
                p = p.getParent();
            }
        }
        return false;
    }
複製代碼

這個方法首先會判斷是否已經找到了配合處理滑動的NestedScrollingParent、若找到了則返回true,不然會判斷是否開啓嵌套滑動,若開啓了則經過構造函數注入的View來循環往上層尋找配合處理滑動的NestedScrollingParent,循環條件是經過ViewParentCompat這個兼容類判斷p是否實現NestedScrollingParent,如果則將p轉爲NestedScrollingParent類型調用onStartNestedScroll()方法若是返回true則證實找配合處理滑動的NestedScrollingParent,因此接下來一樣藉助ViewParentCompat調用NestedScrollingParent的onNestedScrollAccepted()。

4.2 dispatchNestedPreScroll()

public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
        if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {//若是開啓嵌套滑動並找到配合處理滑動的NestedScrollingParent
            if (dx != 0 || dy != 0) {//若是有水平或垂直方向滑動
                int startX = 0;
                int startY = 0;
                if (offsetInWindow != null) {
                    //先記錄View當前的在Window上的x、y座標值
                    mView.getLocationInWindow(offsetInWindow);
                    startX = offsetInWindow[0];
                    startY = offsetInWindow[1];
                }
                //初始化輸出數組consumed
                if (consumed == null) {
                    if (mTempNestedScrollConsumed == null) {
                        mTempNestedScrollConsumed = new int[2];
                    }
                    consumed = mTempNestedScrollConsumed;
                }
                consumed[0] = 0;
                consumed[1] = 0;
                //經過ViewParentCompat調用NestedScrollingParent的onNestedPreScroll()方法
                ViewParentCompat.onNestedPreScroll(mNestedScrollingParent, mView, dx, dy, consumed);

                if (offsetInWindow != null) {
                    //將以前記錄好的x、y座標減去調用NestedScrollingParent的onNestedPreScroll()後View的x、y座標,計算得出偏移量並賦值進offsetInWindow數組
                    mView.getLocationInWindow(offsetInWindow);
                    offsetInWindow[0] -= startX;
                    offsetInWindow[1] -= startY;
                }
                //consumed數組的兩個元素的值有其中一個不爲0則說明NestedScrollingParent消耗的部分或者所有滑動值
                return consumed[0] != 0 || consumed[1] != 0;
            } else if (offsetInWindow != null) {
                offsetInWindow[0] = 0;
                offsetInWindow[1] = 0;
            }
        }
        return false;
    }
複製代碼

這個方法首先會判斷是否開啓嵌套滑動並找到配合處理滑動的NestedScrollingParent,若符合這兩個條件則會根據參數dx、dy滑動值判斷是否有水平或垂直方向滑動,如有滑動調用mView.getLocationInWindow()將View當前的在Window上的x、y座標值賦值進offsetInWindow數組並以startX、startY記錄,接下來初始化輸出數組consumed、並經過ViewParentCompat調用NestedScrollingParent的onNestedPreScroll(),再次調用mView.getLocationInWindow()將調用NestedScrollingParent的onNestedPreScroll()後的View在Window上的x、y座標值賦值進offsetInWindow數組並與以前記錄好的startX、startY相減計算得出偏移量,接着以consumed數組的兩個元素的值有其中一個不爲0做爲boolean值返回,若條件爲true說明NestedScrollingParent消耗的部分或者所有滑動值。

4.3 dispatchNestedScroll()

public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow) {
        if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {//若是開啓嵌套滑動並找到配合處理滑動的NestedScrollingParent
            if (dxConsumed != 0 || dyConsumed != 0 || dxUnconsumed != 0 || dyUnconsumed != 0) {//若是有消費滑動值或者有剩餘滑動值
                int startX = 0;
                int startY = 0;
                if (offsetInWindow != null) {
                    //先記錄View當前的在Window上的x、y座標值
                    mView.getLocationInWindow(offsetInWindow);
                    startX = offsetInWindow[0];
                    startY = offsetInWindow[1];
                }
                //經過ViewParentCompat調用NestedScrollingParent的onNestedScroll()方法
                ViewParentCompat.onNestedScroll(mNestedScrollingParent, mView, dxConsumed,
                        dyConsumed, dxUnconsumed, dyUnconsumed);

                if (offsetInWindow != null) {
                    //將以前記錄好的x、y座標減去調用NestedScrollingParent的onNestedScroll()後View的x、y座標,計算得出偏移量並賦值進offsetInWindow數組
                    mView.getLocationInWindow(offsetInWindow);
                    offsetInWindow[0] -= startX;
                    offsetInWindow[1] -= startY;
                }
                //返回true代表NestedScrollingChild的dispatchNestedScroll事件成功分發NestedScrollingParent
                return true;
            } else if (offsetInWindow != null) {
                // No motion, no dispatch. Keep offsetInWindow up to date.
                offsetInWindow[0] = 0;
                offsetInWindow[1] = 0;
            }
        }
        return false;
    }
複製代碼

這個方法與上面的dispatchNestedPreScroll()方法十分相似,這裏就不細說了。

4.3 dispatchNestedPreFling()、dispatchNestedFling()

public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
        if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
            //經過ViewParentCompat調用NestedScrollingParent的onNestedPreFling()方法,返回值表示NestedScrollingParent是否消費所有慣性滑動
            return ViewParentCompat.onNestedPreFling(mNestedScrollingParent, mView, velocityX,
                    velocityY);
        }
        return false;
    }

    public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
        if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
            //經過ViewParentCompat調用NestedScrollingParent的onNestedFling()方法,返回值表示NestedScrollingParent是否消費所有慣性滑動
            return ViewParentCompat.onNestedFling(mNestedScrollingParent, mView, velocityX,
                    velocityY, consumed);
        }
        return false;
    }
複製代碼

這兩方法都是經過ViewParentCompat調用NestedScrollingParent對應的fling方法來返回NestedScrollingParent是否消費所有慣性滑動。

4.NestedScrollingParentHelper

public class NestedScrollingParentHelper {
    private final ViewGroup mViewGroup;
    private int mNestedScrollAxes;

    public NestedScrollingParentHelper(ViewGroup viewGroup) {
        mViewGroup = viewGroup;
    }

    public void onNestedScrollAccepted(View child, View target, int axes) {
        mNestedScrollAxes = axes;
    }

    public int getNestedScrollAxes() {
        return mNestedScrollAxes;
    }

    public void onStopNestedScroll(View target) {
        mNestedScrollAxes = 0;
    }
}
複製代碼

NestedScrollingParentHelper只提供對應NestedScrollingParent相關的onNestedScrollAccepted()和onStopNestedScroll()方法,主要維護mNestedScrollAxes管理滑動的方向字段。

NestedScrolling機制的改進

慣性滑動不連續問題

在使用以前NestedScrolling機制的 系統控件 嵌套滑動,當內部View快速滑動產生慣性滑動到邊緣就中止,而不將慣性滑動傳遞給外部View繼續消費慣性滑動,就會出現下圖兩個NestedScrollView嵌套滑動這種 慣性滑動不連續 的狀況:

慣性滑動不連續

這裏以com.android.support:appcompat-v7:22.1.0的NestedScrollView源碼做爲分析問題例子:

@Override
    public boolean onTouchEvent(MotionEvent ev) {
        ...
        switch (actionMasked) {
            ...
            case MotionEvent.ACTION_UP:
                if (mIsBeingDragged) {
                    final VelocityTracker velocityTracker = mVelocityTracker;
                    velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
                    int initialVelocity = (int) VelocityTrackerCompat.getYVelocity(velocityTracker,
                            mActivePointerId);

                    if ((Math.abs(initialVelocity) > mMinimumVelocity)) {
                        //分發慣性滑動
                        flingWithNestedDispatch(-initialVelocity);
                    }

                    mActivePointerId = INVALID_POINTER;
                    endDrag();
                }
                break;
        }
        ...
    }

    private void flingWithNestedDispatch(int velocityY) {
        final int scrollY = getScrollY();
        final boolean canFling = (scrollY > 0 || velocityY > 0) &&
                (scrollY < getScrollRange() || velocityY < 0);
        if (!dispatchNestedPreFling(0, velocityY)) {//將慣性滑動分發給NestedScrollingParent,讓它先對慣性滑動進行處理
            dispatchNestedFling(0, velocityY, canFling);//若慣性滑動沒被消費,再次將慣性滑動分發給NestedScrollingParent,並帶上自身是否能消費fling的canFling參數讓NestedScrollingParent根據狀況處理決定canFling是true仍是false
            if (canFling) {
                //執行fling()消費慣性滑動
                fling(velocityY);
            }
        }
    }

    public void fling(int velocityY) {
        if (getChildCount() > 0) {
            int height = getHeight() - getPaddingBottom() - getPaddingTop();
            int bottom = getChildAt(0).getHeight();
            //初始化fling的參數
            mScroller.fling(getScrollX(), getScrollY(), 0, velocityY, 0, 0, 0,
                    Math.max(0, bottom - height), 0, height/2);
            //重繪會觸發computeScroll()進行滾動
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

    @Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset()) {
            int oldX = getScrollX();
            int oldY = getScrollY();
            int x = mScroller.getCurrX();
            int y = mScroller.getCurrY();

            if (oldX != x || oldY != y) {
                final int range = getScrollRange();
                final int overscrollMode = ViewCompat.getOverScrollMode(this);
                final boolean canOverscroll = overscrollMode == ViewCompat.OVER_SCROLL_ALWAYS ||
                        (overscrollMode == ViewCompat.OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0);

                overScrollByCompat(x - oldX, y - oldY, oldX, oldY, 0, range,
                        0, 0, false);

                if (canOverscroll) {
                    ensureGlows();
                    if (y <= 0 && oldY > 0) {
                        mEdgeGlowTop.onAbsorb((int) mScroller.getCurrVelocity());
                    } else if (y >= range && oldY < range) {
                        mEdgeGlowBottom.onAbsorb((int) mScroller.getCurrVelocity());
                    }
                }
            }
        }
    }
複製代碼

上面代碼執行以下:

  • 1.當快速滑動並擡起手指時onTouchEvent()方法會命中MotionEvent.ACTION_UP,執行關鍵flingWithNestedDispatch()方法將垂直方向的慣性滑動值分發。

  • 2.flingWithNestedDispatch()方法先調用dispatchNestedPreFling()將慣性滑動分發給NestedScrollingParent,若NestedScrollingParent沒有消費則調用dispatchNestedFling()並帶上自身是否能消費fling的canFling參數讓NestedScrollingParent能夠根據狀況處理決定canFling是true仍是false,若canFling值爲true,執行fling()方法。

  • 3.fling()方法執行mScroller.fling()初始化fling參數,而後 調用ViewCompat.postInvalidateOnAnimation()重繪觸發computeScroll()方法進行滾動。

  • 4.computeScroll()方法裏面只讓自身進行fling,並無在自身fling到邊緣時將慣性滑動分發給NestedScrollingParent

NestedScrollingChild二、NestedScrollingParent2

在Revision 26.1.0的android.support.v4兼容包添加了NestedScrollingChild二、NestedScrollingParent2兩個接口:

public interface NestedScrollingChild2 extends NestedScrollingChild {

    boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type);

    void stopNestedScroll(@NestedScrollType int type);

    boolean hasNestedScrollingParent(@NestedScrollType int type);

    boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow, @NestedScrollType int type);
            
    boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow, @NestedScrollType int type);
}

public interface NestedScrollingParent2 extends NestedScrollingParent {

    boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes, @NestedScrollType int type);

    void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes, @NestedScrollType int type);

    void onStopNestedScroll(@NonNull View target, @NestedScrollType int type);

    void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @NestedScrollType int type);

    void onNestedPreScroll(@NonNull View target, int dx, int dy, @Nullable int[] consumed, @NestedScrollType int type);
}
複製代碼

它們分別繼承NestedScrollingChild、NestedScrollingParent,都爲滑動相關的方法添加了int類型參數type,這個參數有兩個值:TYPE_TOUCH值爲0表示滑動由用戶手勢滑動屏幕觸發;TYPE_NON_TOUCH值爲1表示滑動不是由用戶手勢滑動屏幕觸發;同時View、ViewGroup、NestedScrollingChildHelper、NestedScrollingParentHelper一樣根據參數type作了調整。

前面說到由於系統控件在computeScroll()方法裏面只讓自身進行fling,並無在自身fling到邊緣時將慣性滑動分發給NestedScrollingParent致使慣性滑動不連貫,因此這裏以com.android.support:appcompat-v7:26.1.0的NestedScrollView源碼看看如何使用改進後的NestedScrolling機制:

public void fling(int velocityY) {
            if (getChildCount() > 0) {
                //發起滑動嵌套,注意ViewCompat.TYPE_NON_TOUCH參數表示不是由用戶手勢滑動屏幕觸發
                startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL,ViewCompat.TYPE_NON_TOUCH);
                mScroller.fling(getScrollX(), getScrollY(), 
                    0, velocityY, 0, 0,Integer.MIN_VALUE, Integer.MAX_VALUE,0, 0);
                mLastScrollerY = getScrollY();
                ViewCompat.postInvalidateOnAnimation(this);
            }
        }

    @Override
    public void computeScroll() {
            if (mScroller.computeScrollOffset()) {
                final int x = mScroller.getCurrX();
                final int y = mScroller.getCurrY();

                int dy = y - mLastScrollerY;

                // Dispatch up to parent(將滑動值分發給NestedScrollingParent2)
                if (dispatchNestedPreScroll(0, dy, mScrollConsumed, null,ViewCompat.TYPE_NON_TOUCH)) {
                    //計算NestedScrollingParent2消費後剩餘的滑動值
                    dy -= mScrollConsumed[1];
                }

                if (dy != 0) {//若滑動值沒有NestedScrollingParent2所有消費掉,則自身進行消費滾動
                    final int range = getScrollRange();
                    final int oldScrollY = getScrollY();

                    overScrollByCompat(0, dy, getScrollX(), oldScrollY, 0, range, 0, 0, false);

                    final int scrolledDeltaY = getScrollY() - oldScrollY;
                    final int unconsumedY = dy - scrolledDeltaY;

                    if (!dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, null,ViewCompat.TYPE_NON_TOUCH)) {//若滾動值沒有分發成功給NestedScrollingParent2,則本身用EdgeEffect消費
                        final int mode = getOverScrollMode();
                        final boolean canOverscroll = mode == OVER_SCROLL_ALWAYS
                                || (mode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0);
                        if (canOverscroll) {
                            ensureGlows();
                            if (y <= 0 && oldScrollY > 0) {
                                mEdgeGlowTop.onAbsorb((int) mScroller.getCurrVelocity());
                            } else if (y >= range && oldScrollY < range) {
                                mEdgeGlowBottom.onAbsorb((int) mScroller.getCurrVelocity());
                            }
                        }
                    }
                }

                // Finally update the scroll positions and post an invalidation
                mLastScrollerY = y;
                ViewCompat.postInvalidateOnAnimation(this);
            } else {
                // We can't scroll any more, so stop any indirect scrolling
                if (hasNestedScrollingParent(ViewCompat.TYPE_NON_TOUCH)) {
                    stopNestedScroll(ViewCompat.TYPE_NON_TOUCH);
                }
                // and reset the scroller y
                mLastScrollerY = 0;
            }
        }
複製代碼

代碼分析以下:

  • 1.與以前的NestedScrollView相比,fling()方法裏面用到了NestedScrollingChild2的startNestedScroll方法發起滑動嵌套。

  • 2.computeScroll()方法首先調用dispatchNestedPreScroll()將滑動值分發給NestedScrollingParent2,若滑動值沒有被NestedScrollingParent2所有消費掉,則自身進行消費滾動,而後再調用dispatchNestedScroll()將自身消費、剩餘的滑動值分發給NestedScrollingParent2,若分發失敗則用EdgeEffect(這個用來滑動到頂部或者底部時會出現一個波浪形的邊緣效果)消費掉,當mScroller滾動完成後調用stopNestedScroll()方法結束嵌套滑動。

OverScroller未終止滾動動畫

Scroller未關閉

在使用以前NestedScrolling機制的 系統控件 嵌套滑動,當子、父View都在頂部時,首先快速下滑子View並擡起手指製造慣性滑動,而後立刻滑動父View,這時就會出現上圖的兩個NestedScrollView嵌套滑動現象,你手指往上滑視圖內容往下滾一段距離,視圖內容馬上就會自動往上回滾。

這裏仍是以com.android.support:appcompat-v7:26.1.0的NestedScrollView源碼做爲分析問題例子:

private void flingWithNestedDispatch(int velocityY) {
        final int scrollY = getScrollY();
        final boolean canFling = (scrollY > 0 || velocityY > 0)
                && (scrollY < getScrollRange() || velocityY < 0);
        if (!dispatchNestedPreFling(0, velocityY)) {
            dispatchNestedFling(0, velocityY, canFling);
            fling(velocityY);
        }
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        switch (actionMasked) {
            ...
            case MotionEvent.ACTION_DOWN: {
                ...
                //中止mScroller滾動
                 if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                }
            }
            ...
        }
        ...
    }
複製代碼

代碼執行以下:

  • 1.這裏分析場景是兩個NestedScrollView嵌套滑動,因此dispatchNestedPreFling()返回值爲false,子View執行就會fling()方法,前面分析過fling()方法調用mScroller.fling()觸發computeScroll()進行實際的滾動。

  • 2.在子View調用computeScroll()方法期間,若是此時子View不命中MotionEvent.ACTION_DOWN,mScroller是不會中止滾動,只能等待它完成,因而就子View就不停調用dispatchNestedPreScroll()和dispatchNestedScroll()分發滑動值給父View,就出現了上圖的場景。

NestedScrollingChild三、NestedScrollingParent3

在androidx.core 1.1.0-alpha01開始引入NestedScrollingChild三、NestedScrollingParent3,它們在androidx.core:core:1.1.0正式被添加:

public interface NestedScrollingChild3 extends NestedScrollingChild2 {
        void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow, @ViewCompat.NestedScrollType int type, @NonNull int[] consumed);
    }

    public interface NestedScrollingParent3 extends NestedScrollingParent2 {
        void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @ViewCompat.NestedScrollType int type, @NonNull int[] consumed);

    }
複製代碼

NestedScrollingChild3繼承NestedScrollingChild2重載dispatchNestedScroll()方法,從返回值類型boolean改成void類型,添加了一個int數組consumed參數做爲輸出參數記錄NestedScrollingParent3消費的滑動值,同理,NestedScrollingParent3繼承NestedScrollingParent2重載onNestedScroll添加了一個int數組consumed參數來對應NestedScrollingChild3,NestedScrollingChildHepler、NestedScrollingParentHelper一樣根據變化作了適配調整。

下面是androidx.appcompat:appcompat:1.1.0的NestedScrollView源碼看看如何使用改進後的NestedScrolling機制:

@Override
    public void computeScroll() {
        if (mScroller.isFinished()) {
            return;
        }

        mScroller.computeScrollOffset();
        final int y = mScroller.getCurrY();
        int unconsumed = y - mLastScrollerY;
        mLastScrollerY = y;

        // Nested Scrolling Pre Pass(分發滑動值給NestedScrollingParent3)
        mScrollConsumed[1] = 0;
        dispatchNestedPreScroll(0, unconsumed, mScrollConsumed, null,
                ViewCompat.TYPE_NON_TOUCH);
        //計算剩餘的滑動值
        unconsumed -= mScrollConsumed[1];

        final int range = getScrollRange();

        if (unconsumed != 0) {
            // Internal Scroll(自身滾動消費滑動值)
            final int oldScrollY = getScrollY();
            overScrollByCompat(0, unconsumed, getScrollX(), oldScrollY, 0, range, 0, 0, false);
            final int scrolledByMe = getScrollY() - oldScrollY;
            //計算剩餘的滑動值
            unconsumed -= scrolledByMe;

            // Nested Scrolling Post Pass(分發滑動值給NestedScrollingParent3)
            mScrollConsumed[1] = 0;
            dispatchNestedScroll(0, scrolledByMe, 0, unconsumed, mScrollOffset,
                    ViewCompat.TYPE_NON_TOUCH, mScrollConsumed);
            //計算剩餘的滑動值
            unconsumed -= mScrollConsumed[1];
        }

        if (unconsumed != 0) {
            //EdgeEffect消費剩餘滑動值
            final int mode = getOverScrollMode();
            final boolean canOverscroll = mode == OVER_SCROLL_ALWAYS
                    || (mode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0);
            if (canOverscroll) {
                ensureGlows();
                if (unconsumed < 0) {
                    if (mEdgeGlowTop.isFinished()) {
                        mEdgeGlowTop.onAbsorb((int) mScroller.getCurrVelocity());
                    }
                } else {
                    if (mEdgeGlowBottom.isFinished()) {
                        mEdgeGlowBottom.onAbsorb((int) mScroller.getCurrVelocity());
                    }
                }
            }
            //中止mScroller滾動動畫並結束滑動嵌套
            abortAnimatedScroll();
        }

        if (!mScroller.isFinished()) {
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

    private void abortAnimatedScroll() {
        mScroller.abortAnimation();
        stopNestedScroll(ViewCompat.TYPE_NON_TOUCH);
    }
複製代碼

代碼分析以下:

  • 1.首先調用dispatchNestedPreScroll()將滑動值分發給NestedScrollingParent3並附帶mScrollConsumed數組做爲輸出參數記錄其具體消費多少滑動值,變量unconsumed表示剩餘的滑動值,在調用dispatchNestedPreScroll()後,unconsumed減去以前的mScrollConsumed數組的元素從新賦值;

  • 2.此時unconsumed值不爲0,說明NestedScrollingParent3沒有消費掉所有滑動值,則自身掉用overScrollByCompat()進行滾動消費滑動值,unconsumed減去記錄本次消費的滑動值scrolledByMe從新賦值;而後調用dispatchNestedScroll()相似於【1】將滑動值分發給NestedScrollingParent3的操做而後計算unconsumed;

  • 3.若unconsumed值還不爲0,說明滑動值沒有徹底消費掉,此時實現NestedScrollingParent三、NestedScrollingChild3對應的父View、子View在同一方向都滑動到了邊緣盡頭,此時自身用EdgeEffect消費剩餘滑動值並調用abortAnimatedScroll()來 中止mScroller滾動並結束嵌套滑動

NestedScrolling機制的使用

若是你最低支持android版本是5.0及其以上,你可使用View、ViewGroup自己對應的NestedScrollingChild、NestedScrollingParent接口;若是你使用AndroidX那麼你就須要使用NestedScrollingChild三、NestedScrollingParent3;若是你兼容Android5.0以前版本請使用NestedScrollingChild二、NestedScrollingParent2。下面的例子是僞代碼,由於下面的自定義View沒有實現相似Scroller的方式來消費滑動值,所以它運行也不能實現嵌套滑動進行滑動,只是提供給你們處理觸摸事件調用NestedScrolling機制的思路。

使用NestedScrollingParent2

若是要兼容NestedScrollingParent則覆寫其接口便可,能夠藉助NestedScrollingParentHelper結合需求做方法代理,你能夠根據具體業務在onStartNestedScroll()選擇在嵌套滑動的方向、在onNestedPreScroll()要不要消費NestedScrollingChild2的滑動值等等。

使用NestedScrollingChild2

若是要兼容NestedScrollingChild則覆寫其接口便可,能夠藉助NestedScrollingChildHelper結合需求做方法代理。

public class NSChildView extends FrameLayout implements NestedScrollingChild2 {
    private int mLastMotionY;
    private final int[] mScrollOffset = new int[2];
    private final int[] mScrollConsumed = new int[2];
    ...

  @Override
    public boolean onTouchEvent(MotionEvent ev) {
        switch (ev.getActionMasked()) {
            case MotionEvent.ACTION_DOWN: {
                //關閉外層觸摸事件攔截,確保能拿到MotionEvent.ACTION_MOVE
                final ViewParent parent = getParent();
                if (parent != null) {
                    parent.requestDisallowInterceptTouchEvent(true);
                }
                mLastMotionY = (int) ev.getY();
                //開始嵌套滑動
                startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH);
                break;
            }
            case MotionEvent.ACTION_MOVE:
                final int y = (int) ev.getY();
                int deltaY = mLastMotionY - y;
                //開始滑動以前,分發滑動值給NestedScrollingParent2
                if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset,
                        ViewCompat.TYPE_TOUCH)) {
                    deltaY -= mScrollConsumed[1];
                }
                //模擬Scroller消費剩餘滑動值
                final int oldY = getScrollY();
                scrollBy(0,deltaY);

                //計算自身消費的滑動值,彙報給NestedScrollingParent2
                final int scrolledDeltaY = getScrollY() - oldY;
                final int unconsumedY = deltaY - scrolledDeltaY;
                if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset,
                        ViewCompat.TYPE_TOUCH)) {
                    mLastMotionY -= mScrollOffset[1];
                }else {
                    //能夠選擇EdgeEffectCompat消費剩餘的滑動值
                }
                break;
            case MotionEvent.ACTION_UP:
                //能夠用VelocityTracker計算velocityY
                int velocityY=0;
                //根據需求判斷是否能Fling
                boolean canFling=true;
                if (!dispatchNestedPreFling(0, velocityY)) {
                    dispatchNestedFling(0, velocityY, canFling);
                    //模擬執行慣性滑動,若是你但願慣性滑動也能傳遞給NestedScrollingParent2,對於每次消費滑動距離,
                    // 與MOVE事件中處理滑動同樣,按照dispatchNestedPreScroll() -> 本身消費 -> dispatchNestedScroll() -> 本身消費的順序進行消費滑動值
                    fling(velocityY);
                }
                //中止嵌套滑動
                stopNestedScroll(ViewCompat.TYPE_TOUCH);
                break;
            case MotionEvent.ACTION_CANCEL:
                //中止嵌套滑動
                stopNestedScroll(ViewCompat.TYPE_TOUCH);
                break;
        }
        return true;
    }
複製代碼

同時使用NestedScrollingChild二、NestedScrollingParent2

這種狀況一般是ViewGroup支持佈局嵌套如:

<android.support.v4.widget.NestedScrollView android:tag="我是爺爺">
    <android.support.v4.widget.NestedScrollView android:tag="我是爸爸">
        <android.support.v4.widget.NestedScrollView android:tag="我是兒子">
        </android.support.v4.widget.NestedScrollView >
    </android.support.v4.widget.NestedScrollView >
</android.support.v4.widget.NestedScrollView >    
複製代碼

舉個例子:當兒子NestedScrollView調用stopNestedScroll()中止嵌套滑動時,就會回調爸爸NestedScrollView的onStopNestedScroll(),這時爸爸NestedScrollView也該中止嵌套滑動而且爺爺NestedScrollView也應該收到爸爸NestedScrollView的中止嵌套滑動,故在NestedScrollingParent2的onStopNestedScroll()應該這麼寫達到嵌套滑動事件往外分發的效果:

//NestedScrollingParent2
    @Override
    public void onStopNestedScroll(@NonNull View target, int type) {
        mParentHelper.onStopNestedScroll(target, type);
        //往外分發
        stopNestedScroll(type);
    }
    //NestedScrollingChild2
    @Override
    public void stopNestedScroll(int type) {
        mChildHelper.stopNestedScroll(type);
    }
複製代碼

常見交互效果

除了下面的餓了麼商家詳情頁外其餘的效果能夠用 CoordinatorLayout+AppBarLayout+CollapsingToolbarLayout 實現摺疊懸停效果,其實它們底層Behavior也是基於NestedScrolling機制來實現的,而像餓了麼這樣的效果若是使用自定View的話要麼用NestedScrolling機制來實現,要能基於傳統的觸摸事件分發實現。

  • 1.餓了麼商家詳情頁(v8.27.6)

  • 2.美團商家詳情頁(v10.6.203)

  • 3.騰訊課堂首頁(v4.7.1)

  • 4.騰訊課堂課程詳情頁(v4.7.1)

  • 5.支付寶首頁(v10.1.82)

總結

本文偏向概念性內容,不免有些枯燥,但若遇到稍微有點挑戰要解決的問題,沒有現成的工具能夠利用,只能靠本身思考和分析或者借鑑其餘現成的工具的原理,就離不開這些看不起眼的「細節知識」;因爲本人水平有限僅給各位提供參考,但願可以拋磚引玉,若是有什麼能夠討論的問題能夠在評論區留言或聯繫本人,下篇將帶你們實戰基於NestedScrolling機制自定義View實現餓了麼商家詳情頁效果。

參考

1.【透鏡系列】看穿 > NestedScrolling 機制 >

相關文章
相關標籤/搜索