Android嵌套滑动机制实战演练

前言

最近产品提了个需求,要把商品列表做成类似淘宝的样式

淘宝

一般遇到这种需求,我们首先会想到的是,拦截TouchEvent,然后自己来处理滑动,这种方法虽然行得通,但是代码写起来非常恶心,且滑动冲突会比较多,使用NestedScrolling API会简单优雅很多。

先上效果图

Touch嵌套
fling嵌套

API分析

NestedScrollingParent

Parent接口共有以下几个方法

public interface NestedScrollingParent {
    //当子View开始滑动时,会触发这个方法,判断接下来是否进行嵌套滑动,
    //返回false,则表示不使用嵌套滑动
    boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes);

    //onStartNestedScroll如果返回true,那么接下来就会调用这个方法,用来做一些初始化操作,一般可以忽略
    void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes);

    //嵌套滑动结束时会触发这个方法
    void onStopNestedScroll(@NonNull View target);

    //子View滑动时会触发这个方法,dyConsumed代表子View滑动的距离,dyUnconsumed代表子View本次滑动未消耗的距离,比如RecyclerView滑到了边界,那么会有一部分y未消耗掉
    void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed);

    //子View开始滑动时,会触发这个回调,dy表示滑动的y距离,consumed数组代表父View要消耗的距离,假如consumed[1] = dy,那么子View就不会滑动了
    void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed);

    //当子View fling时,会触发这个回调,consumed代表速度是否被子View消耗掉,比如RecyclerView滑动到了边界,那么它显然没法消耗本次的fling
    boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed);

    //当子View要开始fling时,会先询问父View是否要拦截本次fling,返回true表示要拦截,那么子View就不会惯性滑动了
    boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY);

    //表示目前正在进行的嵌套滑动的方向,值有ViewCompat.SCROLL_AXIS_HORIZONTAL 或者ViewCompat.SCROLL_AXIS_VERTICAL或者SCROLL_AXIS_NONE
    @ScrollAxis
    int getNestedScrollAxes();
}

NestedScrollingChild

public interface NestedScrollingChild {
    //设置当前子View是否支持嵌套滑动
    void setNestedScrollingEnabled(boolean enabled);

    //当前子View是否支持嵌套滑动
    boolean isNestedScrollingEnabled();

    //开始嵌套滑动,对应Parent的onStartNestedScroll
    boolean startNestedScroll(@ScrollAxis int axes);

    //停止本次嵌套滑动,对应Parent的onStopNestedScroll
    void stopNestedScroll();

    //true表示这个子View有一个支持嵌套滑动的父View
    boolean hasNestedScrollingParent();

    //通知父View子View开始滑动了,对应父View的onNestedScroll方法
    boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);

    //通知父View即将开始滑动了,对应父View的onNestedPreScroll方法
    boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
            @Nullable int[] offsetInWindow);

    //通知父View开始Fling了,对应Parent的onNestedFling方法
    boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);

    //通知父View要开始fling了,对应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向上传递事件时,是循环向上的,即 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;
    }

具体实现

页面结构

页面结构

事件拦截

RV 嵌套 RV 时,内层 RV 是无法滑动的,然而,当外层RV在Fling时,如果我们触摸到子RV,那么会有一定概率导致子RV接收到Touch事件并开始滚动,所以我们需要同时拦截内层和外层的RV的事件。大概思路如下:

  • 当向下滑动时,判断TabLayout是否置顶,如果未置顶,则滑动外层RV;如果TabLayout已经置顶,则滑动子RV

  • 当向上滑动时,判断TabLayout是否置顶,如果未置顶,则滑动外层RV;如果TabLayout已经置顶,则判断子RV能否向上滑动,如果可以,则滑动子RV,否则滑动外层RV

具体处理为,我们在外层RV之上嵌套一层自定义的FrameLayout,并开启外层RV和内层RV的嵌套滑动功能,那么我们就能在FrameLayout中接收到RV传递上来的scroll和fling事件

滚动处理

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) {
        //这里表示只有在纵向滑动时,我们才拦截事件
        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,比如说我们用一个LinearLayout包裹住TabLayout和ViewPager
        if (mChildView == null) {
            return;
        }
        if (target == mRootList) {
            onParentScrolling(mChildView.getTop(), dy, consumed);
        } else {
            onChildScrolling(mChildView.getTop(), dy, consumed);
        }
    }
    /**
     * 父列表在滑动
     *
     * @param childTop
     * @param dy
     * @param consumed
     */
    private void onParentScrolling(int childTop, int dy, int[] consumed) {
        //列表已经置顶
        if (childTop == 0) {
            if (dy > 0 && mChildList != null) {
                //还在向下滑动,此时滑动子列表
                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) {
                //向上滑动
                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默认实现了Parent接口,这里我们不需要再implement一次

Fling处理

当列表开始 Fling 时,我们将会接收到相应的回调,这里我们需要自己处理惯性滑动,使用 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);
            }
        }
    }
}

到这里为止,我们要的效果已经实现了,mChildView 和子RV何时赋值,参考Demo即可。

新版API

你以为这样就完了?


快闪开,我要开始装逼了

谷歌在 26.1.0 的 support 包中加入了两个新的 API

这两个接口各自继承了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 回调,并且增加了 type 参数,type分为两种

    //表示当前事件是由用户手指触摸产生的
    public static final int TYPE_TOUCH = 0;

    //表示当前事件不是用户手指触摸产生的,一般是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)

如上所示,当 RV 开始 Fling 时,每一帧 Fling 的距离,都会通知到 Parent2,由 Parent2 判断是否拦截处理,那么我们就不需要自己使用 OverScroller 来模拟惯性滑动了,代码可以更少。具体实现如下:

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

    /**
     * 父列表在滑动
     *
     * @param childTop
     * @param dy
     * @param consumed
     */
    private void onParentScrolling(int childTop, int dy, int[] consumed) {
        //列表已经置顶
        if (childTop == 0) {
            if (dy > 0 && mChildList != null) {
                //还在向下滑动,此时滑动子列表
                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) {
                //向上滑动
                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;
    }
    
}

有人可能会问,既然有新 API,为啥还要用 OverScroller。

快哭了

因为,我们项目工程里的 RV 版本较低,没有实现 NestedScrollingChild2,而新版本的 RV 已经实现了Child2,所以,大家有空一定要多升级 Support,真的好用。

最后献上Demo地址,欢迎大家参考。

Demo地址

参考文献

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,378评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,356评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,702评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,259评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,263评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,036评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,349评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,979评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,469评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,938评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,059评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,703评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,257评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,262评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,501评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,792评论 2 345

推荐阅读更多精彩内容