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

預覽

嵌套系列導航

本文已在公眾號鴻洋原創(chuàng)發(fā)布。未經(jīng)許可,不得以任何形式轉載!

概述

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

處理同向滑動事件沖突

image

如果兩個可滑動的容器嵌套,外部View攔截了內部View的滑動,可能造成滑動沖突,通?;趥鹘y(tǒng)的觸摸事件分發(fā)機制來解決:

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);
               //根據(jù)自身是否滑動到頂部或者頂部來判斷讓父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.小結

上面通過兩種經(jīng)典的解決方案,在內部View可以滑動時,外部View不攔截,當內部View滑動到底部或者頂部時,讓外部消費滑動事件進行滑動。一般而言,外部攔截法和內部攔截法不能公用。 否則內部容器可能并沒有機會調用 requestDisallowInterceptTouchEvent方法。在傳統(tǒng)的觸摸事件分發(fā)中,如果不手動調用分發(fā)事件或者去發(fā)出事件,外部View最先拿到觸摸事件,一旦它被外部View攔截消費了,內部View無法接收到觸摸事件,同理,內部View消費了觸摸事件,外部View也沒有機會響應觸摸事件。 而接下介紹的NestedScrolling機制,在一次滑動事件中外部View和內部View都有機會對滑動進行響應,這樣處理滑動沖突就相對方便許多。

NestedScrolling機制原理

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


NestedScrolling交互 流程示意圖.png

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

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

  • 2.如果NestedScrollingParent不配合NestedScrollingChild處理滑動就沒有接下來的流程,否則就會配合處理滑動。

  • 3.NestedScrollingChild要滑動之前,它先拿到MotionEvent.ACTION_MOVE滑動的dx,dy并將一個有兩個元素的數(shù)組(分別代表NestedScrollingParent要滑動的水平和垂直方向的距離)作為輸出參數(shù)一同傳給NestedScrollingParent。

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

  • 5.NestedScrollingChild將【2】里拿到的dx、dy減去【4】NestedScrollingParent消費滑動值,計算出剩余的滑動值;如果剩余的滑動值為0說明NestedScrollingParent全部消費了NestedScrollingChild不應進行滑動;否則NestedScrollingChild根據(jù)剩余的滑動值進行消費,然后將自己消費了多少、還剩余多少匯報傳遞給NestedScrollingParent。

  • 6.如果NestedScrollingChild在滑動期間發(fā)生的慣性滑動,它會將velocityX,velocityY傳給NestedScrollingParent,并詢問NestedScrollingParent是否要全部消費。

  • 7.NestedScrollingParent收到【6】NestedScrollingChild傳來的數(shù)據(jù),告訴NestedScrollingChild是否全部消費慣性滑動。

  • 8.如果在【7】NestedScrollingParent沒有全部消費慣性滑動,NestedScrollingChild會將velocityX,velocityY、自身是否需要消費全部慣性滑動傳給NestedScrollingParent,并詢問NestedScrollingParent是否要全部消費。

  • 9.NestedScrollingParent收到【8】NestedScrollingChild傳來的數(shù)據(jù),告訴NestedScrollingChild是否全部消費慣性滑動。

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

PS:

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

類與接口

前面提到Android 5.0及以上的View、ViewGroup自身分別就有NestedScrollingChild和NestedScrollingParent的方法,而方法邏輯就是對應的NestedScrollingChildHelper和NestedScrollingParentHelper的具體方法實現(xiàn),所以本小節(jié)不講解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();

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

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

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

    /**
     * 在慣性滑動之前,將慣性滑動值分發(fā)給NestedScrollingParent
     * @param velocityX 水平方向的速度
     * @param velocityY 垂直方向的速度
     * @return 返回NestedScrollingParent是否消費全部慣性滑動
     */
    boolean dispatchNestedPreFling(float velocityX, float velocityY);
}

2.NestedScrollingParent

public interface NestedScrollingParent {
    /**
     * 對NestedScrollingChild發(fā)起嵌套滑動作出應答
     * @param child 布局中包含下面target的直接父View
     * @param target 發(fā)起嵌套滑動的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滑動完成后將滑動值分發(fā)給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滑動完之前將滑動值分發(fā)給NestedScrollingParent回調此方法
     * @param target 同上
     * @param dx 水平方向的距離
     * @param dy 水平方向的距離
     * @param consumed 返回NestedScrollingParent是否消費部分或全部滑動值
     */
    void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed);

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

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

3.方法調用流程圖:

image

4.NestedScrollingChildHepler

NestedScrollingChildHepler對NestedScrollingChild的接口方法做了代理,您可以結合實際情況借助它來實現(xiàn),如:

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;
            //循環(huán)往上層尋找配合處理滑動的NestedScrollingParent
            while (p != null) {
                //ViewParentCompat.onStartNestedScroll()會判斷p是否實現(xiàn)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;
    }

這個方法首先會判斷是否已經(jīng)找到了配合處理滑動的NestedScrollingParent、若找到了則返回true,否則會判斷是否開啟嵌套滑動,若開啟了則通過構造函數(shù)注入的View來循環(huán)往上層尋找配合處理滑動的NestedScrollingParent,循環(huán)條件是通過ViewParentCompat這個兼容類判斷p是否實現(xiàn)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];
                }
                //初始化輸出數(shù)組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數(shù)組
                    mView.getLocationInWindow(offsetInWindow);
                    offsetInWindow[0] -= startX;
                    offsetInWindow[1] -= startY;
                }
                //consumed數(shù)組的兩個元素的值有其中一個不為0則說明NestedScrollingParent消耗的部分或者全部滑動值
                return consumed[0] != 0 || consumed[1] != 0;
            } else if (offsetInWindow != null) {
                offsetInWindow[0] = 0;
                offsetInWindow[1] = 0;
            }
        }
        return false;
    }

這個方法首先會判斷是否開啟嵌套滑動并找到配合處理滑動的NestedScrollingParent,若符合這兩個條件則會根據(jù)參數(shù)dx、dy滑動值判斷是否有水平或垂直方向滑動,若有滑動調用mView.getLocationInWindow()將View當前的在Window上的x、y坐標值賦值進offsetInWindow數(shù)組并以startX、startY記錄,接下來初始化輸出數(shù)組consumed、并通過ViewParentCompat調用NestedScrollingParent的onNestedPreScroll(),再次調用mView.getLocationInWindow()將調用NestedScrollingParent的onNestedPreScroll()后的View在Window上的x、y坐標值賦值進offsetInWindow數(shù)組并與之前記錄好的startX、startY相減計算得出偏移量,接著以consumed數(shù)組的兩個元素的值有其中一個不為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數(shù)組
                    mView.getLocationInWindow(offsetInWindow);
                    offsetInWindow[0] -= startX;
                    offsetInWindow[1] -= startY;
                }
                //返回true表明NestedScrollingChild的dispatchNestedScroll事件成功分發(fā)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機制的改進

慣性滑動不連續(xù)問題

在使用之前NestedScrolling機制的 系統(tǒng)控件 嵌套滑動,當內部View快速滑動產(chǎn)生慣性滑動到邊緣就停止,而不將慣性滑動傳遞給外部View繼續(xù)消費慣性滑動,就會出現(xiàn)下圖兩個NestedScrollView嵌套滑動這種 慣性滑動不連續(xù) 的情況:

慣性滑動不連續(xù)

這里以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)) {
                        //分發(fā)慣性滑動
                        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)) {//將慣性滑動分發(fā)給NestedScrollingParent,讓它先對慣性滑動進行處理
            dispatchNestedFling(0, velocityY, canFling);//若慣性滑動沒被消費,再次將慣性滑動分發(fā)給NestedScrollingParent,并帶上自身是否能消費fling的canFling參數(shù)讓NestedScrollingParent根據(jù)情況處理決定canFling是true還是false
            if (canFling) {
                //執(zhí)行fling()消費慣性滑動
                fling(velocityY);
            }
        }
    }

    public void fling(int velocityY) {
        if (getChildCount() > 0) {
            int height = getHeight() - getPaddingBottom() - getPaddingTop();
            int bottom = getChildAt(0).getHeight();
            //初始化fling的參數(shù)
            mScroller.fling(getScrollX(), getScrollY(), 0, velocityY, 0, 0, 0,
                    Math.max(0, bottom - height), 0, height/2);
            //重繪會觸發(fā)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());
                    }
                }
            }
        }
    }

上面代碼執(zhí)行如下:

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

  • 2.flingWithNestedDispatch()方法先調用dispatchNestedPreFling()將慣性滑動分發(fā)給NestedScrollingParent,若NestedScrollingParent沒有消費則調用dispatchNestedFling()并帶上自身是否能消費fling的canFling參數(shù)讓NestedScrollingParent可以根據(jù)情況處理決定canFling是true還是false,若canFling值為true,執(zhí)行fling()方法。

  • 3.fling()方法執(zhí)行mScroller.fling()初始化fling參數(shù),然后 調用ViewCompat.postInvalidateOnAnimation()重繪觸發(fā)computeScroll()方法進行滾動。

  • 4.computeScroll()方法里面只讓自身進行fling,并沒有在自身fling到邊緣時將慣性滑動分發(fā)給NestedScrollingParent。

NestedScrollingChild2、NestedScrollingParent2

在Revision 26.1.0的android.support.v4兼容包添加了NestedScrollingChild2、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類型參數(shù)type,這個參數(shù)有兩個值:TYPE_TOUCH值為0表示滑動由用戶手勢滑動屏幕觸發(fā);TYPE_NON_TOUCH值為1表示滑動不是由用戶手勢滑動屏幕觸發(fā);同時View、ViewGroup、NestedScrollingChildHelper、NestedScrollingParentHelper同樣根據(jù)參數(shù)type做了調整。

前面說到因為系統(tǒng)控件在computeScroll()方法里面只讓自身進行fling,并沒有在自身fling到邊緣時將慣性滑動分發(fā)給NestedScrollingParent導致慣性滑動不連貫,所以這里以com.android.support:appcompat-v7:26.1.0的NestedScrollView源碼看看如何使用改進后的NestedScrolling機制:

    public void fling(int velocityY) {
            if (getChildCount() > 0) {
                //發(fā)起滑動嵌套,注意ViewCompat.TYPE_NON_TOUCH參數(shù)表示不是由用戶手勢滑動屏幕觸發(fā)
                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(將滑動值分發(fā)給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)) {//若滾動值沒有分發(fā)成功給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方法發(fā)起滑動嵌套。

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

OverScroller未終止?jié)L動動畫

Scroller未關閉

在使用之前NestedScrolling機制的 系統(tǒng)控件 嵌套滑動,當子、父View都在頂部時,首先快速下滑子View并抬起手指制造慣性滑動,然后馬上滑動父View,這時就會出現(xiàn)上圖的兩個NestedScrollView嵌套滑動現(xiàn)象,你手指往上滑視圖內容往下滾一段距離,視圖內容立刻就會自動往上回滾。

這里還是以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();
                }
            }
            ...
        }
        ...
    }

代碼執(zhí)行如下:

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

  • 2.在子View調用computeScroll()方法期間,如果此時子View不命中MotionEvent.ACTION_DOWN,mScroller是不會停止?jié)L動,只能等待它完成,于是就子View就不停調用dispatchNestedPreScroll()和dispatchNestedScroll()分發(fā)滑動值給父View,就出現(xiàn)了上圖的場景。

NestedScrollingChild3、NestedScrollingParent3

在androidx.core 1.1.0-alpha01開始引入NestedScrollingChild3、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數(shù)組consumed參數(shù)作為輸出參數(shù)記錄NestedScrollingParent3消費的滑動值,同理,NestedScrollingParent3繼承NestedScrollingParent2重載onNestedScroll添加了一個int數(shù)組consumed參數(shù)來對應NestedScrollingChild3,NestedScrollingChildHepler、NestedScrollingParentHelper同樣根據(jù)變化做了適配調整。

下面是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(分發(fā)滑動值給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(分發(fā)滑動值給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()將滑動值分發(fā)給NestedScrollingParent3并附帶mScrollConsumed數(shù)組作為輸出參數(shù)記錄其具體消費多少滑動值,變量unconsumed表示剩余的滑動值,在調用dispatchNestedPreScroll()后,unconsumed減去之前的mScrollConsumed數(shù)組的元素重新賦值;

  • 2.此時unconsumed值不為0,說明NestedScrollingParent3沒有消費掉全部滑動值,則自身掉用overScrollByCompat()進行滾動消費滑動值,unconsumed減去記錄本次消費的滑動值scrolledByMe重新賦值;然后調用dispatchNestedScroll()類似于【1】將滑動值分發(fā)給NestedScrollingParent3的操作然后計算unconsumed;

  • 3.若unconsumed值還不為0,說明滑動值沒有完全消費掉,此時實現(xiàn)NestedScrollingParent3、NestedScrollingChild3對應的父View、子View在同一方向都滑動到了邊緣盡頭,此時自身用EdgeEffect消費剩余滑動值并調用abortAnimatedScroll()來 停止mScroller滾動并結束嵌套滑動;

NestedScrolling機制的使用

如果你最低支持android版本是5.0及其以上,你可以使用View、ViewGroup本身對應的NestedScrollingChild、NestedScrollingParent接口;如果你使用AndroidX那么你就需要使用NestedScrollingChild3、NestedScrollingParent3;如果你兼容Android5.0之前版本請使用NestedScrollingChild2、NestedScrollingParent2。下面的例子是偽代碼,因為下面的自定義View沒有實現(xiàn)類似Scroller的方式來消費滑動值,因此它運行也不能實現(xiàn)嵌套滑動進行滑動,只是提供給大家處理觸摸事件調用NestedScrolling機制的思路。

使用NestedScrollingParent2

如果要兼容NestedScrollingParent則覆寫其接口即可,可以借助NestedScrollingParentHelper結合需求作方法代理,你可以根據(jù)具體業(yè)務在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;
                //開始滑動之前,分發(fā)滑動值給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;
                //根據(jù)需求判斷是否能Fling
                boolean canFling=true;
                if (!dispatchNestedPreFling(0, velocityY)) {
                    dispatchNestedFling(0, velocityY, canFling);
                    //模擬執(zhí)行慣性滑動,如果你希望慣性滑動也能傳遞給NestedScrollingParent2,對于每次消費滑動距離,
                    // 與MOVE事件中處理滑動一樣,按照dispatchNestedPreScroll() -> 自己消費 -> dispatchNestedScroll() -> 自己消費的順序進行消費滑動值
                    fling(velocityY);
                }
                //停止嵌套滑動
                stopNestedScroll(ViewCompat.TYPE_TOUCH);
                break;
            case MotionEvent.ACTION_CANCEL:
                //停止嵌套滑動
                stopNestedScroll(ViewCompat.TYPE_TOUCH);
                break;
        }
        return true;
    }

同時使用NestedScrollingChild2、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()應該這么寫達到嵌套滑動事件往外分發(fā)的效果:

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

常見交互效果

除了下面的餓了么商家詳情頁外其他的效果可以用 CoordinatorLayout+AppBarLayout+CollapsingToolbarLayout 實現(xiàn)折疊懸停效果,其實它們底層Behavior也是基于NestedScrolling機制來實現(xiàn)的,而像餓了么這樣的效果如果使用自定View的話要么用NestedScrolling機制來實現(xiàn),要能基于傳統(tǒng)的觸摸事件分發(fā)實現(xiàn)。

image

  • 1.餓了么商家詳情頁(v8.27.6)
image
  • 2.美團商家詳情頁(v10.6.203)
image
  • 3.騰訊課堂首頁(v4.7.1)
image
  • 4.騰訊課堂課程詳情頁(v4.7.1)
image
  • 5.支付寶首頁(v10.1.82)

總結

本文偏向概念性內容,難免有些枯燥,但若遇到稍微有點挑戰(zhàn)要解決的問題,沒有現(xiàn)成的工具可以利用,只能靠自己思考和分析或者借鑒其他現(xiàn)成的工具的原理,就離不開這些看不起眼的“細節(jié)知識”;由于本人水平有限僅給各位提供參考,希望能夠拋磚引玉,如果有什么可以討論的問題可以在評論區(qū)留言或聯(lián)系本人,下篇將帶大家實戰(zhàn)基于NestedScrolling機制自定義View實現(xiàn)餓了么商家詳情頁效果。

參考

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

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

友情鏈接更多精彩內容