RecyclerView嵌套滑动分析,和SwipeRefreshLayout的交互

1.嵌套滑动

  嵌套滑动是在父View包含子View的情况下,子View将自己的滑动状态告诉父View,父View根据自己的情况做出相应的动作(滑动)。本文仅对当RecyclerView启用嵌套滑动功能时在LinearLayoutManager布局情况下的下拉刷新来分析。它通常和SwipeRefreshLayout一起使用来达到效果,SwipeRefreshLayout作为RecyclerView的直接父类,在RecyclerView滑动到顶部继续下拉时会让SwipeRefreshLayout一起下拉出现刷新效果,但这是怎么做到的呢,我们就从以下几个比较重要的类和接口分析:

  NestedScrollingChild该接口由嵌套滑动的子类实现,它可以记录子类的各种滑动状态和数据,比如滑动的开始、结束、抛出、速度等,当我们使用时需要确定子View具体在什么地方实现此接口中的方法,RecyclerView作为SwipeRefreshLayout的子类就实现了这个接口,不过它实现的是NestedScrollingChild2接口,只是扩展了下NestedScrollingChild而已,之后我们会分析下RecyclerView中这些方法实现的时机。

  NestedScrollingChildHelperNestedScrollingChild接口的实现类会将接口中的逻辑实现交给NestedScrollingChildHelper来处理,看NestedScrollingChildHelper的源码我们会发现在里面会调用ViewParentCompat来判断系统版本,然后通知父View中相应的方法。同时,它也是兼容5.0以下版本的助手类。

  NestedScrollingParent嵌套滑动的父类应实现的接口,里面定义了子类各种滑动状态的通知方法,通过判断子类滑动的状态,让父类做出相应的操作。SwipeRefreshLayout实现了此接口。

  NestedScrollingParentHelper嵌套滑动的父类助手类,兼容5.0版本之前。

  在下一篇文章中,我们会扩展一下SwipeRefreshlayout的功能让它同时支持RecyclerView和ListView的上拉加载,并且能在原生效果和传统的延展效果之间切换,有兴趣的朋友可以看一下(自定义SwipeRefreshLayout,一个能同时支持RecyclerView和ListView的下拉刷新和上拉加载的布局

2.RecyclerView嵌套滑动分析

  下面这张图描述了NestedScrollingChild接口中方法和NestedScrollingParent中接口中方法的调用关系,他们之间通过NestedScrollingChildHelper来沟通。

RecyclerView中的具体实现方法都会通知SwipeRefreshLayout中对应的方法

  按照上图的顺序,我们看一下RecyclerView的嵌套滑动实现:

  a.首先在RecyclerView的构造函数中有(代码有省略,具体可看源码)

public RecyclerView(Context context, @Nullable AttributeSet attrs, int defStyle) {
    super(context, attrs, defStyle);
    
    ···省略···
    
    if (Build.VERSION.SDK_INT >= 21) {
        a = context.obtainStyledAttributes(attrs, NESTED_SCROLLING_ATTRS,
                defStyle, defStyleRes);
        nestedScrollingEnabled = a.getBoolean(0, true);
        a.recycle();
    }
    
    ···省略···

    //设置RecyclerView是否支持嵌套滑动
    setNestedScrollingEnabled(nestedScrollingEnabled);
}

setNestedScrollingEnabled(nestedScrollingEnabled);

/**
 * 设置RecyclerView是否能嵌套滑动
 */
@Override
public void setNestedScrollingEnabled(boolean enabled) {
    getScrollingChildHelper().setNestedScrollingEnabled(enabled);
}

/**
 * 获取RecyclerView是否支持嵌套滑动
 */
@Override
public boolean isNestedScrollingEnabled() {
    return getScrollingChildHelper().isNestedScrollingEnabled();
}

  同时,在NestedScrollingChildHelper中会有如下代码

public void setNestedScrollingEnabled(boolean enabled) {
    if (mIsNestedScrollingEnabled) {
        ViewCompat.stopNestedScroll(mView);
    }
    mIsNestedScrollingEnabled = enabled;
}

  以上代码设置了RecyclerView是否支持嵌套滑动,这是整个嵌套滑动的初始地方,在构造函数中就已经声明,同时,它的具体实现是在getScrollingChildHelper().isNestedScrollingEnabled()中,也就是NestedScrollingChildHelper中实现的。另外,以后的所有的NestedScrollingChild接口中的实现方法具体实现逻辑都调用了getScrollingChildHelper()来处理,也就是委托给了NestedScrollingChildHelper。

  b.然后,我们看一下boolean startNestedScroll()方法是在何处调用的,可以看到有三处地方,分别是onInterceptTouchEvent()方法中的MotionEvent.ACTION_DOWN事件中、onTouchEvent()方法中的MotionEvent.ACTION_DOWN事件和MotionEvent.ACTION_UP事件中。

@Override
public boolean onInterceptTouchEvent(MotionEvent e) {
    ···省略···

    switch (action) {
        case MotionEvent.ACTION_DOWN:
            startNestedScroll(nestedScrollAxis, TYPE_TOUCH);
            break;

        ···省略···
    }
    return mScrollState == SCROLL_STATE_DRAGGING;
}

@Override
public boolean onTouchEvent(MotionEvent e) {
    ···省略···

    switch (action) {
        case MotionEvent.ACTION_DOWN: {
            ···省略···
            startNestedScroll(nestedScrollAxis, TYPE_TOUCH);
        } break;
        ···省略···

        case MotionEvent.ACTION_UP: {
                ···省略···
            if (!((xvel != 0 || yvel != 0) && fling((int) xvel, (int) yvel))) {
                setScrollState(SCROLL_STATE_IDLE);
            }
            resetTouch();
        } break;
    ···省略···
        
    }
    ···省略···

    return true;
}

/*
 * 抛出时
 */
public boolean fling(int velocityX, int velocityY) {
    ···省略···

    if (!dispatchNestedPreFling(velocityX, velocityY)) {
        ···省略···    

        if (canScroll) {
            ···省略···
            startNestedScroll(nestedScrollAxis, TYPE_NON_TOUCH);

            ···省略···
            
            return true;
        }
    }
    return false;
}

/*
 * 当开始滑动时
 */
@Override
public boolean startNestedScroll(int axes, int type) {
    return getScrollingChildHelper().startNestedScroll(axes, type);
}

  再来看一下NestedScrollingChildHelper中

public boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type) {
    if (hasNestedScrollingParent(type)) {
        // Already in progress
        return true;
    }
    if (isNestedScrollingEnabled()) {
        ViewParent p = mView.getParent();
        View child = mView;
        while (p != null) {
            if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)) {
                setNestedScrollingParentForType(type, p);
                ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes, type);
                return true;
            }
            if (p instanceof View) {
                child = (View) p;
            }
            p = p.getParent();
        }
    }
    return false;
}

  startNestedScroll()方法中,会调用ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)和ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes, type)方法,其中的参数含义
  p:父View,这里很明显获取到的是SwipeRefreshLayout
  child:子View,这里为RecyclerView
  axes:轴方向,X轴或Y轴,可沿着轴方向进行滑动
  type:用户触摸导致的滑动、非用户触摸导致的滑动(TYPE_TOUCH、TYPE_NON_TOUCH)

  再看下ViewParentCompat中相关的方法

static final ViewParentCompatBaseImpl IMPL;
//根据版本获取IMPL
static {
    if (Build.VERSION.SDK_INT >= 21) {
        IMPL = new ViewParentCompatApi21Impl();
    } else if (Build.VERSION.SDK_INT >= 19) {
        IMPL = new ViewParentCompatApi19Impl();
    } else {
        IMPL = new ViewParentCompatBaseImpl();
    }
}

public static boolean onStartNestedScroll(ViewParent parent, View child, View target,
        int nestedScrollAxes, int type) {
    if (parent instanceof NestedScrollingParent2) {
        // First try the NestedScrollingParent2 API
        return ((NestedScrollingParent2) parent).onStartNestedScroll(child, target,
                nestedScrollAxes, type);
    } else if (type == ViewCompat.TYPE_TOUCH) {
        // Else if the type is the default (touch), try the NestedScrollingParent API
        return IMPL.onStartNestedScroll(parent, child, target, nestedScrollAxes);
    }
    return false;
}

/*
 * 5.0版本以下会调用这里的方法
 */
static class ViewParentCompatBaseImpl {
    public boolean onStartNestedScroll(ViewParent parent, View child, View target,
            int nestedScrollAxes) {
        if (parent instanceof NestedScrollingParent) {
            return ((NestedScrollingParent) parent).onStartNestedScroll(child, target,
                    nestedScrollAxes);
        }
        return false;
    }
}

/*
 * 5.0版本以上会调用这里的方法
 */
static class ViewParentCompatApi21Impl extends ViewParentCompatApi19Impl {
    @Override
    public boolean onStartNestedScroll(ViewParent parent, View child, View target,
            int nestedScrollAxes) {
        try {
            return parent.onStartNestedScroll(child, target, nestedScrollAxes);
        } catch (AbstractMethodError e) {
            Log.e(TAG, "ViewParent " + parent + " does not implement interface "
                    + "method onStartNestedScroll", e);
            return false;
        }
    }
}

  这里要小心,从代码中我们可以看到IMPL是根据不同版本获取并调用的,在5.0版本以上,NestedScrollingParent接口中所有的抽象方法在ViewGroup中都有同名方法的具体实现,所以在5.0版本以上,ViewParentCompat中默认调用的是ViewGroup中的onStartNestedScroll()方法,但在5.0版本以下,ViewGroup中并没有相应的方法,就会调用NestedScrollingParent接口中的具体实现方法。 如果我们没有主动实现NestedScrollingParent的onStartNestedScroll()抽象方法,那我们的代码就会在5.0版本以下运行时报错,这个下面还会再提到。

  到现在,我们已经从RecyclerView源码中一路跟踪,找到了与它父View之间的调用关系,我们再看看剩下的一些方法的调用。

  c.在调用完startNestedScroll()之后,手指继续触摸RecyclerView会调用dispatchNestedPreScroll()和dispatchNestedScroll()方法

@Override
public boolean onTouchEvent(MotionEvent e) {
    ...省略...

    switch (action) {
        ...省略...

        case MotionEvent.ACTION_MOVE: {
            ...省略...

            if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset, TYPE_TOUCH)) {
                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;

        ...省略...
    }

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

    return true;
}

boolean scrollByInternal(int x, int y, MotionEvent ev) {
    ...省略...

    if (dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset,
            TYPE_TOUCH)) {
        // 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];
    }

    ...省略...

    return consumedX != 0 || consumedY != 0;
}

@Override
public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
        int dyUnconsumed, int[] offsetInWindow, int type) {
    return getScrollingChildHelper().dispatchNestedScroll(dxConsumed, dyConsumed,
            dxUnconsumed, dyUnconsumed, offsetInWindow, type);
}

@Override
public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow,
        int type) {
    return getScrollingChildHelper().dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow,
            type);
}

  这两个方法发生在RecyclerView的MotionEvent.ACTION_MOVE事件中,区别是,dispatchNestedPreScroll()方法先调用,返回一个经父View计算后的消费值,在RecyclerView中会用当前移动的距离减去这个消费值作为RecyclerView移动的偏移量,之后会把这个偏移量再经过计算后得到consumedX, consumedY, unconsumedX, unconsumedY作为参数传递给dispatchNestedScroll()方法。最后再通过NestedScrollingChildHelper类转交给ViewParentCompat,再回调父VIew的onNestedPreScroll()方法和onNestedScroll()方法,参考下面源码。

  NestedScrollingChildHelper中

public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
        @Nullable int[] offsetInWindow, @NestedScrollType int type) {
    if (isNestedScrollingEnabled()) {
        ···省略···
        if (dx != 0 || dy != 0) {
            ···省略···
            ViewParentCompat.onNestedPreScroll(parent, mView, dx, dy, consumed, type);

            if (offsetInWindow != null) {
                mView.getLocationInWindow(offsetInWindow);
                offsetInWindow[0] -= startX;
                offsetInWindow[1] -= startY;
            }
            return consumed[0] != 0 || consumed[1] != 0;
        }
        ···省略···
    }
    return false;
}

public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
        int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow,
        @NestedScrollType int type) {
    if (isNestedScrollingEnabled()) {
        ···省略···
        if (dxConsumed != 0 || dyConsumed != 0 || dxUnconsumed != 0 || dyUnconsumed != 0) {
            ···省略···

            ViewParentCompat.onNestedScroll(parent, mView, dxConsumed,
                    dyConsumed, dxUnconsumed, dyUnconsumed, type);

            if (offsetInWindow != null) {
                mView.getLocationInWindow(offsetInWindow);
                offsetInWindow[0] -= startX;
                offsetInWindow[1] -= startY;
            }
            return true;
        }
        ···省略···
    }
    return false;
}

  NestedScrollingChildHelper中

public static void onNestedPreScroll(ViewParent parent, View target, int dx, int dy,
        int[] consumed, int type) {
    if (parent instanceof NestedScrollingParent2) {
        // First try the NestedScrollingParent2 API
        ((NestedScrollingParent2) parent).onNestedPreScroll(target, dx, dy, consumed, type);
    } else if (type == ViewCompat.TYPE_TOUCH) {
        // Else if the type is the default (touch), try the NestedScrollingParent API
        IMPL.onNestedPreScroll(parent, target, dx, dy, consumed);
    }
}

public static void onNestedScroll(ViewParent parent, View target, int dxConsumed,
        int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) {
    if (parent instanceof NestedScrollingParent2) {
        // First try the NestedScrollingParent2 API
        ((NestedScrollingParent2) parent).onNestedScroll(target, dxConsumed, dyConsumed,
                dxUnconsumed, dyUnconsumed, type);
    } else if (type == ViewCompat.TYPE_TOUCH) {
        // Else if the type is the default (touch), try the NestedScrollingParent API
        IMPL.onNestedScroll(parent, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed);
    }
}

  onNestedPreScroll()方法参数含义:
  int dx:RecyclerView水平滚动的距离
  int dy:RecyclerView竖直滚动的距离
  int[] consumed:需要父View消费的水平或竖直滚动距离,RecyclerView实际上滚动的距离是dx-consumed[0]和dy-consumed[1]

  onNestedScroll()方法参数含义:
  dxConsumed:表示RecyclerView在X轴方向上真实滚动的距离,只有当RecyclerView滚动到X轴两端无法再继续滚动时为0
  dyConsumed:表示RecyclerView在Y轴方向上真实滚动的距离,只有当RecyclerView到达Y轴两端无法再滑动时为0
  dxUnconsumed:当RecyclerView滑动到X轴两端无法再继续滑动时,如果这时继续滑动,此参数就是表示的当前手指X轴方向上的滑动距离
  dyUnconsumed:当RecyclerView滑动到Y轴两端无法再滑动时,如果这时继续滑动,此参数就是表示的当前手指Y轴方向上的滑动距离

  d.以上两步骤是手指放下并滑动时调用的方法,当我们手指离开时也会调用相应的方法dispatchNestedPreFling()和dispatchNestedFling(),在源码中是这样体现的

@Override
public boolean onTouchEvent(MotionEvent e) {
    ···省略···

    switch (action) {
        ···省略···

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

        ···省略···
    }

    ···省略···

    return true;
}

public boolean fling(int velocityX, int velocityY) {
    ···省略···

    if (!dispatchNestedPreFling(velocityX, velocityY)) {
        final boolean canScroll = canScrollHorizontal || canScrollVertical;
        dispatchNestedFling(velocityX, velocityY, canScroll);

        ···省略···
    }
    return false;
}

  可以看到,在抬起时,先调用dispatchNestedPreFling()方法作为抬起的预处理,将水平和竖直方向的抛出速度作为参数传递给父View,通过父View的返回值决定是否调用dispatchNestedFling()方法。同样,它们也是通过NestedScrollingChildHelper类和ViewParentCompat类实现的。

  e.最终,当这些操作结束后,RecyclerView会调用stopNestedScroll()方法,并会通知父View调用onStopNestedScroll(),但stopNestedScroll()方法不仅仅会在最终结束时调用,再刚开始触摸时,如过我们有滑动的行为,也会调用一次此方法作为滑动开始前的标识。

结束语

  以上就是RecyclerView嵌套滑动主要方法的总结,实际开发中,我们并不会使用全部的方法,只需要选择合适的就行,嵌套滑动无非就是将子View的滑动状态告诉父View,让父View做出对应的滑动操作,他们之间的沟通是通过两个类和两个接口实现的。下一篇文章中,我们会利用这些类和接口实现一个非常简单好用的下拉刷新上拉加载的RecyclerView.

传送门:自定义SwipeRefreshLayout,一个能同时支持RecyclerView和ListView的下拉刷新和上拉加载的布局

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

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 172,147评论 25 707
  • 问题分析 嵌套滑动一直是Android中比较棘手的问题, 根本原因是Android的事件分发机制导致的.不了解事件...
    AssIstne阅读 33,139评论 22 117
  • 简介 NestedScrollView 即 支持嵌套滑动的 ScrollView。 因此,我们可以简单的把 Nes...
    Whyn阅读 103,944评论 20 150
  • 几千年来,发源于鄂湘赣三省交汇处的汨罗江浩浩汤汤,奔流不息。因为江水的滋养,这里岸芷汀兰,郁郁青青,一派和谐安宁的...
    C调先森阅读 303评论 0 0
  • 山海经里没有记载的怪物 蓝色星空之鹿 我笔下没有时间、地点和条件 我甚至没有笔 树叶操纵着树枝 七颗石子 一颗敲开...
    巫零阅读 288评论 0 2