Android嵌套滑動(dòng)機(jī)制實(shí)戰(zhàn)演練

前言

最近產(chǎn)品提了個(gè)需求,要把商品列表做成類似淘寶的樣式

淘寶

一般遇到這種需求,我們首先會(huì)想到的是,攔截TouchEvent,然后自己來處理滑動(dòng),這種方法雖然行得通,但是代碼寫起來非常惡心,且滑動(dòng)沖突會(huì)比較多,使用NestedScrolling API會(huì)簡(jiǎn)單優(yōu)雅很多。

先上效果圖

Touch嵌套
fling嵌套

API分析

NestedScrollingParent

Parent接口共有以下幾個(gè)方法

public interface NestedScrollingParent {
    //當(dāng)子View開始滑動(dòng)時(shí),會(huì)觸發(fā)這個(gè)方法,判斷接下來是否進(jìn)行嵌套滑動(dòng),
    //返回false,則表示不使用嵌套滑動(dòng)
    boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes);

    //onStartNestedScroll如果返回true,那么接下來就會(huì)調(diào)用這個(gè)方法,用來做一些初始化操作,一般可以忽略
    void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes);

    //嵌套滑動(dòng)結(jié)束時(shí)會(huì)觸發(fā)這個(gè)方法
    void onStopNestedScroll(@NonNull View target);

    //子View滑動(dòng)時(shí)會(huì)觸發(fā)這個(gè)方法,dyConsumed代表子View滑動(dòng)的距離,dyUnconsumed代表子View本次滑動(dòng)未消耗的距離,比如RecyclerView滑到了邊界,那么會(huì)有一部分y未消耗掉
    void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed);

    //子View開始滑動(dòng)時(shí),會(huì)觸發(fā)這個(gè)回調(diào),dy表示滑動(dòng)的y距離,consumed數(shù)組代表父View要消耗的距離,假如consumed[1] = dy,那么子View就不會(huì)滑動(dòng)了
    void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed);

    //當(dāng)子View fling時(shí),會(huì)觸發(fā)這個(gè)回調(diào),consumed代表速度是否被子View消耗掉,比如RecyclerView滑動(dòng)到了邊界,那么它顯然沒法消耗本次的fling
    boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed);

    //當(dāng)子View要開始fling時(shí),會(huì)先詢問父View是否要攔截本次fling,返回true表示要攔截,那么子View就不會(huì)慣性滑動(dòng)了
    boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY);

    //表示目前正在進(jìn)行的嵌套滑動(dòng)的方向,值有ViewCompat.SCROLL_AXIS_HORIZONTAL 或者ViewCompat.SCROLL_AXIS_VERTICAL或者SCROLL_AXIS_NONE
    @ScrollAxis
    int getNestedScrollAxes();
}

NestedScrollingChild

public interface NestedScrollingChild {
    //設(shè)置當(dāng)前子View是否支持嵌套滑動(dòng)
    void setNestedScrollingEnabled(boolean enabled);

    //當(dāng)前子View是否支持嵌套滑動(dòng)
    boolean isNestedScrollingEnabled();

    //開始嵌套滑動(dòng),對(duì)應(yīng)Parent的onStartNestedScroll
    boolean startNestedScroll(@ScrollAxis int axes);

    //停止本次嵌套滑動(dòng),對(duì)應(yīng)Parent的onStopNestedScroll
    void stopNestedScroll();

    //true表示這個(gè)子View有一個(gè)支持嵌套滑動(dòng)的父View
    boolean hasNestedScrollingParent();

    //通知父View子View開始滑動(dòng)了,對(duì)應(yīng)父View的onNestedScroll方法
    boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);

    //通知父View即將開始滑動(dòng)了,對(duì)應(yīng)父View的onNestedPreScroll方法
    boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
            @Nullable int[] offsetInWindow);

    //通知父View開始Fling了,對(duì)應(yīng)Parent的onNestedFling方法
    boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);

    //通知父View要開始fling了,對(duì)應(yīng)Parent的onNestedPreFling方法
    boolean dispatchNestedPreFling(float velocityX, float velocityY);
}

整體流程描述如下(以RecyclerView為例):

child.ACTION_DOWN
-> child.startNestedScroll
-> parent.onStartNestedScroll (如果返回false,則流程終止)
-> parent.onNestedScrollAccepted
-> child.ACTION_MOVE
-> child.dispatchNestedPreScroll
-> parent.onNestedPreScroll
-> child.ACTION_UP
-> chid.stopNestedScroll
-> parent.onStopNestedScroll
-> child.fling
-> child.dispatchNestedPreFling
-> parent.onNestedPreScroll
-> child.dispatchNestedFling
-> parent.onNestedFling

有興趣的朋友可以直接查看 RecyclerView 的源碼

子View向上傳遞事件時(shí),是循環(huán)向上的,即 Parent 不需要是 Child 的直接 ViewParent,具體可以看代碼,以startNestedScroll為例

   public boolean startNestedScroll(int axes) {
        if (hasNestedScrollingParent()) {
            // Already in progress
            return true;
        }
        if (isNestedScrollingEnabled()) {
            ViewParent p = getParent();
            View child = this;
            while (p != null) {
                try {
                    if (p.onStartNestedScroll(child, this, axes)) {
                        mNestedScrollingParent = p;
                        p.onNestedScrollAccepted(child, this, axes);
                        return true;
                    }
                } catch (AbstractMethodError e) {
                    Log.e(VIEW_LOG_TAG, "ViewParent " + p + " does not implement interface " +
                            "method onStartNestedScroll", e);
                    // Allow the search upward to continue
                }
                if (p instanceof View) {
                    child = (View) p;
                }
                p = p.getParent();
            }
        }
        return false;
    }

具體實(shí)現(xiàn)

頁(yè)面結(jié)構(gòu)

頁(yè)面結(jié)構(gòu)

事件攔截

RV 嵌套 RV 時(shí),內(nèi)層 RV 是無(wú)法滑動(dòng)的,然而,當(dāng)外層RV在Fling時(shí),如果我們觸摸到子RV,那么會(huì)有一定概率導(dǎo)致子RV接收到Touch事件并開始滾動(dòng),所以我們需要同時(shí)攔截內(nèi)層和外層的RV的事件。大概思路如下:

  • 當(dāng)向下滑動(dòng)時(shí),判斷TabLayout是否置頂,如果未置頂,則滑動(dòng)外層RV;如果TabLayout已經(jīng)置頂,則滑動(dòng)子RV

  • 當(dāng)向上滑動(dòng)時(shí),判斷TabLayout是否置頂,如果未置頂,則滑動(dòng)外層RV;如果TabLayout已經(jīng)置頂,則判斷子RV能否向上滑動(dòng),如果可以,則滑動(dòng)子RV,否則滑動(dòng)外層RV

具體處理為,我們?cè)谕鈱覴V之上嵌套一層自定義的FrameLayout,并開啟外層RV和內(nèi)層RV的嵌套滑動(dòng)功能,那么我們就能在FrameLayout中接收到RV傳遞上來的scroll和fling事件

滾動(dòng)處理

public class NestedScrollLayout extends FrameLayout {
    private View mChildView;
    /**
     * 最外層的RecyclerView
     */
    private RecyclerView mRootList;
    /**
     * 子RecyclerView
     */
    private RecyclerView mChildList;
    @Override
    public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int nestedScrollAxes) {
        //這里表示只有在縱向滑動(dòng)時(shí),我們才攔截事件
        return nestedScrollAxes == ViewCompat.SCROLL_AXIS_VERTICAL;
    }
    @Override
    public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed) {
        stopScroller();
        //mChildView表示TabLayout和ViewPager的父View,比如說我們用一個(gè)LinearLayout包裹住TabLayout和ViewPager
        if (mChildView == null) {
            return;
        }
        if (target == mRootList) {
            onParentScrolling(mChildView.getTop(), dy, consumed);
        } else {
            onChildScrolling(mChildView.getTop(), dy, consumed);
        }
    }
    /**
     * 父列表在滑動(dòng)
     *
     * @param childTop
     * @param dy
     * @param consumed
     */
    private void onParentScrolling(int childTop, int dy, int[] consumed) {
        //列表已經(jīng)置頂
        if (childTop == 0) {
            if (dy > 0 && mChildList != null) {
                //還在向下滑動(dòng),此時(shí)滑動(dòng)子列表
                mChildList.scrollBy(0, dy);
                consumed[1] = dy;
            } else {
                if (mChildList != null && mChildList.canScrollVertically(dy)) {
                    consumed[1] = dy;
                    mChildList.scrollBy(0, dy);
                }
            }
        } else {
            if (childTop < dy) {
                consumed[1] = dy - childTop;
            }
        }
    }

    private void onChildScrolling(int childTop, int dy, int[] consumed) {
        if (childTop == 0) {
            if (dy < 0) {
                //向上滑動(dòng)
                if (!mChildList.canScrollVertically(dy)) {
                    consumed[1] = dy;
                    mRootList.scrollBy(0, dy);
                }
            }
        } else {
            if (dy < 0 || childTop > dy) {
                consumed[1] = dy;
                mRootList.scrollBy(0, dy);
            } else {
                //dy大于0
                consumed[1] = dy;
                mRootList.scrollBy(0, childTop);
            }
        }
    }
    /**
     * 表示我們只接收縱向的事件
     * @return
     */
    @Override
    public int getNestedScrollAxes() {
        return ViewCompat.SCROLL_AXIS_VERTICAL;
    }
}

ViewGroup默認(rèn)實(shí)現(xiàn)了Parent接口,這里我們不需要再implement一次

Fling處理

當(dāng)列表開始 Fling 時(shí),我們將會(huì)接收到相應(yīng)的回調(diào),這里我們需要自己處理慣性滑動(dòng),使用 OverScroller 來替我們模擬Fling

public class NestedScrollLayout extends FrameLayout {
    /**
     * 用來處理Fling
     */
    private OverScroller mScroller;

    private int mLastY;

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

    @Override
    public boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY) {
        mLastY = 0;
        this.mScroller.fling(0, 0, (int) velocityX, (int) velocityY, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
        invalidate();
        return true;
    }
    @Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset()) {
            int currY = mScroller.getCurrY();
            int dy = currY - mLastY;
            mLastY = currY;
            if (dy != 0) {
                onFling(dy);
            }
            invalidate();
        }
        super.computeScroll();
    }
    private void onFling(int dy) {
        if (mChildView != null) {
            //子列表有顯示
            int top = mChildView.getTop();
            if (top == 0) {
                if (dy > 0) {
                    if (mChildList != null && mChildList.canScrollVertically(dy)) {
                        mChildList.scrollBy(0, dy);
                    } else {
                        stopScroller();
                    }
                } else {
                    if (mChildList != null && mChildList.canScrollVertically(dy)) {
                        mChildList.scrollBy(0, dy);
                    } else {
                        mRootList.scrollBy(0, dy);
                    }
                }
            } else {
                if (dy > 0) {
                    if (top > dy) {
                        mRootList.scrollBy(0, dy);
                    } else {
                        mRootList.scrollBy(0, top);
                    }
                } else {
                    if (mRootList.canScrollVertically(dy)) {
                        mRootList.scrollBy(0, dy);
                    } else {
                        stopScroller();
                    }
                }
            }
        } else {
            if (!mRootList.canScrollVertically(dy)) {
                stopScroller();
            } else {
                mRootList.scrollBy(0, dy);
            }
        }
    }
}

到這里為止,我們要的效果已經(jīng)實(shí)現(xiàn)了,mChildView 和子RV何時(shí)賦值,參考Demo即可。

新版API

你以為這樣就完了?


快閃開,我要開始裝逼了

谷歌在 26.1.0 的 support 包中加入了兩個(gè)新的 API

這兩個(gè)接口各自繼承了NestedScrollingParent和NestedScrollingChild

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, @NonNull int[] consumed,
            @NestedScrollType int type);

}
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);
}

在新的API中去掉了 fling 回調(diào),并且增加了 type 參數(shù),type分為兩種

    //表示當(dāng)前事件是由用戶手指觸摸產(chǎn)生的
    public static final int TYPE_TOUCH = 0;

    //表示當(dāng)前事件不是用戶手指觸摸產(chǎn)生的,一般是fling
    public static final int TYPE_NON_TOUCH = 1;

Parent2具體流程如下:

child.ACTION_DOWN
-> child.startNestedScroll (TYPE_TOUCH)
-> parent.onStartNestedScroll (TYPE_TOUCH) (如果返回false,則流程終止)
-> parent.onNestedScrollAccepted (TYPE_TOUCH)
-> child.ACTION_MOVE
-> child.dispatchNestedPreScroll (TYPE_TOUCH)
-> parent.onNestedPreScroll (TYPE_TOUCH)
-> child.ACTION_UP
-> chid.stopNestedScroll (TYPE_TOUCH)
-> parent.onStopNestedScroll (TYPE_TOUCH)
-> child.fling
-> child.startNestedScroll (TYPE_NON_TOUCH)
-> parent.onStartNestedScroll (TYPE_NON_TOUCH) (如果返回false,則流程終止)
-> parent.onNestedScrollAccepted (TYPE_NON_TOUCH)
-> child.dispatchNestedPreScroll (TYPE_NON_TOUCH)
-> parent.onNestedPreScroll (TYPE_NON_TOUCH)
-> child.dispatchNestedScroll (TYPE_NON_TOUCH)
-> parent.onNestedScroll (TYPE_NON_TOUCH)
-> child.stopNestedScroll (TYPE_NON_TOUCH)
-> parent.onStopNestedScroll (TYPE_NON_TOUCH)

如上所示,當(dāng) RV 開始 Fling 時(shí),每一幀 Fling 的距離,都會(huì)通知到 Parent2,由 Parent2 判斷是否攔截處理,那么我們就不需要自己使用 OverScroller 來模擬慣性滑動(dòng)了,代碼可以更少。具體實(shí)現(xiàn)如下:

public class NestedScrollLayout2 extends FrameLayout implements NestedScrollingParent2 {

    private View mChildView;
    /**
     * 最外層的RecyclerView
     */
    private RecyclerView mRootList;
    /**
     * 子RecyclerView
     */
    private RecyclerView mChildList;

    private NestedViewModel mScrollViewModel;

    private int mAxes;

    public NestedScrollLayout2(@NonNull Context context) {
        super(context);
    }

    public NestedScrollLayout2(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    public void setTarget(LifecycleOwner target) {
        if (target instanceof FragmentActivity) {
            mScrollViewModel = ViewModelProviders.of((FragmentActivity) target).get(NestedViewModel.class);
        } else if (target instanceof Fragment) {
            mScrollViewModel = ViewModelProviders.of((Fragment) target).get(NestedViewModel.class);
        } else {
            throw new IllegalArgumentException("target must be FragmentActivity or Fragment");
        }
        mScrollViewModel.getChildView().observe(target, new Observer<View>() {
            @Override
            public void onChanged(@Nullable View view) {
                mChildView = view;
            }
        });
        mScrollViewModel.getChildList().observe(target, new Observer<View>() {
            @Override
            public void onChanged(@Nullable View view) {
                mChildList = (RecyclerView) view;
            }
        });
    }

    public void setRootList(RecyclerView recyclerView) {
        mRootList = recyclerView;
    }

    @Override
    public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes, int type) {
        return axes == ViewCompat.SCROLL_AXIS_VERTICAL;
    }

    @Override
    public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes, int type) {
        mAxes = axes;
    }

    @Override
    public void onStopNestedScroll(@NonNull View target, int type) {
        mAxes = SCROLL_AXIS_NONE;
    }

    @Override
    public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) {

    }

    @Override
    public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
        if (mChildView == null) {
            return;
        }
        if (target == mRootList) {
            onParentScrolling(mChildView.getTop(), dy, consumed);
        } else {
            onChildScrolling(mChildView.getTop(), dy, consumed);
        }
    }

    /**
     * 父列表在滑動(dòng)
     *
     * @param childTop
     * @param dy
     * @param consumed
     */
    private void onParentScrolling(int childTop, int dy, int[] consumed) {
        //列表已經(jīng)置頂
        if (childTop == 0) {
            if (dy > 0 && mChildList != null) {
                //還在向下滑動(dòng),此時(shí)滑動(dòng)子列表
                mChildList.scrollBy(0, dy);
                consumed[1] = dy;
            } else {
                if (mChildList != null && mChildList.canScrollVertically(dy)) {
                    consumed[1] = dy;
                    mChildList.scrollBy(0, dy);
                }
            }
        } else {
            if (childTop < dy) {
                consumed[1] = dy - childTop;
            }
        }
    }

    private void onChildScrolling(int childTop, int dy, int[] consumed) {
        if (childTop == 0) {
            if (dy < 0) {
                //向上滑動(dòng)
                if (!mChildList.canScrollVertically(dy)) {
                    consumed[1] = dy;
                    mRootList.scrollBy(0, dy);
                }
            }
        } else {
            if (dy < 0 || childTop > dy) {
                consumed[1] = dy;
                mRootList.scrollBy(0, dy);
            } else {
                //dy大于0
                consumed[1] = dy;
                mRootList.scrollBy(0, childTop);
            }
        }
    }

    @Override
    public int getNestedScrollAxes() {
        return mAxes;
    }
    
}

有人可能會(huì)問,既然有新 API,為啥還要用 OverScroller。

快哭了

因?yàn)?,我們?xiàng)目工程里的 RV 版本較低,沒有實(shí)現(xiàn) NestedScrollingChild2,而新版本的 RV 已經(jīng)實(shí)現(xiàn)了Child2,所以,大家有空一定要多升級(jí) Support,真的好用。

最后獻(xiàn)上Demo地址,歡迎大家參考。

Demo地址

參考文獻(xiàn)

1.Android Developer——NestedScrollingChild
2.Android Developer——NestedScrollingParent
3.Android Developer——NestedScrollingParen2
4.Android Developer——NestedScrollingChild2

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

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容