NestedScrolling機制解析,自定義巢狀滑動你也可以

不做一知半解的小路發表於2019-09-26

概述

如果在以前要實現巢狀滑動,比如ScrollView巢狀RecyclerView,這時候常用的方法就是重寫onMeasure方法,進行重新測量。現在官方提供了兩個神奇的介面,幫助我們實現複雜的巢狀以及更加炫酷的效果,那就是NestedScrollingChild2NestedScrollingParent2

我們先來看下要實現的效果:

NestedScrolling機制解析,自定義巢狀滑動你也可以

其實這是一個RecyclerView中一個模版是帶有RecyclerView的佈局,也就是說 RecyclerView巢狀RecyclerView。

那麼如何才能做到像上圖的中效果呢,我們來簡單分析一下:

假如我們用事件分發的思路去分析,首先當父RecylerView滑動到子RecyclerView時,父RecyclerView不應該再攔截滑動事件,當子RecyclerView從頂部往下滑動時,父RecyclerView要攔截子RecyclerView的滑動事件。但是如何判斷分發的時機呢,又要確保如此的流暢。

NestedScrolling機制可以很好的幫我們去實現這樣的效果。

原理

我們知道RecyclerView是實現了NestedScrollingChild2介面的,我們來看下它的幾個方法:


  /**
     * 開啟一個巢狀滑動
     *
     * @param axes 支援的巢狀滑動方法,分為水平方向,豎直方向,或不指定
     * @param type 滑動事件型別
     * @return 如果返回true, 表示當前子控制元件已經找了一起巢狀滑動的view
     */
    public boolean startNestedScroll(int axes, int type) {}
 
    /**
     * 在子控制元件滑動前,將事件分發給父控制元件,由父控制元件判斷消耗多少
     *
     * @param dx             水平方向巢狀滑動的子控制元件想要變化的距離 dx<0 向右滑動 dx>0 向左滑動
     * @param dy             垂直方向巢狀滑動的子控制元件想要變化的距離 dy<0 向下滑動 dy>0 向上滑動
     * @param consumed       子控制元件傳給父控制元件陣列,用於儲存父控制元件水平與豎直方向上消耗的距離,consumed[0] 水平消耗的距離,consumed[1] 垂直消耗的距離
     * @param offsetInWindow 子控制元件在當前window的偏移量
     * @return 如果返回true, 表示父控制元件已經消耗了
     */
    public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow, int type) {}
 
 
    /**
     * 當父控制元件消耗事件後,子控制元件處理後,又繼續將事件分發給父控制元件,由父控制元件判斷是否消耗剩下的距離。
     *
     * @param dxConsumed     水平方向巢狀滑動的子控制元件滑動的距離(消耗的距離)
     * @param dyConsumed     垂直方向巢狀滑動的子控制元件滑動的距離(消耗的距離)
     * @param dxUnconsumed   水平方向巢狀滑動的子控制元件未滑動的距離(未消耗的距離)
     * @param dyUnconsumed   垂直方向巢狀滑動的子控制元件未滑動的距離(未消耗的距離)
     * @param offsetInWindow 子控制元件在當前window的偏移量
     * @return 如果返回true, 表示父控制元件又繼續消耗了
     */
    public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow, int type) {}
 
    /**
     * 子控制元件停止巢狀滑動
     */
    public void stopNestedScroll(int type) {}
 
 
    /**
     * 當子控制元件產生fling滑動時,判斷父控制元件是否處攔截fling,如果父控制元件處理了fling,那子控制元件就沒有辦法處理fling了。
     *
     * @param velocityX 水平方向上的速度 velocityX > 0  向左滑動,反之向右滑動
     * @param velocityY 豎直方向上的速度 velocityY > 0  向上滑動,反之向下滑動
     * @return 如果返回true, 表示父控制元件攔截了fling
     */
    public boolean dispatchNestedPreFling(float velocityX, float velocityY) {}
 
    /**
     * 當父控制元件不攔截子控制元件的fling,那麼子控制元件會呼叫該方法將fling,傳給父控制元件進行處理
     *
     * @param velocityX 水平方向上的速度 velocityX > 0  向左滑動,反之向右滑動
     * @param velocityY 豎直方向上的速度 velocityY > 0  向上滑動,反之向下滑動
     * @param consumed  子控制元件是否可以消耗該fling,也可以說是子控制元件是否消耗掉了該fling
     * @return 父控制元件是否消耗了該fling
     */
    public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {}
 
    /**
     * 設定當前子控制元件是否支援巢狀滑動,如果不支援,那麼父控制元件是不能夠響應巢狀滑動的
     *
     * @param enabled true 支援
     */
    public void setNestedScrollingEnabled(boolean enabled) {}
 
    /**
     * 當前子控制元件是否支援巢狀滑動
     */
    public boolean isNestedScrollingEnabled() {}
 
    /**
     * 判斷當前子控制元件是否擁有巢狀滑動的父控制元件
     */
    public boolean hasNestedScrollingParent(int type) {}

複製程式碼

我們看原始碼裡會發現其實裡面的實現都是通過NestedScrollingChildHelper去實現的。

那麼它是怎麼呼叫Child2中的方法以及怎麼回撥給NestedScrollingParent的各種方法呢?

我們來看下RecyclerView的onTouchEvent方法:

    public boolean onTouchEvent(MotionEvent e) {
    .....
                int nestedScrollAxis;
                switch(action) {
                case MotionEvent.ACTION_DOWN:
                    this.mScrollPointerId = e.getPointerId(0);
                    this.mInitialTouchX = this.mLastTouchX = (int)(e.getX() + 0.5F);
                    this.mInitialTouchY = this.mLastTouchY = (int)(e.getY() + 0.5F);
                    nestedScrollAxis = 0;
                    if (canScrollHorizontally) {
                        nestedScrollAxis |= 1;
                    }

                    if (canScrollVertically) {
                        nestedScrollAxis |= 2;
                    }

                    this.startNestedScroll(nestedScrollAxis, 0);
                    break;
                case MotionEvent.ACTION_UP:
                    .....
                    if (xvel == 0.0F && yvel == 0.0F || !this.fling((int)xvel, (int)yvel)) {
                        this.setScrollState(0);
                    }

                    this.resetTouch();
                    break;
                case MotionEvent.ACTION_MOVE:
                    nestedScrollAxis = e.findPointerIndex(this.mScrollPointerId);
                    if (nestedScrollAxis < 0) {
                        return false;
                    }

                    int x = (int)(e.getX(nestedScrollAxis) + 0.5F);
                    int y = (int)(e.getY(nestedScrollAxis) + 0.5F);
                    int dx = this.mLastTouchX - x;
                    int dy = this.mLastTouchY - y;
                    if (this.dispatchNestedPreScroll(dx, dy, this.mScrollConsumed, this.mScrollOffset, 0)) {
                        dx -= this.mScrollConsumed[0];
                        dy -= this.mScrollConsumed[1];
                        vtev.offsetLocation((float)this.mScrollOffset[0], (float)this.mScrollOffset[1]);
                        int[] var10000 = this.mNestedOffsets;
                        var10000[0] += this.mScrollOffset[0];
                        var10000 = this.mNestedOffsets;
                        var10000[1] += this.mScrollOffset[1];
                    }
                    ...
                    NestedScrollingChildHelperNestedScrollingChildHelperNestedScrollingChildHelperNestedScrollingChildHelperNestedScrollingChildHelper;
                }

                if (!eventAddedToVelocityTracker) {
                    this.mVelocityTracker.addMovement(vtev);
                }

                vtev.recycle();
                return true;
            }
        } else {
            return false;
        }
    }
複製程式碼

從上面的程式碼中可以看出ACTION_DOWN呼叫了startNestedScroll方法;ACTION_MOVE呼叫了 dispatchNestedPreScroll;ACTION_UP呼叫了fling

我們再來看下NestedScrollingChildHelperstartNestedScroll的實現方法:

    public boolean startNestedScroll(int axes, int type) {
        if (this.hasNestedScrollingParent(type)) {
            // Already in progress
            return true;
        } else {
            if (this.isNestedScrollingEnabled()) {
                ViewParent p = this.mView.getParent();

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

                    if (p instanceof View) {
                        child = (View)p;
                    }
                }
            }

            return false;
        }
    }
複製程式碼

ViewParentCompat中的程式碼我就不貼了,其實這段程式碼就是去尋找父類的NestedScrollingParent,如果找到就會回撥onStartNestedScroll和onNestedScrollAccepted

我們簡單看下NestedScrollingParent2的實現方法:

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

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

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

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

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

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

public int getNestedScrollAxes();
複製程式碼

同樣的,我們也可以在NestedScrollingChildHelper看到其他方法的實現:dispatchNestedPreScroll中會回撥onNestedPreScroll方法。

在RecyclerView中的scrollByInternal中呼叫了dispatchNestedScroll, 在dispatchNestedScroll中會回撥onNestedScroll

fling方法中會回撥onNestedPreFling和onNestedFling方法。

resetTouch方法中則會回撥onStopNestedScroll。

我畫了一個簡單的示意圖來幫助梳理下我上面說到的這些:

NestedScrolling機制解析,自定義巢狀滑動你也可以

實現

看了上面的實現原理,我們來實現下gif圖的效果。

首先,實現NestedScrollingParent2介面,建立一個NestedScrollingParentHelper實現類。

    public NestedContainerRecyclerView(
            @NonNull Context context, @Nullable AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        nestedScrollingParentHelper = new NestedScrollingParentHelper(this);
        setNestedScrollingEnabled(false);
        setOverScrollMode(OVER_SCROLL_NEVER);
    }

複製程式碼

預設不開啟巢狀滑動。

    @Override
    public boolean onStartNestedScroll(
            @NonNull View child, @NonNull View target, int axes, int type) {
        if (axes == ViewCompat.SCROLL_AXIS_HORIZONTAL) {
            return false;
        }
        this.mTouchType = type;
        targetChild = new WeakReference<View>(target);
        itemChild = new WeakReference<View>(getItemChild(child));
        return true;
    }
複製程式碼

在開始巢狀滑動的時候,判斷一下滑動的方向,如果是水平方向,不進行巢狀滑動。targetChild用於儲存當前的可滑動View,itemChild 用於儲存可滑動的View當前的佈局,看下getItemChild的實現,就懂了。

    private View getItemChild(View target) {
        ViewParent parent = target.getParent();
        if (parent == null) {
            return null;
        }
        if (parent == this) {
            return target;
        } else if (parent instanceof View) {
            return getItemChild((View) parent);
        }
        return null;
    }
複製程式碼

接下來就到了我們的重頭戲登場的時候了,鐺鐺鐺:

    public void onNestedScroll(
            @NonNull View target,
            int dxConsumed,
            int dyConsumed,
            int dxUnconsumed,
            int dyUnconsumed,
            int type) {
        if (dyUnconsumed == 0) {
            return;
        }
        if (type == ViewCompat.TYPE_TOUCH) {
            if (dyUnconsumed > 0) {
                if ((!target.canScrollVertically(1) || getItemChildY() != itemTargetY)
                        && canScrollVertically(1)) {
                    scrollBy(0, dyUnconsumed);
                }
            } else {
                if ((!target.canScrollVertically(-1) || getItemChildY() != itemTargetY)
                        && canScrollVertically(-1)) {
                    scrollBy(0, dyUnconsumed);
                }
            }
        } else if (type == ViewCompat.TYPE_NON_TOUCH) {
            if (dyUnconsumed > 0) {
                boolean canscroll = target.canScrollVertically(1);
                if ((!canscroll || getItemChildY() != itemTargetY) && canScrollVertically(1)) {
                    scrollBy(0, dyUnconsumed);
                }
            } else {
                boolean canscroll = target.canScrollVertically(-1);
                if ((!canscroll || getItemChildY() != itemTargetY) && canScrollVertically(-1)) {
                    scrollBy(0, dyUnconsumed);
                    if (getItemChildY() > getHeight() + dyUnconsumed) {
                        fling(0, dyUnconsumed * VELUE);
                    }
                }
            }
        }
    }
複製程式碼

這裡呢,補充一個小知識點,就是判斷一個view是否到頂或者到底的方法:

canScrollVertically(1)的值表示是否能向上滾動,false表示已經滾動到底部
canScrollVertically(-1)的值表示是否能向下滾動,false表示已經滾動到頂部
複製程式碼

在onNestedScroll時,如果是向上滑動,當前RecyclerView並未滑動到底部並且可滑動View(以後簡稱targetView)已經滑動到底部或者targe當前所有的View的getY(相對於父控制元件的y軸距離)不等於itemTargetY時,當前RecyclerView消費點剩餘的距離。

itemTargetY是什麼呢,我們在佈局的時候,targetView所有的佈局不一定充滿RecyclerView,可能上方會有一定的距離,這個距離就是itemTargetY。

那麼getItemChildY裡是怎麼實現的呢?看下面:

    private int getItemChildY() {
        if (itemChild == null || itemChild.get() == null) {
            return -10000;
        }
        return (int) (itemChild.get().getY() + 0.5f);
    }
複製程式碼

同理,在向下滑動的時候,我們通過targetView是否到底以及當前RecyclerView是否到底來判斷是否消費剩餘的滑動距離。

   @Override
    public void onNestedPreScroll(@NonNull View target, int dx, int dy, int[] consumed, int type) {
        if (dy != 0) {
            if (type == ViewCompat.TYPE_TOUCH && itemChild != null && itemChild.get() != null) {
                int y = getItemChildY();
                if (dy > 0) {
                    if (y != itemTargetY) {
                        if (!canScrollVertically(1)) {
                            scrollBy(0, y - itemTargetY);
                            consumed[1] = y - itemTargetY;
                        } else {
                            scrollBy(0, dy);
                            y -= getItemChildY();
                            consumed[1] = y;
                        }
                    } else if (!target.canScrollVertically(1) && canScrollVertically(1)) {
                        scrollBy(0, dy);
                        y -= getItemChildY();
                        consumed[1] = y;
                    }

                } else {
                    if (target instanceof RecyclerView) {
                        RecyclerView tRecyclerView = (RecyclerView) target;
                        if ((tRecyclerView.computeVerticalScrollOffset() <= 0
                                        || y - itemTargetY > 1)
                                && canScrollVertically(-1)) {
                            scrollBy(0, dy);
                            y -= getItemChildY();
                            consumed[1] = y;
                        }
                    } else {
                        if ((!target.canScrollVertically(-1) || y - itemTargetY > 1)
                                && canScrollVertically(-1)) {
                            scrollBy(0, dy);
                            y -= getItemChildY();
                            consumed[1] = y;
                        }
                    }
                }
            }
            if (type == ViewCompat.TYPE_NON_TOUCH && itemChild != null && itemChild.get() != null) {
                int y = getItemChildY();
                if (dy > 0) {
                    if ((!target.canScrollVertically(1) || y != itemTargetY)
                            && canScrollVertically(1)) {
                        scrollBy(0, dy);
                        y -= getItemChildY();
                        consumed[1] = y;
                    }
                } else {
                    if ((!target.canScrollVertically(-1) || y != itemTargetY)
                            && canScrollVertically(-1)) {
                        scrollBy(0, dy);
                        y -= getItemChildY();
                        consumed[1] = y;
                    }
                }
            }
        }
    }
複製程式碼

在onNestedPreScoll中,我們判斷,如果是上滑時,判斷targeView是否到底以及RecyclerView是否到底來判斷是否消費dy,如果targetView到底,RecyclerView並沒有到底,則消費dy,如果targetView所在View的getY不等於itemTargetY且RecyclerView到底,則消費dy-itemTargetY。

此外,還處理了onNestedFling以及dispatchNestedScroll,更詳細的程式碼請見github地址。 AndroidHighlights

注意:要準確設定子RecyclerView的高度,也就是targetView的高度,推薦高度為

recyclerView.getHeight - itemTargetY
複製程式碼

如有錯誤或者不同意見,歡迎指出、探討。感謝閱讀到這裡的所有人。

相關文章