Android進階寶典 -- NestedScroll巢狀滑動機制實現吸頂效果

語言: CN / TW / HK

在上一篇文章Android進階寶典 -- 事件衝突怎麼解決?先從Android事件分發機制開始說起中,我們詳細地介紹了Android事件分發機制,其實只要頁面結構複雜,聯動眾多就會產生事件衝突,處理不得當就是bug,e.g. 我畫了一張很醜的圖

image.png

其實這種互動形式在很多電商、支付平臺都非常常見,頁面整體是可滑動的(scrollable),當頁面整體往上滑時,是外部滑動元件,e.g. NestedScrollView,當TabBar滑動到頂部的時候吸頂,緊接著ListView自身特性繼續往上滑。

其實這種效果,系統已經幫我們實現好了,尤其是像NestScrollView;如果我們在自定義View的時候,沒有系統能力的加持,會有問題嗎?如果熟悉Android事件分發機制,因為整體上滑的時候,外部元件消費了DOWM事件和MOVE事件,等到Tabbar吸頂之後,再次滑動ListView的時候,因為事件都在外部攔截,此時 mFirstTouchTarget還是父容器,沒有機會讓父容器取消事件再轉換到ListView,導致ListView不可滑動。

那麼我們只有鬆開手,再次滑動ListView,讓DOWN事件傳遞到ListView當中,這樣列表會繼續滑動,顯得沒有那麼順滑,從使用者體驗上來說是不可接受的。

1 自定義滑動佈局,實現吸頂效果

首先我們如果想要實現這個效果,其實辦法有很多,CoordinateLayout就是其中之一,但是如果我們想要自定義一個可滑動的佈局,而且還需要實現Tabbar的吸頂效果,我們需要注意兩點:

1)在頭部沒有被移出螢幕的時候,事件需要被外部攔截,只能滑動外部佈局,ListView不可滑動;

2)當頭部被移出到螢幕之外時,事件需要被ListView消費(繼續上滑時),如果下滑時則是同樣會先把頭部拉出來然後才可以滑動ListView

1.1 滑動容器實現

因為我們知道,要控制view移動,可以呼叫scrollBy或者scrollTo兩個方法,其中兩個方法的區別在於,前者是滑動的相對上一次的距離,而後者是滑動到具體位置。

```kotlin class MyNestScrollView @JvmOverloads constructor( val mContext: Context, val attributeSet: AttributeSet? = null, val flag: Int = 0 ) : LinearLayout(mContext, attributeSet, flag) {

private var mTouchSlop = 0
private var startY = 0f

init {
    mTouchSlop = ViewConfiguration.get(context).scaledPagingTouchSlop
}


override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {

    /**什麼時候攔截事件呢,當頭部還沒有消失的時候*/


    return super.onInterceptTouchEvent(ev)
}

override fun onTouchEvent(event: MotionEvent?): Boolean {

    when (event?.action) {
        MotionEvent.ACTION_DOWN -> {
            Log.e("TAG", "MyNestScrollView ACTION_DOWN")
            startY = event.y
        }
        MotionEvent.ACTION_MOVE -> {
            Log.e("TAG", "MyNestScrollView ACTION_MOVE")
            val endY = event.y

            if (abs(endY - startY) > mTouchSlop) {
                //滑動了
                scrollBy(0, (startY - endY).toInt())
            }

            startY = endY
        }
    }

    return super.onTouchEvent(event)
}

override fun scrollTo(x: Int, y: Int) {

    var finalY = 0

    if (y < 0) {
    } else {
        finalY = y
    }

    super.scrollTo(x, finalY)
}

} 所以在事件消費的時候,會呼叫scrollBy,來進行頁面的滑動,如果我們看scrollBy的原始碼,會明白最終呼叫就是通過scrollTo實現的,只不過是在上次pos的基礎上進行累計。java public void scrollBy(int x, int y) { scrollTo(mScrollX + x, mScrollY + y); } ```

所以這裡重寫了scrollTo方法,來判斷y(縱向)滑動的位置,因為當y小於0的時候,按照Android的座標系,我們知道如果一直往下滑,那麼△Y(豎直方向滑動距離) < 0,如果一直向下滑,最終totalY也會小於0,所以這裡也是做一次邊界的處理。

接下來我們需要處理下吸頂效果,所以我們需要知道,頂部View的高度,以便控制滑動的距離,也是一次邊界處理。 ```kotlin override fun scrollTo(x: Int, y: Int) {

var finalY = 0

if (y < 0) {
} else {
    finalY = y
}

if (y > mTopViewHeight) {
    finalY = mTopViewHeight
}

super.scrollTo(x, finalY)

}

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) //頂部view是第一個View mTopViewHeight = getChildAt(0).measuredHeight } ``` 所以這裡需要和我們寫的佈局相對應,頂部view是容器中第一個子View,通過在onSizeChanged或者onMeasure中獲取第一個子View的高度,在滑動時,如果滑動的距離超過 mTopViewHeight(頂部View的高度),那麼滑動時也就不會再繼續滑動了,這樣就實現了TabBar的吸頂效果。

基礎工作完成了,接下來我們完成需要注意的第一點,先看下面的圖:

image.png

當我們上滑的時候,頭部是準備逐漸隱藏的,所以這裡會有幾個條件,首先 mStartX - nowX > 0 而且 scrollY < mTopViewHeight,而且此時scrollY是大於0的 /** * 頭部View逐漸消失 * @param dy 手指滑動的相對距離 dy >0 上滑 dy < 0 下滑 */ private fun isViewHidden(dy: Int): Boolean { return dy > 0 && scrollY < mTopViewHeight } 當我們向下滑動的時候,此時 mStartX - nowX < 0,因為此時頭部隱藏了,所以ScrollY > 0,而且此時是能夠滑動的,如果到了下面這個邊界條件(不會有這種情況發生,因此在滑動時做了邊界處理),此時scrollY < 0 image.png

kotlin private fun isViewShow(dy: Int):Boolean{ return dy < 0 && scrollY > 0 && !canScrollVertically(-1) } 此時還有一個條件,就是canScrollVertically,這個相信夥伴們也很熟悉,意味著當前View是能夠往下滑動的,如果返回了false,那麼就是不能繼續往下滑動了。

```kotlin override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {

var intercepted = false
/**什麼時候攔截事件呢,當頭部還沒有消失的時候*/
when (ev?.action) {

    MotionEvent.ACTION_DOWN -> {
        startY = ev.rawY
    }
    MotionEvent.ACTION_MOVE -> {
        val endY = ev.rawY
        if (abs(startY - endY) > mTouchSlop) {

            if (isViewHidden((startY - endY).toInt())
                || isViewShow((startY - endY).toInt())
            ) {
                Log.e("TAG","此時就需要攔截,外部進行消費事件")
                //此時就需要攔截,外部進行消費事件
                intercepted = true
            }
        }
        startY = endY
    }
}

return intercepted

} ``` 所以在外部攔截的時候,通過判斷這兩種狀態,如果滿足其中一個條件就會攔截事件完全由外部容器處理,這樣就完成了吸頂效果的處理。

i9cjg-7tspb.gif

1.2 巢狀滑動機制完成互動優化

通過上面的gif,我們看效果貌似還可以,但是有一個問題就是,當完成吸頂之後,ListView並不能跟隨手指繼續向上滑動,而是需要鬆開手指之後,再次滑動即可,其實我們從Android事件分發機制中就能夠知道,此時mFirstTouchTarget == 父容器,此時再次上滑並沒有給父容器Cancel的機會,所以才導致事件沒有被ListView接收。

因為傳統的事件衝突解決方案,會導致滑動不流暢,此時就需要巢狀滑動機制解決這個問題。在前面我們提到過,NestedScrollView其實就是已經處理過巢狀滑動了,所以我們前面去看一下NestedScrollView到底幹了什麼事? java public class NestedScrollView extends FrameLayout implements NestedScrollingParent3, NestedScrollingChild3, ScrollingView

我們看到,NestedScrollView是實現了NestedScrollingParent3、NestedScrollingChild3等介面,挺有意思的,這幾個介面貌似都是根據數字做了升級,既然有3,那麼必然有1和2,所以我們看下這幾個介面的作用。

1.2.1 NestedScrollingParent介面和NestedScrollingChild介面

對於NestedScrollingParent介面,如果可滑動的ViewGroup,e.g. 我們在1.1中定義的容器作為父View,那麼就需要實現這個介面;如果是作為可滑動的子View,那麼就需要實現NestedScrollingChild介面,因為我們在自定義控制元件的時候,它既可能作為子View也可能作為父View,因此這倆介面都需要實現。

```java public interface NestedScrollingChild { /* * Enable or disable nested scrolling for this view. * * 啟動或者禁用巢狀滑動,如果返回ture,那麼說明當前佈局存在巢狀滑動的場景,反之沒有 * 使用場景:NestedScrollingParent巢狀NestedScrollingChild * 在此介面中的方法,都是交給NestedScrollingChildHelper代理類實現 / void setNestedScrollingEnabled(boolean enabled);

/**
 * Returns true if nested scrolling is enabled for this view.
 * 其實就是返回setNestedScrollingEnabled中設定的值
 */
boolean isNestedScrollingEnabled();

/**
 * Begin a nestable scroll operation along the given axes.
 * 表示view開始滾動了,一般是在ACTION_DOWN中呼叫,如果返回true則表示父佈局支援巢狀滾動。
 * 一般也是直接代理給NestedScrollingChildHelper的同名方法即可。這個時候正常情況會觸發Parent的onStartNestedScroll()方法
 */
boolean startNestedScroll(@ScrollAxis int axes);

/**
 * Stop a nested scroll in progress.
 * 停止巢狀滾動,一般在UP或者CANCEL事件中執行,告訴父容器已經停止了巢狀滑動
 */
void stopNestedScroll();

/**
 * Returns true if this view has a nested scrolling parent.
 * 判斷當前View是否存在巢狀滑動的Parent
 */
boolean hasNestedScrollingParent();

/**
* 當前View消費滑動事件之後,滾動一段距離之後,把剩餘的距離回撥給父容器,父容器知道當前剩餘距離
* dxConsumed:x軸滾動的距離
* dyConsumed:y軸滾動的距離
* dxUnconsumed:x軸未消費的距離
* dyUnconsumed:y軸未消費的距離
* 這個方法是巢狀滑動的時候呼叫才有用,返回值 true分發成功;false 分發失敗
*/
boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
        int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);

/**
 * Dispatch one step of a nested scroll in progress before this view consumes any portion of it.
 * 在子View消費滑動距離之前,將滑動距離傳遞給父容器,相當於把消費權交給parent
 * dx:當前水平方向滑動的距離
 * dy:當前垂直方向滑動的距離
 * consumed:輸出引數,會將Parent消費掉的距離封裝進該引數consumed[0]代表水平方向,consumed[1]代表垂直方向
* @return true:代表Parent消費了滾動距離
 */
boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
        @Nullable int[] offsetInWindow);

/**
 * Dispatch one step of a nested scroll in progress.
 * 處理慣性事件,與dispatchNestedScroll類似,也是在消費事件之後,將消費和未消費的距離都傳遞給父容器
 */
boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);

/**
 * Dispatch a fling to a nested scrolling parent before it is processed by this view.
 * 與dispatchNestedPreScroll類似,在消費之前首先會傳遞給父容器,把優先處理權交給父容器
 */
boolean dispatchNestedPreFling(float velocityX, float velocityY);

} ```

```java public interface NestedScrollingParent { /** * React to a descendant view initiating a nestable scroll operation, claiming the * nested scroll operation if appropriate.

 * 當子View呼叫startNestedScroll方法的時候,父容器會在這個方法中獲取回撥
 */
boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes);

/**
 * React to the successful claiming of a nested scroll operation.
 * 在onStartNestedScroll呼叫之後,就緊接著呼叫這個方法
 */
void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes);

/**
 * React to a nested scroll operation ending.
 * 當子View呼叫 stopNestedScroll方法的時候回撥
 */
void onStopNestedScroll(@NonNull View target);

/**
 * React to a nested scroll in progress.
 * 
 */
void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
        int dxUnconsumed, int dyUnconsumed);

/**
 * React to a nested scroll in progress before the target view consumes a portion of the scroll.
 * 在子View呼叫dispatchNestedPreScroll之後,這個方法拿到了回撥
 * 
 */
void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed);

/**
 * Request a fling from a nested scroll.
 *
 */
boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed);

/**
 * React to a nested fling before the target view consumes it.
 *
 */
boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY);

/**
 * Return the current axes of nested scrolling for this NestedScrollingParent.
 * 返回當前滑動的方向
 */
@ScrollAxis
int getNestedScrollAxes();

} ```

通過這兩個介面,我們大概就能夠明白,其實巢狀滑動機制完全是子View在做主導,通過子View能夠決定Parent是否能夠優先消費事件(dispatchNestedPreScroll),所以我們先從子View開始,開啟巢狀滑動之旅。

1.2.2 預滾動階段實現

在這個示例中,需要與parent巢狀滑動的就是RecyclerView,所以RecyclerView就需要實現child介面。前面我們看到child介面好多方法,該怎麼呼叫呢?其實這個介面中大部分的方法都可以交給一個helper代理類實現,e.g. NestedScrollingChildHelper.

因為所有的巢狀滑動都是由子View主導,所以我們先看子View消費事件,也就是onTouchEvent中,如果當手指按下的時候,首先獲取滑動的是x軸還是y軸,這裡我們就認為是豎向滑動,然後呼叫NestedScrollingChild的startNestedScroll方法,這個方法就代表開始滑動了。

``` override fun onTouchEvent(e: MotionEvent?): Boolean {

when(e?.action){
    MotionEvent.ACTION_DOWN->{
        mStartX = e.y.toInt()
        //子View開始巢狀滑動
        var axis = ViewCompat.SCROLL_AXIS_NONE
        axis = axis or ViewCompat.SCROLL_AXIS_VERTICAL
        nestedScrollingChildHelper.startNestedScroll(axis)
    }
    MotionEvent.ACTION_MOVE->{

    }
}

return super.onTouchEvent(e)

} ```

我們看下startNestedScroll內部的原始碼: java public boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type) { if (hasNestedScrollingParent(type)) { // Already in progress return true; } if (isNestedScrollingEnabled()) { ViewParent p = mView.getParent(); View child = mView; while (p != null) { if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)) { setNestedScrollingParentForType(type, p); ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes, type); return true; } if (p instanceof View) { child = (View) p; } p = p.getParent(); } } return false; } 從原始碼中 我們可以看到,首先如果有巢狀滑動的父容器,直接返回true,此時代表巢狀滑動成功; ```java public boolean hasNestedScrollingParent(@NestedScrollType int type) { return getNestedScrollingParentForType(type) != null;

private ViewParent getNestedScrollingParentForType(@NestedScrollType int type) { switch (type) { case TYPE_TOUCH: return mNestedScrollingParentTouch; case TYPE_NON_TOUCH: return mNestedScrollingParentNonTouch; } return null; } ``` 在判斷的時候,會判斷mNestedScrollingParentTouch是否為空,因為第一次進來的時候肯定是空的,所以會繼續往下走;如果支援巢狀滑動,那麼就會進入到while迴圈中。

核心程式碼1: ```java while (p != null) {

//---------- 判斷條件1 -------------//

if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)) {
    setNestedScrollingParentForType(type, p);
    ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes, type);
    return true;
}
if (p instanceof View) {
    child = (View) p;
}
p = p.getParent();

} 首先呼叫ViewParentCompat的onStartNestedScroll方法如下:java public static boolean onStartNestedScroll(@NonNull ViewParent parent, @NonNull View child, @NonNull View target, int nestedScrollAxes, int type) { if (parent instanceof NestedScrollingParent2) { // First try the NestedScrollingParent2 API return ((NestedScrollingParent2) parent).onStartNestedScroll(child, target, nestedScrollAxes, type); } else if (type == ViewCompat.TYPE_TOUCH) { // Else if the type is the default (touch), try the NestedScrollingParent API if (Build.VERSION.SDK_INT >= 21) { try { return Api21Impl.onStartNestedScroll(parent, child, target, nestedScrollAxes); } catch (AbstractMethodError e) { Log.e(TAG, "ViewParent " + parent + " does not implement interface " + "method onStartNestedScroll", e); } } else if (parent instanceof NestedScrollingParent) { return ((NestedScrollingParent) parent).onStartNestedScroll(child, target, nestedScrollAxes); } } return false; } ``` 其實在這個方法中,就是判斷parent是否實現了NestedScrollingParent(2 3)介面,如果實現了此介面,那麼返回值就是parent中onStartNestedScroll的返回值。

這裡需要注意的是,如果parent中onStartNestedScroll的返回值為false,那麼就不會進入程式碼塊的條件判斷,所以在實現parent介面的時候,onStartNestedScroll需要返回true。進入程式碼塊中呼叫setNestedScrollingParentForType方法,將父容器給mNestedScrollingParentTouch賦值,那麼此時hasNestedScrollingParent方法就返回true,不需要遍歷View層級了。 java private void setNestedScrollingParentForType(@NestedScrollType int type, ViewParent p) { switch (type) { case TYPE_TOUCH: mNestedScrollingParentTouch = p; break; case TYPE_NON_TOUCH: mNestedScrollingParentNonTouch = p; break; } } 然後又緊接著呼叫了parent的onNestedScrollAccepted方法,這兩者一前一後,這樣預滾動階段就算是完成了

在父容器中,預滾動節點就需要處理這兩個回撥即可,關鍵在於onStartNestedScroll的返回值。 ```kotlin override fun onStartNestedScroll(child: View, target: View, axes: Int): Boolean { Log.e("TAG","onStartNestedScroll") //這裡需要return true,否則在子View中分發事件就不會成功 return true }

override fun onNestedScrollAccepted(child: View, target: View, axes: Int) { Log.e("TAG","onNestedScrollAccepted") } ```

1.2.3 滾動階段實現

然後MOVE事件來了,這個時候我們需要記住,即便是滑動了子View,但是子View依然是需要將事件扔給父類,這裡就需要呼叫dispatchNestedPreScroll方法,這裡在1.2.1中介紹過,需要跟dispatchNestedScroll區分,dispatchNestedPreScroll是在子View消費事件之前就交給父類優先處理。 ```java public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow, @NestedScrollType int type) { if (isNestedScrollingEnabled()) { //這裡不為空了 final ViewParent parent = getNestedScrollingParentForType(type); if (parent == null) { return false; }

    if (dx != 0 || dy != 0) {
        int startX = 0;
        int startY = 0;
        if (offsetInWindow != null) {
            mView.getLocationInWindow(offsetInWindow);
            startX = offsetInWindow[0];
            startY = offsetInWindow[1];
        }

        if (consumed == null) {
            consumed = getTempNestedScrollConsumed();
        }
        consumed[0] = 0;
        consumed[1] = 0;
        ViewParentCompat.onNestedPreScroll(parent, mView, dx, dy, consumed, type);

        if (offsetInWindow != null) {
            mView.getLocationInWindow(offsetInWindow);
            offsetInWindow[0] -= startX;
            offsetInWindow[1] -= startY;
        }
        //-------- 由父容器是否消費決定返回值 -------//
        return consumed[0] != 0 || consumed[1] != 0;
    } else if (offsetInWindow != null) {
        offsetInWindow[0] = 0;
        offsetInWindow[1] = 0;
    }
}
return false;

} ``` 在子View呼叫dispatchNestedPreScroll方法時,需要傳入四個引數,這裡我們再次詳細介紹一下:\ dx、dy指的是x軸和y軸滑動的距離;\ consumed在子View呼叫時,其實只需要傳入一個空陣列即可,具體的賦值是需要在父容器中進行,父view消費了多少距離,就傳入多少,consumed[0]代表x軸,consumed[1]代表y軸;

看上面的原始碼,當dx或者dy不為0的時候,說明有滑動了,那麼此時就會做一些初始化的配置,把consumed陣列清空,然後會呼叫父容器的onNestedPreScroll方法,父容器決定是否消費這個事件,因為在父容器中會對consumed陣列進行復制,所以這個方法的返回值代表著父容器是否消費過事件;如果消費過,那麼就返回true,沒有消費過,那麼就返回false.

所以我們先看父容器的處理:

kotlin override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray) { Log.e("TAG", "onNestedPreScroll") //父容器什麼時候 消費呢? if (isViewShow(dy) || isViewHidden(dy)) { //假設這個時候把事件全消費了 consumed[1] = dy scrollBy(0, dy) } } 其實我們這裡就是直接將之前在onTouchEvent中的處理邏輯放在了onNestedPreScroll中,如果在上拉或者下滑時,首先頭部優先,假設父容器把距離全部消費,這個時候給consumed[1]賦值為dy。

```kotlin MotionEvent.ACTION_MOVE -> { val endY = e.y.toInt() val endX = e.x.toInt() var dx = mStartX - endX var dy = mStartY - endY //進行事件分發,優先給parent if (dispatchNestedPreScroll(dx, dy, cosumed, null)) { //如果父容器消費過事件,這個時候,cosumed有值了,我們只關心dy dy -= cosumed[1] if (dy == 0) { //代表父容器全給消費了 return true } } else { //如果沒有消費事件,那麼就子view消費吧 smoothScrollBy(dx, dy) }

} ``` 再來看子View,這裡是在MOVE事件中進行事件分發,呼叫dispatchNestedPreScroll方法,判斷如果父容器有事件消費,看消費了多少,剩下的就是子View消費;如果父容器沒有消費,dispatchNestedPreScroll返回了false,那麼子View自行處理事件

所以如果子View使用的是RecyclerView,那麼在父容器做完處理之後,其實就能夠實現巢狀滑動吸頂的完美效果,為什麼呢?是因為RecyclerView本來就實現了parent介面,所以如果在自定義子View(可滑動)時,子View處理的這部分程式碼就需要特別關心。

1.2.4 滾動結束

在手指擡起之後,呼叫stopNestedScroll方法。

kotlin MotionEvent.ACTION_UP->{ nestedScrollingChildHelper.stopNestedScroll() } 從原始碼中看,其實就是回到父容器的onStopNestedScroll方法,然後將滑動的標誌位(mNestedScrollingParentTouch)置為空,在下次按下的時候,重新初始化。 java public void stopNestedScroll(@NestedScrollType int type) { ViewParent parent = getNestedScrollingParentForType(type); if (parent != null) { ViewParentCompat.onStopNestedScroll(parent, mView, type); setNestedScrollingParentForType(type, null); } }