Android NestedScrolling机制深入解析

概述

NestedScrolling是Android 5.0之后为我们提供的新特性,降低了使用传统事件分发机制处理嵌套滑动的难度,用于给子view与父view提供更好的交互。

嵌套滚动,顾名思义,就是有至少两个可以滚动的view或者viewgroup,也就说父view和子view都可以滚动,而子view要滚动的时候就要通知父view,我要开始滚动了,由父view决定是否要帮助子view滚动一段距离,父view帮助子view滚动了多少,子view就少移动多少距离。

传统的嵌套事件,我们滑动子view的内容区域,而移动却是外部的ViewGroup,所以传统的方式肯定是外部的ViewGroup拦截了内部的Child的事件,但是在Parent滑动到一定程度时,Chlid又开始滑动,中间的过程没有间断。从正常的事件分发(不手动分发)是不能完成的,因为Parent拦截后,就没有办法再把事件交给Child的(拦截的是一个事件序列)

主要类

需要知道几个关键的接口或类,如下:
NestedScrollingChild:支持滚动的子View需要实现一套接口。
NestedScrollingChildHelper:将子View的滑动事件转发到相应的父View,让父View来处理事件。
NestedScrollingParent:包括滚动子View的父View需要实现的接口。这玩意就是我们上面说的父View必须具有的特性,也就是说父View必须要实现这个接口,稍后的源代码中会看到解释的。
NestedScrollingParentHelper:父View中会使用的辅助类。此类只有3个方法,基本没干啥事。

1、NestedScrollingChild

public interface NestedScrollingChild {
   /**
     * 设置嵌套滑动是否能用
     *
     * @param enabled
     */
    void setNestedScrollingEnabled(boolean enabled);

    /**
     * 判断嵌套滑动是否可用
     *
     * @return
     */
    boolean isNestedScrollingEnabled();

    /**
     * 开始嵌套滑动
     *
     * @param axes 表示方向轴,有横向和竖向
     * @return
     */
    boolean startNestedScroll(int axes);


    /**
     * 停止嵌套滑动
     */
    void stopNestedScroll();

    /**
     * 判断是否有父View 支持嵌套滑动
     *
     * @return
     */
    boolean hasNestedScrollingParent();

    /**
     * 子view处理scroll后调用
     *
     * @param dxConsumed     x轴上被消费的距离(横向)
     * @param dyConsumed     y轴上被消费的距离(竖向)
     * @param dxUnconsumed   x轴上未被消费的距离
     * @param dyUnconsumed   y轴上未被消费的距离
     * @param offsetInWindow 子View的窗体偏移量
     * @return true if the event was dispatched, false if it could not be dispatched.
     */
    boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
                                 int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);

    /**
     * 在子View的onInterceptTouchEvent或者onTouch中,调用该方法通知父View滑动的距离
     *
     * @param dx             x轴上滑动的距离
     * @param dy             y轴上滑动的距离
     * @param consumed       父view消费掉的scroll长度
     * @param offsetInWindow 子View的窗体偏移量
     * @return 支持的嵌套的父View 是否处理了 滑动事件
     */
    boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
                                    @Nullable int[] offsetInWindow);

    /**
     * 滑行时调用
     *
     * @param velocityX x 轴上的滑动速率
     * @param velocityY y 轴上的滑动速率
     * @param consumed  是否被消费
     * @return true if the nested scrolling parent consumed or otherwise reacted to the fling
     */
    boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);

    /**
     * 进行滑行前调用
     *
     * @param velocityX x 轴上的滑动速率
     * @param velocityY y 轴上的滑动速率
     * @return true if a nested scrolling parent consumed the fling
     */
    boolean dispatchNestedPreFling(float velocityX, float velocityY);
}

2、NestedScrollingParent

public interface NestedScrollingParent {
    /**
     * 当 Scrolling Child 调用 onStartNestedScroll 方法的时候,通过 NestedScrollingChildHelper
     * 会回调 Scrolling parent 的 onStartNestedScroll 方法,如果返回 true,
     * Scrolling parent 的 onNestedScrollAccepted(View child, View target, int nestedScrollAxes)
     * 方法会被回调。
     *
     * @param child 包含此目标的ViewParent的直接view
     * @param target 初始化嵌套滚动的view
     * @param nestedScrollAxes 滚动方向:ViewCompat#SCROLL_AXIS_HORIZONTAL,ViewCompat#SCROLL_AXIS_VERTICAL
     * @return
     */
    public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes);


    /**
     * 如果 Scrolling Parent 的onStartNestedScroll 返回 true,
     * Scrolling parent 的 onNestedScrollAccepted(View child, View target,
     * int nestedScrollAxes) 方法会被回调。
     * @param child
     * @param target
     * @param nestedScrollAxes
     */
    public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes);

    /**
     * 嵌套滚动结束后调用
     * @param target
     */
    public void onStopNestedScroll(View target);

    /**
     * 正在嵌套滚动
     * 要接收对此方法的调用,ViewParent必须先前在调用onStartNestedScroll时返回true
     * @param target 嵌套滚动的child view
     * @param dxConsumed 目标已消耗的水平滚动距离
     * @param dyConsumed 目标已消耗的垂直滚动距离
     * @param dxUnconsumed 目标未消耗的水平滚动距离
     * @param dyUnconsumed 目标未消耗的垂直滚动距离
     */
    public void onNestedScroll(View target, int dxConsumed, int dyConsumed,
                               int dxUnconsumed, int dyUnconsumed);

    /**
     * 当 Scrolling Child 调用 dispatchNestedPreScroll 方法的时候调用此方法
     * @param target Scrolling Child
     * @param dx 水平滚动距离(像素)
     * @param dy 垂直滚动距离(像素)
     * @param consumed  Output. The horizontal and vertical scroll distance consumed by this parent
     */
    public void onNestedPreScroll(View target, int dx, int dy, int[] consumed);

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

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

    public int getNestedScrollAxes();
}

NestedScrollingChildNestedScrollingParent分别定义了嵌套子View和嵌套父View需要实现的接口。另外这些方法基本都是通过NestedScrollingChildHelperNestedScrollingParentHelper来实现,一般并不需要手动编写多少逻辑。

通过方法名可以看出,NestedScrollingChild的方法均为主动方法,而NestedScrollingParent的方法基本都是回调方法。这也是NestedScrolling机制的一个体现,子View作为NestedScrolling事件传递的主动方,父View作为被动方。

NestedScrolling机制生效的前提条件是子View作为Touch事件的消费者,在消费过程中向父View发送NestedScrolling事件(注意这里不是Touch事件,而是NestedScrolling事件。

NestedScrolling事件传递

NestedScrolling机制中,NestedScrolling事件使用dx, dy表示,分别表示子View Touch事件处理方法中判定的x和y方向上的滚动偏移量。

NestedScrolling事件的传递:

  1. 由子View产生NestedScrolling事件;
  2. 发送给父View进行处理,父View处理之后,返回消费的偏移量;
  3. 子View根据父View消费的偏移量计算NestedScrolling事件剩余偏移量;
  4. 根据剩余偏移量判断是否能处理滚动事件;如果处理滚动事件,同时将自身滚动情况通知父View;
  5. 处理结束,事件传递完成。

这里只说明了一层嵌套的情况,事实上NestedScrolling很可能出现在多重嵌套的场景。对于多重嵌套,步骤2、3、4将事件自底向上进行传递,步骤2中消费的偏移量将记录所有嵌套父View消费偏移量的总和。这里不再重复。

下面以RecyclerView为例,看一下NestedScrolling事件的传递过程

1、初始阶段

确认开启NestedScrolling,关联父View和子View。

//NestedScrollingChild
void setNestedScrollingEnabled(boolean enabled);
boolean startNestedScroll(int axes);

//NestedScrollingParent
boolean onStartNestedScroll(View child, View target, int nestedScrollAxes);
void onNestedScrollAccepted(View child, View target, int nestedScrollAxes);

RecyclerView实现了NestedScrollingChild,那它就是事件的源头。
直接去看RecyclerViewonTouchEvent方法:

    case MotionEvent.ACTION_DOWN: {
        mScrollPointerId = e.getPointerId(0);
        mInitialTouchX = mLastTouchX = (int) (e.getX() + 0.5f);
        mInitialTouchY = mLastTouchY = (int) (e.getY() + 0.5f);

        int nestedScrollAxis = ViewCompat.SCROLL_AXIS_NONE;
        if (canScrollHorizontally) {
            nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL;
        }
        if (canScrollVertically) {
            nestedScrollAxis |= ViewCompat.SCROLL_AXIS_VERTICAL;
        }
        startNestedScroll(nestedScrollAxis);
    } break;

在MotionEvent.ACTION_DOWN中,获取到RecyclerView滚动的方向。记录初始位置。然后调用startNestedScroll(nestedScrollAxis);代码如下:

    public boolean startNestedScroll(int axes) {
        return getScrollingChildHelper().startNestedScroll(axes);
    }

getScrollingChildHelper()返回的就是NestedScrollingChildHelper。NestedScrollingChildHelper的startNestedScroll方法是真正将事件传递到父View的地方

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

在NestedScrollingChildHelper的startNestedScroll方法中会去递归的寻找有特征的父View,此处调用了ViewParentCompat.onStartNestedScroll(ViewParent parent, View child, View target,int nestedScrollAxes)方法。若找到父View,则将父View记录到变量mNestedScrollingParent 中,在接下来的事件中直接使用。如果有找到父View,并且父View的onStartNestedScroll方法返回true(代码父View接受滑动事件,比如父View只接受垂直滑动事件,就可以根据坐标轴进行方向判断是否是垂直方向,并返回true),还会调用ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes)方法。

2、预滚动阶段

子View将事件分发到父View

// NestedScrollingChild
boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow);

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

接下来看看RecyclerViewonTouchEventACTION_MOVE事件的实现,代码如下:

case MotionEvent.ACTION_MOVE: {
        final int index = e.findPointerIndex(mScrollPointerId);
   
        final int x = (int) (e.getX(index) + 0.5f);
        final int y = (int) (e.getY(index) + 0.5f);
        int dx = mLastTouchX - x;
        int dy = mLastTouchY - y;

        if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset)) {
            dx -= mScrollConsumed[0];
            dy -= mScrollConsumed[1];
            vtev.offsetLocation(mScrollOffset[0], mScrollOffset[1]);
            // Updated the nested offsets
            mNestedOffsets[0] += mScrollOffset[0];
            mNestedOffsets[1] += mScrollOffset[1];
        }
        ...
        if (mScrollState == SCROLL_STATE_DRAGGING) {
            mLastTouchX = x - mScrollOffset[0];
            mLastTouchY = y - mScrollOffset[1];

            if (scrollByInternal(
                    canScrollHorizontally ? dx : 0,
                    canScrollVertically ? dy : 0,
                    vtev)) {
                getParent().requestDisallowInterceptTouchEvent(true);
            }
            if (mGapWorker != null && (dx != 0 || dy != 0)) {
                mGapWorker.postFromTraversal(this, dx, dy);
            }
        }
    } break;

首先计算出当前滑动的距离dx和dy。然后调用dispatchNestedPreScroll方法。dispatchNestedPreScroll方法的实现最终也是调用NestedScrollingChildHelper的dispatchNestedPreScroll方法。代码如下:

    public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
        if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
            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) {
                    if (mTempNestedScrollConsumed == null) {
                        mTempNestedScrollConsumed = new int[2];
                    }
                    consumed = mTempNestedScrollConsumed;
                }
                consumed[0] = 0;
                consumed[1] = 0;
                ViewParentCompat.onNestedPreScroll(mNestedScrollingParent, mView, dx, dy, consumed);

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

通过ViewParentCompat.onNestedPreScroll方法,并调用父View的onNestedPreScroll方法。主要就是想知道父View是否消费了某个方向的距离。如果父View有消费某个方向上的距离,整个方法就返回true

3、滚动阶段

子View处理滚动事件。
RecyclerView的ACTION_MOVE中还有一段代码很重要,那就是当子View当前处于拖拽状态时(mScrollState == SCROLL_STATE_DRAGGING)会执行的方法,那就是scrollByInternal方法。该方法的代码如下:

    boolean scrollByInternal(int x, int y, MotionEvent ev) {
        int unconsumedX = 0, unconsumedY = 0;
        int consumedX = 0, consumedY = 0;

        consumePendingUpdateOperations();
        if (mAdapter != null) {
            eatRequestLayout();
            onEnterLayoutOrScroll();
            TraceCompat.beginSection(TRACE_SCROLL_TAG);
            if (x != 0) {
                consumedX = mLayout.scrollHorizontallyBy(x, mRecycler, mState);
                unconsumedX = x - consumedX;
            }
            if (y != 0) {
                consumedY = mLayout.scrollVerticallyBy(y, mRecycler, mState);
                unconsumedY = y - consumedY;
            }
            TraceCompat.endSection();
            repositionShadowingViews();
            onExitLayoutOrScroll();
            resumeRequestLayout(false);
        }
        if (!mItemDecorations.isEmpty()) {
            invalidate();
        }

        if (dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset)) {
            // Update the last touch co-ords, taking any scroll offset into account
            mLastTouchX -= mScrollOffset[0];
            mLastTouchY -= mScrollOffset[1];
            if (ev != null) {
                ev.offsetLocation(mScrollOffset[0], mScrollOffset[1]);
            }
            mNestedOffsets[0] += mScrollOffset[0];
            mNestedOffsets[1] += mScrollOffset[1];
        } else if (getOverScrollMode() != View.OVER_SCROLL_NEVER) {
            if (ev != null) {
                pullGlows(ev.getX(), unconsumedX, ev.getY(), unconsumedY);
            }
            considerReleasingGlowsOnScroll(x, y);
        }
        if (consumedX != 0 || consumedY != 0) {
            dispatchOnScrolled(consumedX, consumedY);
        }
        if (!awakenScrollBars()) {
            invalidate();
        }
        return consumedX != 0 || consumedY != 0;
    }

该方法内部,主要做了3件事:

  1. 让子View沿着水平或者垂直方向,将剩下的dx和dy滚动完。
  2. 计算出子View当前以及滚动的距离和未滚动的距离。
  3. 根据子View已经滚动的距离和未滚动的距离调用dispatchNestedScroll方法。当然这里和上面的dispatchNestedPreScroll方法类似,最终也是会调用到父View的onNestedScroll方法的。

4、结束阶段

// NestedScrollingChild
void stopNestedScroll();

// NestedScrollingParent
void onStopNestedScroll(View target);

最后再看一下RecyclerViewACTION_UP事件:

    case MotionEvent.ACTION_UP: {
        mVelocityTracker.addMovement(vtev);
        eventAddedToVelocityTracker = true;
        mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
        final float xvel = canScrollHorizontally ?
                -VelocityTrackerCompat.getXVelocity(mVelocityTracker, mScrollPointerId) : 0;
        final float yvel = canScrollVertically ?
                -VelocityTrackerCompat.getYVelocity(mVelocityTracker, mScrollPointerId) : 0;
        if (!((xvel != 0 || yvel != 0) && fling((int) xvel, (int) yvel))) {
            setScrollState(SCROLL_STATE_IDLE);
        }
        resetTouch();
    } break;
    private void resetTouch() {
        if (mVelocityTracker != null) {
            mVelocityTracker.clear();
        }
        stopNestedScroll();
        releaseGlows();
    }

ACTION_UP中会调用resetTouch方法。在此方法中,最重要的就是调用了stopNestedScroll()方法,该方法的目的就是通知父View滚动停止了。会调用父View的onStopNestedScroll()方法

当子View调用startNestedScroll方法时,开始嵌套滚动流程;之后不断循环pre-scroll和scroll两个过程(一般在子View的onTouchEvent的MOVE分支调用);直到手指抬起,子View调用stopNestedScroll方法结束滚动(在结束之前可能进入Fling状态)。

参考:
1、NestedScrolling嵌套滚动原理
2、NestedScrolling事件机制源码解析
3、NestedScrolling 机制深入解析
4、Android Nested Scrolling

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

推荐阅读更多精彩内容