天天看點

Android 嵌套滑動——NestedScrolling完全解析

基本的事件分發流程

對于一次從父布局到自布局的觸摸事件流程分發,關鍵便是在三個方法上的流程處理

dispatchTouchEvent()

,

onInterceptTouchEvent()

,

onTouchEvent()

。由于和

NestScroll

相關,是以不細緻分析到

View

層面上的事件分發。

對于事件分發的觸摸大緻可分為按下(DOWN),移動(MOVE),擡起(UP)。按照這三個事件,流程分析如下:

  • 按下(DOWN):首先調用父控件的

    dispatchTouchEvent()

    進行事件分發,然後在該方法中,調用

    onInterceptTouchEvent()

    方法,如果傳回

    true

    表示事件被打斷,則直接調用父控件的

    onTouchEvent()

    方法。如果沒有被打斷,會周遊子控件的

    dispatchTouchEvent()

    方法,子控件通過

    onTouchEvent()

    去判斷是否處理事件,如果處理事件,會儲存處理觸摸事件的控件對象。
  • 移動(MOVE):該事件在父控件的流程相似,差別在于當父控件不處理時,直接擷取

    DOWN

    時的處理對象,由該儲存的處理對象消耗事件。
  • 擡起(UP): 該事件和

    MOVE

    事件基本相似,不做分析。

由該流程,不然發現一個問題,事件很容易的向子控件傳遞和處理,但是子控件無法向父控件傳遞觸摸事件。例如:當子控件滑動時,滑到一半突然不想處理事件了,想讓父控件滑動會,或者子控件滑動到極限,然後讓父控件滑動,這兩種情況在如上的流程中很難去實作。

那麼對于這種情況的處理,推出了2個關鍵的接口以及輔助實作的類,

// 接口
NestedScrollingParent
NestedScrollingChild
// 輔助實作類
NestedScrollingChildHelper
NestedScrollingParentHelper
           

而這篇文章關鍵點便在這四個方法的分析。因為這四個方法是一個整體流程,在單獨分析時,初始可能會有很多疑惑,此時隻需要記憶,等整體流程時便會豁然開朗。

目标Demo

Android 嵌套滑動——NestedScrolling完全解析

有兩個方框,按住橙色方塊可以跟随手指滑動,當垂直滑動一定距離,再滑動時,由紫色方快滑動,橙色不在移動。

具體的布局代碼如下:

<com.spearbothy.custombehavior.nestscroll.NestParent xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center_horizontal"
    android:orientation="vertical">
    <View
        android:layout_width="50dp"
        android:layout_height="50dp"
        android:layout_marginTop="100dp"
        android:background="#f0f" />
    <com.spearbothy.custombehavior.nestscroll.NestChild
        android:layout_width="50dp"
        android:layout_height="50dp"
        android:layout_marginTop="200dp"
        android:background="#88ff7f3c" />
</com.spearbothy.custombehavior.nestscroll.NestParent>
           

其中

NestChild

表示子控件,實作

NestedScrollingChild

接口。

NestParent

表示父控件,實作

NestedScrollingParent

接口

NestedScrollingChild

NestedScrollingChildHelper

首先考慮怎麼實作子控件,子控件的目的便是在将要滑動時,通知父控件,父控件可以選擇消耗或者不消耗這次滑動,然後子控件根據父控件的情況處理此次滑動,滑動完了,再将滑動的情況通知以下父控件,那麼我們就能實作了上述不足中的兩種情況。

首先看

NestedScrollingChild

是一個接口,接口是什麼,是用來定義一系列規範的,即定義具體的方法和作用,不管具體的實作。那麼看一下這個接口都有那些方法:

請注意:這些方法都是目前控件需要實作的方法,其中目的表示我們要在這些方法中實作哪些功能。

public void setNestedScrollingEnabled(boolean enabled);
 public boolean isNestedScrollingEnabled();
           

設定和擷取該控件是否可以嵌套滾動,是否參與這整個嵌套滾動的流程,和普通滑動區分,他們沒有什麼關系。

public boolean startNestedScroll(int axes);
           

開始滾動滑動時調用此方法,參數為滑動的方向。目的:該方法需要實作通知父控件,我要開始滾動滑動。

hasNestedScrollingParent();
           

目前的滾動該是否有父控件正在處理。

//dx:x軸的偏移量
//dy:y軸偏移量
//consumed:位移消耗量,由父控件為其指派(數組作為參數的特性?),用以表示父控件消耗了多少位移。
// offsetInWindow: 目前控件的位置索引。
public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow);
           

目前控件準備滑動時的事件分發,這裡的參數比較多,其中dx,dy是我們需要根據手指的觸摸計算得出,而後兩個數組,隻需要傳入size為2的數組能夠存儲x,y的值即可。目的:告訴父控件,我要滑動了和将要滑動的偏移量。父控件可以根據自己的情況是否消耗滑動。

// dxConsumed:目前控件x軸消耗
// dyConsumed:目前控件y軸消耗
// dxUnconsumed,dyUnconsumed:x,y軸未消耗的
// offsetInWindow : 忽略..
  public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow);
           

目前控件滑動完成之後調用。目的:通知父控件我滑動完了,你看看要咋辦吧。

//  滑動的速度
public boolean dispatchNestedPreFling(float velocityX, float velocityY);
           

目前控件開始慣性滑動時調用。目的:告訴父控件,我要開始慣性滑動了。

// consumed : 目前控件是否消費了慣性滑動
 public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);
           

目前控件慣性滑動結束後調用。目的:告訴父控件我慣性滑動完了,你看着辦吧。

public void stopNestedScroll();
           

停止滾動滑動時調用此方法。目的:告訴父控件,我的滑動完成了。

到這裡,所有方法已經分析完了,上面的分析中,主要突出了什麼時機調用和要做的事情。具體的傳回值等的沒有解釋,此時解釋會更加混淆。我們隻需要了解他們的調用時機和目的就夠了。

分析完了,就要實作這個接口并根據上面的定義實作方法。如果此時,我們定義一個

View

并實作

NestedScrollingChild

方法時,會發現

View

已經實作了這些方法,但是,我們千萬不要認為,我們不需要重寫,因為

View

中的方法是在21的時候加入的。如果我們不重寫接口中的方法,在低版本時編譯會報錯。

掐指一算,這麼多的方法要實作,瘋了~~~~突然想到,

View

中不是有實作嗎,我們複制過來行不,當然可以。但這樣會不會很麻煩,此時便是

NestedScrollingChildHelper

類的登場。注意,為了相容,我們需要忽略

View

中有關于此的實作。

從名字上可以看出,他的目的就是為了輔助我們實作

NestedScrollingChild

接口的,我們隻需要把這些方法的實作扔給該輔助類即可。具體實作如下:

public class NestChild extends View implements NestedScrollingChild {

    private static final String TAG = NestChild.class.getSimpleName();

    private final NestedScrollingChildHelper mChildHelper;

    public NestChild(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, );
    }

    public NestChild(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        // 生成輔助類,并傳入目前控件
        mChildHelper = new NestedScrollingChildHelper(this);
        setNestedScrollingEnabled(true);
    }

    @Override
    public boolean hasNestedScrollingParent() {
        return mChildHelper.hasNestedScrollingParent();
    }

    @Override
    public boolean isNestedScrollingEnabled() {
        return mChildHelper.isNestedScrollingEnabled();
    }

    @Override
    public void setNestedScrollingEnabled(boolean enabled) {
        Log.i(TAG, "setNestedScrollingEnabled");
        mChildHelper.setNestedScrollingEnabled(enabled);
    }


    @Override
    public boolean startNestedScroll(int axes) {
        Log.i(TAG, "startNestedScroll");
        return mChildHelper.startNestedScroll(axes);
    }

    @Override
    public void stopNestedScroll() {
        Log.i(TAG, "stopNestedScroll");
        mChildHelper.stopNestedScroll();
    }

    @Override
    public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
                                        int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow) {
        Log.i(TAG, "dispatchNestedScroll");
        // 滾動之後将剩餘滑動傳給父類
        return mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed,
                dxUnconsumed, dyUnconsumed, offsetInWindow);
    }

    @Override
    public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
        // 子View滾動之前将滑動距離傳給父類
        Log.i(TAG, "dispatchNestedPreScroll");
        return mChildHelper.dispatchNestedPreScroll(dx, dy,
                consumed, offsetInWindow);
    }

    @Override
    public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
        return mChildHelper.dispatchNestedFling(velocityX, velocityY,
                consumed);
    }

    @Override
    public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
        return mChildHelper.dispatchNestedPreFling(velocityX, velocityY);
    }
}
           

大功告成,是不是很爽。而根據我們之前的定義,我們重寫

onTouchEvent()

來實作流程。

@Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                // 啟動滑動,傳入方向
                startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL);
                // 記錄y值
                mOldY = (int) event.getRawY();
                break;
            case MotionEvent.ACTION_MOVE:
                int y = (int) event.getRawY();
                // 計算y值的偏移
                int offsetY = y - mOldY;
                Log.i(TAG, mConsumed[] + ":" + mConsumed[] + "--" + mOffset[] + ":" + mOffset[]);
                // 通知父類,如果傳回true,表示父類消耗了觸摸
                if (dispatchNestedPreScroll(, offsetY, mConsumed, mOffset)) {
                    offsetY -= mConsumed[];
                }
                int unConsumed = ;
                float targetY = getTranslationY() + offsetY;
                if (targetY > - && targetY < ) {
                    setTranslationY(targetY);
                } else {
                    unConsumed = offsetY;
                    offsetY = ;
                }
                // 滾動完成之後,通知目前滑動的狀态
                dispatchNestedScroll(, offsetY, , unConsumed, mOffset);
                Log.i(TAG, mConsumed[] + ":" + mConsumed[] + "--" + mOffset[] + ":" + mOffset[]);
                mOldY = y;
                break;
            case MotionEvent.ACTION_UP:
                // 滑動結束
                stopNestedScroll();
                break;
            default:
                break;
        }
        return true;
    }
           

分析一下這個流程,在

ACTION_DOWN

時,記錄

y

值,并調用

startNestedScroll()

通知滑動開始。然後,在

ACTION_MOVE

中,根據每次的y值偏移,調用

dispatchNestedPreScroll()

通知父控件,我要開始偏移了,然後根據

mConsumed

判斷父控件消耗的偏移量,并擷取剩餘偏移量,然後開始處理自己的滾動,滾動完成之後通知父控件,目前控件滾動狀态(已滑動的和未消耗的)。

子控件的編寫到此結束,到這裡可能依然一頭霧水,但不要緊,下面通過實作父控件,這些方法就能串聯起來。

NestedScrollingParent

NestedScrollingParentHelper

NestedScrollingChild

中,其大部分方法需要手動調用,因為其作為事件的第一處理者,事件的所有他最清楚。而

NestedScrollingParent

,其中的方法不需要我們手動調用,他通常是作為回調的方法,在方法裡處理子控件通知的回調。

首先看一下

NestedScrollingParent

中所有定義的方法:

//  child : 忽略(後面說)
// target: 滑動的目标view
// nestedScrollAxes: 滑動的方向
public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes);
           

開始滑動時的回調,傳回true表示父控件要處理觸摸。和

child

startNestedScroll()

對應

public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes);
           

接受到開始滑動,在這個方法裡做一些初始化。和

child

startNestedScroll()

對應。

public void onNestedPreScroll(View target, int dx, int dy, int[] consumed);
           

子控件準備滾動之前的通知,和

child

dispatchNestedPreScroll()

相對應。如果該方法消費了dx或dy,則在

consumed[0|1]

的對應索引上添加消耗的置。

public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed);
           

子控件滑動完成之後的通知,和

child

dispatchNestedScroll()

對應。

public boolean onNestedPreFling(View target, float velocityX, float velocityY);
           

子控件開始慣性滑動之前的通知,傳回

true

表示父控件處理滑動。和

child

dispatchNestedPreFling()

對應。

public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed);
           

子控件慣性滑動完成之後的通知,和

child

dispatchNestedFling()

對應。其中

consumed

表示子控件是否消費了滑動。

public void onStopNestedScroll(View target);
           

滑動結束的回調,和

child

stopNestedScroll()

對應。

public int getNestedScrollAxes();
           

擷取目前滑動的方向。

可以看到,大部分方法是以

onXXX

命名的,他們和

onClickListener

類似,這些方法中主要對相應事件的回調做處理,對于目前,就是對子控件的滑動狀态回調做處理。

對于

NestedScrollingParent

,起需要我們實作的不多,主要的是對回調通知做處理,是以相應的輔助類

NestedScrollingParentHelper

隻是做了最基本的狀态的情況與儲存。

那麼實作如下:

public class NestParent extends LinearLayout implements NestedScrollingParent {

    private static final String TAG = NestParent.class.getSimpleName();

    NestedScrollingParentHelper mParentHelper;

    public NestParent(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, );
    }


    public NestParent(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mParentHelper = new NestedScrollingParentHelper(this);
    }

    @Override
    public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
        // 滑動的child  , 目标child , 兩者唯一
        // child  嵌套滑動的子控件(目前控件的子控件) , target , 手指觸摸的控件
        Log.i(TAG, "onStartNestedScroll:" + child.getClass().getSimpleName() + ":" + target.getClass().getSimpleName());
        return true;
    }

    @Override
    public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes) {
        mParentHelper.onNestedScrollAccepted(child, target, nestedScrollAxes);
    }


    @Override
    public void onStopNestedScroll(View target) {
        Log.i(TAG, "onStopNestedScroll" + target.getClass().getSimpleName());
        mParentHelper.onStopNestedScroll(target);
    }

    @Override
    public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
        Log.i(TAG, "onNestedScroll" + target.getClass().getSimpleName());
        Log.i(TAG, "dxUnconsumed:" + dxUnconsumed + "dyUnconsumed:" + dyUnconsumed);

        getChildAt().setTranslationY(getChildAt().getTranslationY() + dyUnconsumed);
    }

    @Override
    public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
        Log.i(TAG, "onNestedPreScroll" + target.getClass().getSimpleName());
        // 開始滑動之前
        Log.i(TAG, consumed[] + ":" + consumed[]);

//        consumed[1] = 10;// 消費10px

    }

    @Override
    public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) {
        // 慣性滑動
        return false;
    }

    @Override
    public boolean onNestedPreFling(View target, float velocityX, float velocityY) {
        return false;
    }

    @Override
    public int getNestedScrollAxes() {
        // 垂直滾動
        return mParentHelper.getNestedScrollAxes();
    }
           

可以看到

NestedScrollingParentHelper

中主要處理了

getNestedScrollAxes()

onNestedScrollAccepted()

onNestedScrollAccepted()

,如果看源碼,其實就是儲存滑動方向狀态和釋放。

流程分析

到此,代碼工作基本上結束,我在上面添加了一些log,讓我們運作以下程式。簡單的滑動後

log

如下。

NestChild: startNestedScroll
NestParent: onStartNestedScroll
// 循環
NestChild: dispatchNestedPreScroll
NestParent: onNestedPreScroll
//  子控件消耗
NestChild: dispatchNestedScroll
NestParent: onNestedScroll    

NestChild: dispatchNestedPreScroll
NestParent: onNestedPreScroll
NestChild: dispatchNestedScroll
NestParent: onNestedScroll
// ......
NestChild: stopNestedScroll
NestParent: onStopNestedScroll
           

基本的log如下,通過此log可總結流程如下

  • child:startNestedScroll

    : 子控件開始滑動,調用該方法通知父類滑動即将開始。(DOWN)
  • parent: onStartNestedScroll

    :父控件收到子控件滑動狀态的通知。
  • child:dispatchNestedPreScroll

    :子控件開始滑動的回調,和第一種差別在于此時有具體的滑動距離。
  • parent:onNestedPreScroll

    :父控件收到子控件準備滑動的通知,根據情況是否消耗滑動。
  • child: dispatchNestedScroll

    :子控件滑動完之後調用,通知父控件
  • parent:onNestedScrollNestChild

    :父控件收到子控件滑動之後的通知
  • child:stopNestedScroll:

    : 子控件滑動結束的通知。
  • parent : onStopNestedScroll

    : 父控件收到子控件滑動結束的通知

根據上面的分析,可以看到整個流程都是

child

parent

一一對應的。

簡單的源碼分析

首先從

child

startNestedScroll()

方法,其調用

mChildHelper.startNestedScroll(axes)

,再往下跟如下

public boolean startNestedScroll(int axes) {
        if (hasNestedScrollingParent()) {
            // Already in progress
            return true;
        }
        if (isNestedScrollingEnabled()) {
            ViewParent p = mView.getParent();
            View child = mView;
            while (p != null) {
                // 擷取處理嵌套滑動的父控件
                if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes)) {
                    mNestedScrollingParent = p;
                    ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes);
                    return true;
                }
                if (p instanceof View) {
                    child = (View) p;
                }
                p = p.getParent();
            }
        }
        return false;
    }
           

在該方法中,首先判斷是否有父類在滾動,如果沒有,判斷目前控件是否可以嵌套滾動,然後擷取他的父控件,判斷父控件是否處理嵌套滾動,如果處理,則結束循環。否則,以目前父控件為跟,擷取父控件的父控件,繼續判斷。

parent

public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes)

中有參數

child

target

,通過改遠嗎不難發現,

child

就為循環的

child

,而

target

為循環中的

mView

再看一下準備滑動的實作方法

mChildHelper.dispatchNestedPreScroll(dx, dy, consumed,offsetInWindow)

public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
    // 是否可以嵌套滑動的基本判斷
        if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
            if (dx !=  || dy != ) {
                int startX = ;
                int startY = ;
                if (offsetInWindow != null) {
                  // 儲存子控件的位置狀态
                    mView.getLocationInWindow(offsetInWindow);
                    startX = offsetInWindow[];
                    startY = offsetInWindow[];
                }

                if (consumed == null) {
                    if (mTempNestedScrollConsumed == null) {
                        mTempNestedScrollConsumed = new int[];
                    }
                    consumed = mTempNestedScrollConsumed;
                }
                consumed[] = ;
                consumed[] = ;
                // 調用父控件,事件分發
                ViewParentCompat.onNestedPreScroll(mNestedScrollingParent, mView, dx, dy, consumed);

                if (offsetInWindow != null) {
                   // 重新計算子控件的位置并擷取偏移
                    mView.getLocationInWindow(offsetInWindow);
                    offsetInWindow[] -= startX;
                    offsetInWindow[] -= startY;
                }
                return consumed[] !=  || consumed[] != ;
            } else if (offsetInWindow != null) {
                offsetInWindow[] = ;
                offsetInWindow[] = ;
            }
        }
        return false;
    }
           

剩下的方法大體就是如上邏輯,不在多做分析。

系統控件中已經預設實作兩個接口的類

  • 實作

    NestedScrollingChild

    的類

NestedScrollView

HorizontalGridView

RecyclerView

SwipeRefreshLayout

VerticalGridView

  • 實作

    NestedScrollingParent

    的類

NestedScrollView

CoordinatorLayout

SwipeRefreshLayout

總結

對于Android中基礎的事件體系,一旦子控件擷取到事件并處理,父控件很難在處理滑動。而

NestedScrolling

的機制便是負責子控件擷取完事件之後的滑動分發。能夠通過

NestedScrolling

機制,在必要時,将子控件的滑動事件交給父控件去處理。