CoordinatorLayout源码解析之从NestedScrolling说起

来源:互联网 发布:mac队基地 编辑:程序博客网 时间:2024/06/06 04:23

概述

CoordinatorLayout(下面简称CL)出来也有很长时间了,平常也仅限于API的调用,如果不知道其中的原理,出了问题也不好去解决。所以今次决定深入其内部一探究竟(…)。

相比于RecyclerView的1W+行的源码,CL包含的内容算是少的了。但是本系列文章并不想从CL作为一个ViewGroup的角度去分析它的onMeasure、onLayout、onDraw和滑动等等,而是想从比较有特色的嵌套滑动还有Behavior下手,顺便提及前述知识。

我还会简单介绍所涉及的辅助类,因为如果不明白有些类的作用会对理解造成阻碍。

说一下NestedScrollingXXXX

你看了很多NestedScrolling的文章,肯定一上来就摆出了四个类(or 接口),说是用于嵌套滑动的,你的VG和View要继承这几个接口然后去实现你自己的嵌套滑动。

  • interface NestedScrollingChild
  • class NestedScrollingChildHelper
  • interface NestedScrollingParent
  • class NestedScrollingParentHelper

其实这几个类(or 接口)是用来做前向兼容的。

我们看一下Lolipop以上的版本的View和VG的源码,已经多了一些public的方法,这些方法都和“Nested”有关。

//View.javapublic void setNestedScrollingEnabled(boolean enabled);public boolean isNestedScrollingEnabled();public boolean startNestedScroll(int axes);public void stopNestedScroll();public boolean hasNestedScrollingParent();public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow);public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow);public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);public boolean dispatchNestedPreFling(float velocityX, float velocityY);//ViewGroup.javapublic boolean onStartNestedScroll(View child, View target, int nestedScrollAxes);public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes);public void onStopNestedScroll(View target);public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed);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();

然后仅拿出NestedScrollingChild接口中的方法,对比View中多出来的方法,我们发现是一样儿一样儿的。

public interface NestedScrollingChild {    public void setNestedScrollingEnabled(boolean enabled);    public boolean isNestedScrollingEnabled();    public boolean startNestedScroll(int axes);    public void stopNestedScroll();    public boolean hasNestedScrollingParent();    public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,            int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow);    public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow);    public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);    public boolean dispatchNestedPreFling(float velocityX, float velocityY);}

而我们再看一下NestedScrollingChildHelper中的接口,包含了所有NestedScrollingChild接口中应该实现的接口。

public class NestedScrollingChildHelper {        ...    public void setNestedScrollingEnabled(boolean enabled) {        ...    }    public boolean isNestedScrollingEnabled() {        return mIsNestedScrollingEnabled;    }     public boolean startNestedScroll(int axes) {        ...    }  public void stopNestedScroll() {        ...    }    public boolean hasNestedScrollingParent() {        return mNestedScrollingParent != null;    }    public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,            int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow) {      ...    }    public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {        ...    }    public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {       ...    }    public boolean dispatchNestedPreFling(float velocityX, float velocityY) {        ...    }    public void onDetachedFromWindow() {        ViewCompat.stopNestedScroll(mView);    }    public void onStopNestedScroll(View child) {        ViewCompat.stopNestedScroll(mView);    }}

其实也就是说在5.0之后,我们的View和VG已经具有嵌套滑动相关的接口,而在5.0之前的View和VG要想实现嵌套滑动,就得继承上面的两个接口并用两个Helper去完成具体的逻辑。所以说这几个接口和类是用来前向兼容的。

在嵌套滑动中会要求控件要么是继承于SDK21之后的View或VG, 要么实现了这两个接口, 这是控件能够进行嵌套滑动的前提条件。

说一下ViewCompat

随便看一个NestedScrollingChildHelper里的方法,比如setNestedScrollingEnabled(),可能会看到ViewCompat这个类。

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

注释介绍这个类是为了获取View中特性的后向兼容的帮助类,也就是说新出来的一个SDK版本,我们可用这个类使用和原来版本一样的方法,但是可能会有新的效果或不一样的实现方法。

实现这样的功能主要是因为下面几个关键点:

  • 内部提供了一系列和View一样的接口。
  • 内部提供了一系列这个接口的实现类,这些类都是和版本对应的,且都是继承关系。
  • 在类初始化的时候就将为一个静态的ViewCompatImpl变量IMPL根据版本赋值。
  • 该类对外的方法几乎是调用IMPL对应的方法实现的。(桥接模式?)
//内部提供了一系列和View一样的接口。interface ViewCompatImpl {        boolean canScrollHorizontally(View v, int direction);        boolean canScrollVertically(View v, int direction);        void onInitializeAccessibilityEvent(View v, AccessibilityEvent event);        void onPopulateAccessibilityEvent(View v, AccessibilityEvent event);        void onInitializeAccessibilityNodeInfo(View v, AccessibilityNodeInfoCompat info);    ...}//内部提供了一系列这个接口的实现类,这些类都是和版本对应的,且是继承关系,当然可以选择直接使用父类的方法,有需要再重写。static class BaseViewCompatImpl implements ViewCompatImpl {        @Override        public boolean canScrollHorizontally(View v, int direction) {            return (v instanceof ScrollingView) &&                canScrollingViewScrollHorizontally((ScrollingView) v, direction);        }        @Override        public boolean canScrollVertically(View v, int direction) {            return (v instanceof ScrollingView) &&                    canScrollingViewScrollVertically((ScrollingView) v, direction);        }        ....}static class HCViewCompatImpl extends BaseViewCompatImpl {  ...}static class ICSViewCompatImpl extends HCViewCompatImpl {  ...}...//在类初始化的时候就将为一个静态的ViewCompatImpl变量IMPL根据版本赋值。    static final ViewCompatImpl IMPL;    static {        final int version = android.os.Build.VERSION.SDK_INT;        if (BuildCompat.isAtLeastN()) {            IMPL = new Api24ViewCompatImpl();        } else if (version >= 23) {            IMPL = new MarshmallowViewCompatImpl();        } else if (version >= 21) {            IMPL = new LollipopViewCompatImpl();        } else if (version >= 19) {            IMPL = new KitKatViewCompatImpl();        } else if (version >= 18) {            IMPL = new JbMr2ViewCompatImpl();        } else if (version >= 17) {            IMPL = new JbMr1ViewCompatImpl();        } else if (version >= 16) {            IMPL = new JBViewCompatImpl();        } else if (version >= 15) {            IMPL = new ICSMr1ViewCompatImpl();        } else if (version >= 14) {            IMPL = new ICSViewCompatImpl();        } else if (version >= 11) {            IMPL = new HCViewCompatImpl();        } else {            IMPL = new BaseViewCompatImpl();        }    }//该类对外的接口几乎是调用IMPL对应的方法。public static boolean canScrollHorizontally(View v, int direction) {        return IMPL.canScrollHorizontally(v, direction);}public static boolean canScrollVertically(View v, int direction) {        return IMPL.canScrollVertically(v, direction);}...

ViewCompat和View的接口一样,但是却根据手机不同的版本提供了不同的实现。IMPL成员是在静态块里被赋值的,也就是说整个应用(或者说所有应用,因为开机过后手机的版本是不会变的)获取到的ViewCompact都是一个版本,且在应用被加载到虚拟机的时候就已经初始化好了。像XXXXCompat这样的类,比如ViewGroupCompat、ViewParentCompat,都是和原类(接口)提供了相同的API,但是根据手机版本提供版本下具体的实现。

看一下嵌套滑动机制的概貌

想一想我们熟知的Touch事件分发机制,事件从Activity传递到DecorView,接着在View树中向下传递。这种机制有两个特点,一是如果父VG在一次事件中(DOWN to UP)拦截了某个MotionEvent,那么一次事件接下来的MotionEvent就都会交给这个父VG处理,子View是处理不了MotionEvent的;二是如果一次事件中父VG都不决定拦截MotionEvent,那么所有MotionEvent会交给已经承诺消耗的子View,父VG是处理不了MotionEvent的。

那么会形成这样一种情况:如果一个子View消费了某个MotionEvent,那么其父VG是没有机会再去处理这个MotionEvent的。也就是说,正常情况下,父VG承诺不拦截MotionEvent过后,这个MotionEvent它无法再触碰了。

嵌套滑动机制可以解决这个问题。

它的流程是这样的:这个机制中有两方,一个是NestedParent,一个是NestedChild。假设parent不拦截event,那么当event到达child的时候,1.child会通知parent并先交给parent去处理event,2.parent处理完之后,child自己处理event,3.child处理完之后假设还有剩余值(对于滑动就是一段距离)没有处理,会继续交给parent去处理event。

parent和child都有机会对滑动操作作出响应, 尤其parent能够分别在child处理滑动距离之前和之后对滑动距离进行响应。那么对于touch分发机制中的那个问题,用嵌套滑动解决起来就简单了,因为child不会独享event,而是在消耗之前以及之后都会让parent先去消耗。

这个过程是怎么实现的呢?可以做这样一些类比:child是嵌套滑动的发起者,parent是接收者,child就像观察者模式中的Observable,parent是Observer,或者把parent看成child的listener。child需要获得parent的引用,并在合适的地方调用child中的方法,进而调用parent中的对应方法。

这只是整体上的思路,从具体实现来说:一个child中的方法对应着一个或多个parent中的对应回调方法,而如何对应,谷歌爸爸已经在NestedScrollingChildHelper中帮我们实现好了,比如我们看几个比较重要的,重点关注里面调用了parent的什么方法,重点关注里面调用了parent的什么方法,重点关注里面调用了parent的什么方法:

//NestedScrollingChildHelper.java    //此方法用于在开始嵌套滑动前,child通知parent    public boolean startNestedScroll(int axes) {        if (hasNestedScrollingParent()) {            // Already in progress            return true;        }        if (isNestedScrollingEnabled()) {          //获取一下直接父View            ViewParent p = mView.getParent();            View child = mView;            while (p != null) {                //调用ViewParentCompat.onStartNestedScroll(),最终会调用到父View的onStartNestedScroll()              //这个父View继承了NestedParent接口并且方法返回true                if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes)) {                    //找到了NestedParent,记录它                    mNestedScrollingParent = p;                  //调用它的onNestedScrollAccepted()方法                    ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes);                    //返回true                    return true;                }                //如果父View没实现NestedParent接口或者onStartNestedScroll()返回false                if (p instanceof View) {                    //把child赋值为当前的父View                    child = (View) p;                }                //父View赋值为父View的父View继续向上寻找              //注意,直接父View不一定是parent,知道找到符合要求的parent为止                p = p.getParent();            }        }        return false;    }    //此方法用于在child准备滑动的时候,先让parent决定如何动作    public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {        if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {            //dx dy代表本次滑动child滑动的距离            if (dx != 0 || dy != 0) {                int startX = 0;                int startY = 0;                //这个数组记录被parent处理前后child坐标的偏移              //这里是记录起始位置                if (offsetInWindow != null) {                    mView.getLocationInWindow(offsetInWindow);                    startX = offsetInWindow[0];                    startY = offsetInWindow[1];                }                //这个数组交给parent记录parent消耗的值                if (consumed == null) {                    if (mTempNestedScrollConsumed == null) {                        mTempNestedScrollConsumed = new int[2];                    }                    consumed = mTempNestedScrollConsumed;                }                consumed[0] = 0;                consumed[1] = 0;                //最终将调用到parent的onNestedPreScroll(),parent可能会进行滑动。                ViewParentCompat.onNestedPreScroll(mNestedScrollingParent, mView, dx, dy, consumed);                if (offsetInWindow != null) {                  //获得parent处理后child的坐标                    mView.getLocationInWindow(offsetInWindow);                  //计算前后差值                    offsetInWindow[0] -= startX;                    offsetInWindow[1] -= startY;                }                //只要parent消耗了,就返回true。                return consumed[0] != 0 || consumed[1] != 0;            } else if (offsetInWindow != null) {                offsetInWindow[0] = 0;                offsetInWindow[1] = 0;            }        }        return false;    }    //此方法用于在child滑动之后,如果还有剩余,可以再次通知parent    public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,            int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow) {        if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {            if (dxConsumed != 0 || dyConsumed != 0 || dxUnconsumed != 0 || dyUnconsumed != 0) {                int startX = 0;                int startY = 0;                //记录child的起始位置                if (offsetInWindow != null) {                    mView.getLocationInWindow(offsetInWindow);                    startX = offsetInWindow[0];                    startY = offsetInWindow[1];                }                //最终调用到parent的onNestedScroll()                ViewParentCompat.onNestedScroll(mNestedScrollingParent, mView, dxConsumed,                        dyConsumed, dxUnconsumed, dyUnconsumed);                //计算child的偏移                if (offsetInWindow != null) {                    mView.getLocationInWindow(offsetInWindow);                    offsetInWindow[0] -= startX;                    offsetInWindow[1] -= startY;                }                //总是返回true                return true;            } else if (offsetInWindow != null) {                // No motion, no dispatch. Keep offsetInWindow up to date.                offsetInWindow[0] = 0;                offsetInWindow[1] = 0;            }        }        return false;    }    //此方法用于在child产生fling之前    public boolean dispatchNestedPreFling(float velocityX, float velocityY) {        if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {            //返回parent是否消耗了fling            return ViewParentCompat.onNestedPreFling(mNestedScrollingParent, mView, velocityX,                    velocityY);        }        return false;    }    //此方法用在child进行fling决策之后,再次交给parent处理fling(此时fling被child处理或没有)    public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {        if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {            return ViewParentCompat.onNestedFling(mNestedScrollingParent, mView, velocityX,                    velocityY, consumed);        }        return false;    }

也就是说child在某个地方起调A方法,parent作为一个listener会被回调相应的A ’方法。方法的对应是这样的:

child parent startNestedScroll onStartNestedScroll、onNestedScrollAccepted dispatchNestedPreScroll onNestedPreScroll dispatchNestedScroll onNestedScroll dispatchNestedPreFling onNestedPreFling dispatchNestedFling onNestedFling stopNestedScroll onStopNestedScroll

那么也就是说child和parent之间的互动形式已经由Helper类决定好了,剩下来的问题就是child何时发起相应方法的调用?

从RecycleView谈嵌套滑动

上面已经说了,我们已经知道child和parent之间是如何互动的了,那么作为实现了NestedScrollingChild接口的child,是在什么地方合乎逻辑地起调了这些函数呢?

点开NestedScrollingChild的继承树,发现有3个实现类:NestedScrollView、SwipeRefreshLayout、RecyclerView,算上5.0之后的ListView(继承了View)有4个。就挑RecyclerView来看一下。

对于startNestedScroll(),在onInterceptTouchEvent()和onTouchEvent()的DOWN中,这里作用就是找到parent,让parent做相应的准备工作。

对于dispatchNestedPreScroll()和dispatchNestedScroll(),都是在onTouchEvent()的MOVE中。

         case MotionEvent.ACTION_MOVE: {                ...                int dx = mLastTouchX - x;                int dy = mLastTouchY - y;            //让parent先处理                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];                //这里是child自身滑动                    if (scrollByInternal(                            canScrollHorizontally ? dx : 0,                            canScrollVertically ? dy : 0,                            vtev)) {                        getParent().requestDisallowInterceptTouchEvent(true);                    }                    ...                }            } break;    boolean scrollByInternal(int x, int y, MotionEvent ev) {        int unconsumedX = 0, unconsumedY = 0;        int consumedX = 0, consumedY = 0;        consumePendingUpdateOperations();        if (mAdapter != null) {            ...            if (y != 0) {                //child自身滑动并记录消耗的距离                consumedY = mLayout.scrollVerticallyBy(y, mRecycler, mState);                //记录未消耗的距离                unconsumedY = y - consumedY;            }            ...        }        ...        //再次交给parent处理        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];        } ...        return consumedX != 0 || consumedY != 0;    }

对于dispatchNestedPreFling和dispatchNestedFling,是在onTouchEvent的UP中。

case MotionEvent.ACTION_UP: {                ...                  //这里                if (!((xvel != 0 || yvel != 0) && fling((int) xvel, (int) yvel))) {                    setScrollState(SCROLL_STATE_IDLE);                }                resetTouch();            } break;    public boolean fling(int velocityX, int velocityY) {        ...        //交给parent处理fling,如果不处理        if (!dispatchNestedPreFling(velocityX, velocityY)) {            final boolean canScroll = canScrollHorizontal || canScrollVertical;            //再次交给parent处理            dispatchNestedFling(velocityX, velocityY, canScroll);            if (mOnFlingListener != null && mOnFlingListener.onFling(velocityX, velocityY)) {                return true;            }            if (canScroll) {                velocityX = Math.max(-mMaxFlingVelocity, Math.min(velocityX, mMaxFlingVelocity));                velocityY = Math.max(-mMaxFlingVelocity, Math.min(velocityY, mMaxFlingVelocity));                //自身处理                mViewFlinger.fling(velocityX, velocityY);                return true;            }        }        return false;    }

我们可以看到,这样的逻辑也符合child向parent传递消息的实际情况。

那么NestedScrollingParent的实现类到底在这些回调方法里面做了什么?这个就要看我们怎么去自定义或者已经有的实现类是怎么具体实现的了,我们打开继承树发现有4个实现类:ActionBarOverlayLayout、CoordinatorLayout、NestedScrollView、SwipeRefreshLayout。下一篇打算讲一讲其中一个——CoordinatorLayout。

总结

作为一种机制,嵌套滑动解决了传统事件分发机制中一个不能解决的问题:子View经手的event父View一般不能处理了。

利用NestedScrollingChild、NestedScrollingChildHelper、NestedScrollingParent、NestedScrollingParentHelper对5.0以前的View进行了兼容。

它的思想是NestedScrollingChild的实现类要在合适的地方起调合适的函数,从而达到符合逻辑的child和parent的互动效果。

它的流程大概是这样的:

滑动之前,child寻找parent并通知parent。

准备滑动的时候,child先让parent处理。

parent处理完之后,child继续处理。

child处理完之后,还可以交给parent处理一次。

原创粉丝点击