一文厘清安卓 Nested 事件分发机制

763 阅读14分钟

本文旨在从 Android 传统事件分发开始,接着根据嵌套滚动中的事件共享机制分析NestedScrollView源码,从分析过程中得出朴素的处理 View 联动的方案,最后拆解官方提供的CoordinatorLayout解决 View 联动的处理方案。

朴素的事件分发

先来回顾一下点击事件在 ViewTree 中分发和消费过程。

class Activity {
    public boolean dispatchTouchEvent(MotionEvent ev) {
        // 先传递给内部ViewGroup:ViewGroup.dispatchTouchEvent()
        if (getWindow().superDispatchTouchEvent()) {
            return true;
        }
        return onTouchEvent(ev);
    }

    public boolean onTouchEvent(MotionEvent ev) {
        if (mWindow.shouldCloseOnTouch(this, ev)) { // 若需点外部消失
            finish();
            return true;
        }
        return false;
    }
}

class ViewGroup {
    public boolean dispatchTouchEvent(MotionEvent ev) {
        // 若子 View 没调用 parent.requestDisallowInterceptTouchEvent(true)
        // 当前ViewGroup不拦截,则事件会继续分发下去
        if (disallowIntercept || !onInterceptTouchEvent(ev)) {
            for (int i = count - 1; i >= 0; i--) {
                final View child = children[i];
                if (isTouchPointInView(ev, child) && child.dispatchTouchEvent(ev)) {
                    return true;
                }
            }
        }
        // 子类没消费则交给当前ViewGroup消费
        return super.dispatchTouchEvent();
    }

    // 返回true当前组件拦截不再分发给子View,返回false继续传递
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        return false;
    }
}


class View {
    public boolean dispatchTouchEvent(MotionEvent event) {
        if (mOnTouchListener.onTouch(this, event)) {
            return true;
        }
        return onTouchEvent(event);
    }

    public boolean onTouchEvent(MotionEvent event) {
        if (CLICKABLE || LONG_CLICKABLE) {
            switch (event.getAction()) {
                case MotionEvent.ACTION_UP: // 抬起
                    performClick(); // 最终调 mOnClickListener.onClick()
                    break;
                case MotionEvent.ACTION_DOWN: // 按下
                    // 一定时长后调 mOnLongClickListener.onLongClick()
                    postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
                    break;

                case MotionEvent.ACTION_MOVE: // 移动
                    if (isScrolling) { // 滑动而非点击
                        removeLongPressCallback();
                    }
                    break;
                // 非人为结束(如ScrollView中的Button在按下后再滑动就会收到)
                case MotionEvent.ACTION_CANCEL:
                    setPressed(false);
                    removeTapCallback();
                    removeLongPressCallback();
                    break;
            }
            return true; // 可点击时一定把事件消费掉
        }
        return false;
    }
}

view-event.png

若 Android 事件分发机制止步于上述逻辑,则只要有一个控件消费了事件,其他控件就没办法再接收到这个事件了。为了适应更复杂的业务场景,API 21(Android 5 Lollipop)引入嵌套滑动机制(典型代表:NestedScrollView)。

嵌套滚动机制

嵌套滑动实现的原理是基于子控件驱动的事件共享,即:

  1. 子 View 接收到滑动事件,会先通知父控件 startNestedScroll()
  2. 子 View 在滑动之前,会先让父控件优先处理滑动dispatchNestedPreScroll(), 然后子 View 处理剩余的滑动距离。
  3. 子控件滑动后如果滑动距离还有剩余,就会再交给父控件处理 dispatchNestedScroll()

ViewViewGroup(:ViewParent)都增加了嵌套滑动机制相关的方法,另外还提供了两类接口:

  • NestedScrollingParent: 作为父控件,支持嵌套滑动功能。
  • NestedScrollingChild: 作为子控件,支持嵌套滑动功能。

  • NestedScrollView同时实现了NestedScrollingParent3NestedScrollingChild3
  • NestedScrollingXxx2新增了int type参数由于接收ViewCompat.TYPE_NON_TOUCH,以新起一轮滑动事件的方式处理手指离屏后 View 的 Fling 惯性滑动。

NestedScrollingParent2

// 子类调用 startNestedScroll(int) 或 NestedScrollingChildHelper#startNestedScroll(int) 时,
// 回调父控件的该方法。

// @param child: target的直接父控件
// @param target: 触发滑动的View
// @param axes: 垂直滚动 ViewCompat.SCROLL_AXIS_VERTICAL
//       水平滚动 ViewCompat.SCROLL_AXIS_HORIZONTAL
// @param type: 交互事件触发滑动 ViewCompat.TYPE_TOUCH
//       惯性触发滑动 ViewCompat.TYPE_NON_TOUCH
boolean onStartNestedScroll (View child, View target, int axes, int type){
    // 返回true表示父控件接受该滑动事件,后续嵌套滑动事件就会通知到该父控件
}

// 当 onStartNestedScroll() 返回true时,该方法被回调
void onNestedScrollAccepted(View child, View target, int axes, int type){
    // 可在这里做一些滑动前准备工作,通常是调super或helper
    super.onNestedScrollAccepted(child, target, axes, type);
    mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes, type);
}

// 在子View消费滑动事件前回调,可优先消费部分或全部

// @param target: 触发滑动的View
// @param dx: 本次水平方向的滑动距离/单位px
// @param dy: 本次垂直方向的滑动距离/单位px
// @param consumed: 用于告知子View当前父控件消费的滑动距离
// @param type: 交互事件类型 ViewCompat.TYPE_TOUCH/ViewCompat.TYPE_NON_TOUCH
void onNestedPreScroll (View target, int dx, int dy, int[] consumed, int type){
}

// 在子View消费滑动事件后回调,可消费剩余部分
// @param target: 触发滑动的View
// @param dxConsumed: 子View已经消费的水平滑动距离/px
// @param dyConsumed: 子View已经消费的垂直滑动距离/px
// @param dxUnconsumed: 剩余未消费的水平滑动距离/px
// @param dyUnconsumed: 剩余未消费的垂直滑动距离/px
// @param type: 交互事件类型 ViewCompat.TYPE_TOUCH/ViewCompat.TYPE_NON_TOUCH
void onNestedScroll (View target, int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, int type){
}

NestedScrollingChild2

// 子View准备滑动时调用(通常是ACtION_DOWN事件、惯性滑动开始时)
// 回调父控件的 onStartNestedScroll()

// @param axes: 垂直滚动 ViewCompat.SCROLL_AXIS_VERTICAL
//       水平滚动 ViewCompat.SCROLL_AXIS_HORIZONTAL
// @param type: 交互事件触发滑动 ViewCompat.TYPE_TOUCH
//       惯性触发滑动 ViewCompat.TYPE_NON_TOUCH
boolean startNestedScroll (int axes, int type){
    // 返回true表示当前View开启了嵌套滑动且找到了支持嵌套滑动的父控件
    return mNestedScrollingChildHelper.startNestedScroll(axes, type);
}

// 当前View开始滑动前调用(通常是ACTION_MOVE/computeScroll)
// 回调父控件的 onNestedPreScroll()

// @param dx: 本次水平方向的滑动距离/单位px
// @param dy: 本次垂直方向的滑动距离/单位px
// @param consumed: 用于接收父控件消费的滑动距离
// @param offsetWindow: 用于接收父控件滑动后当前View在Window中的偏移量
// @param type: 交互事件类型 ViewCompat.TYPE_TOUCH/ViewCompat.TYPE_NON_TOUCH
boolean dispatchNestedPreScroll (int dx, int dy, int[] consumed, int[] offsetInWindow, int type){
    // 返回true表示父组件消费了部分/全部滑动事件
    return mNestedScrollingChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type);
}

// 处于拖动状态时调用(通常是ACTION_MOVE/computeScroll)
// 回调父控件的 onNestedScroll()

// @param dxConsumed: 当前View已经消费的水平滑动距离/px
// @param dyConsumed: 当前View已经消费的垂直滑动距离/px
// @param dxUnconsumed: 剩余未消费的水平滑动距离/px
// @param dyUnconsumed: 剩余未消费的垂直滑动距离/px
// @param offsetWindow: 用于接收当前View滑动后在Window中的偏移量
// @param type: 交互事件类型 ViewCompat.TYPE_TOUCH/ViewCompat.TYPE_NON_TOUCH
boolean dispatchNestedScroll (int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow, int type){
    // 返回true表示事件成功分发
    return mNestedScrollingChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, offsetInWindow, type);
}


// 滑动停止时调用(通常时ACTION_UP/ACTION_CANCEL,且没有Fling事件)
// 回调父控件的 onStopNestedScroll()
void stopNestedScroll (int type){
    mNestedScrollingChildHelper.stopNestedScroll(type);
}


// 在View消费Fling事件前调用,将事件分发给父控件
// @param velocityX: 水平惯性滑动速率/像素/秒
// @param velocityY: 垂直惯性滑动速率/像素/秒
boolean dispatchNestedPreFling (float velocityX, float velocityY){
    // 返回true表示父控件消费该Fling事件(子View就不会进行下一步了)
    return mNestedScrollingChildHelper.dispatchNestedPreFling(velocityX, velocityY);
}
// 分发Fling事件给父控件
// @param velocityX: 水平惯性滑动速率/像素/秒
// @param velocityY: 垂直惯性滑动速率/像素/秒
// @param consumed: 子控件是否有消费该事件
boolean dispatchNestedFling (float velocityX, float velocityY, boolean consumed){
    // 返回true表示父控件响应了Fling事件
    return mNestedScrollingChildHelper.dispatchNestedFling(velocityX, velocityY);
}

NestedScrollView 源码分析

以下是嵌套滚动的核心代码走读主线流程:

  1. 子控件收到事件onTouchEvent#ACTION_DOWN: Child.startNestedScroll()
  2. 父控件收到回调 Parent.onStartNestedScroll(), 若返回true则进一步回调Parent.onNestedScrollAccepted()
  3. 子控件收到事件onTouchEvent#ACTION_MOVE:
    • 先分发 ScrollPre: Child.dispatchNestedPreScroll(),父控件收到回调Parent.onNestedPreScroll()
    • 子控件滚动自身
    • 再分发 Scroll: Child.dispatchNestedScroll(),父控件收到回调Parent.onNestedScroll()
  4. 子控件收到事件onTouchEvent#ACTION_UP/ACTION_CANCEL时调用Child.stopNestedScroll()
public class NestedScrollView extends FrameLayout implements NestedScrollingParent3,
        NestedScrollingChild3, ScrollingView {
    private final NestedScrollingParentHelper mParentHelper;
    private final NestedScrollingChildHelper mChildHelper;

    public NestedScrollView(@NonNull Context context, @Nullable AttributeSet attrs,
            int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        // 创建Helper用于辅助嵌套滚动
        mParentHelper = new NestedScrollingParentHelper(this);
        mChildHelper = new NestedScrollingChildHelper(this);
        // NestedScrollView默认是开的,普通View默认关
        setNestedScrollingEnabled(true);
    }


    // NestedScrollingChild
    @Override
    public boolean startNestedScroll(int axes, int type) {
        return mChildHelper.startNestedScroll(axes, type);
    }

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

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

    @Override
    public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
        return mChildHelper.dispatchNestedPreFling(velocityX, velocityY);
    }

    @Override
    public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
        return mChildHelper.dispatchNestedFling(velocityX, velocityY, consumed);
    }

    @Override
    public void stopNestedScroll(int type) {
        mChildHelper.stopNestedScroll(type);
    }

    // NestedScrollingParent
    @Override
    public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes,
            int type) {
        // 只对垂直方向的滚动感兴趣
        return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
    }

    @Override
    public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes,
            int type) {
        mParentHelper.onNestedScrollAccepted(child, target, axes, type);
        // 将start向父控件传递
        startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, type);
    }

    @Override
    public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed,
            int type) {
        // 不处理,将PreScroll向父控件传递
        dispatchNestedPreScroll(dx, dy, consumed, null, type);
    }

    @Override
    public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, int type, @NonNull int[] consumed) {
        // 滚动自身后,再向父容器传递Scroll
        final int oldScrollY = getScrollY();
        scrollBy(0, dyUnconsumed);
        final int myConsumed = getScrollY() - oldScrollY;
        if (consumed != null) {
            consumed[1] += myConsumed;
        }
        final int myUnconsumed = dyUnconsumed - myConsumed;
        mChildHelper.dispatchNestedScroll(0, myConsumed, 0, myUnconsumed, null, type, consumed);
    }

    @Override
    public void onStopNestedScroll(@NonNull View target, int type) {
        // 移除滚动类型标记后,再向父容器传递stop
        mParentHelper.onStopNestedScroll(target, type);
        stopNestedScroll(type);
    }


    @Override
    public boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY) {
        return dispatchNestedPreFling(velocityX, velocityY);
    }

    @Override
    public boolean onNestedFling(@NonNull View target, float velocityX, float velocityY,
            boolean consumed) {
        if (!consumed) { // 子控件没消费时,先向父容器传递再执行fling
            dispatchNestedFling(0, velocityY, true);
            fling((int) velocityY);
            return true;
        }
        return false;
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        switch (actionMasked) {
            case MotionEvent.ACTION_DOWN: {
                // 收到DOWN事件,作为Child调用start (嵌套滚动的起点)
                startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH);
                break;
            }
            case MotionEvent.ACTION_MOVE:
                // 滚动前转发PreScroll
                if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset,
                        ViewCompat.TYPE_TOUCH)) {
                    deltaY -= mScrollConsumed[1];
                    mNestedYOffset += mScrollOffset[1];
                }
                // 滚动自身,最终调到scrollTo()
                if (overScrollByCompat(0, deltaY, 0, getScrollY(), 0, range, 0,
                        0, true) && !hasNestedScrollingParent(ViewCompat.TYPE_TOUCH)) {
                    // Break our velocity if we hit a scroll barrier.
                    mVelocityTracker.clear();
                }
                // 滚动后转发Scroll
                final int scrolledDeltaY = getScrollY() - oldY;
                final int unconsumedY = deltaY - scrolledDeltaY;
                mScrollConsumed[1] = 0;
                dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset,
                        ViewCompat.TYPE_TOUCH, mScrollConsumed);
                break;
            case MotionEvent.ACTION_UP:
                // 速度探测下来满足fling条件则分发嵌套Fling
                if ((Math.abs(initialVelocity) >= mMinimumVelocity)) {
                    if (!dispatchNestedPreFling(0, -initialVelocity)) {
                        dispatchNestedFling(0, -initialVelocity, true);
                        fling(-initialVelocity); // 最后会调到 Scroller#fling()
                    }
                }
                endDrag(); // 结束滚动,会调用 stopNestedScroll()
                break;
            case MotionEvent.ACTION_CANCEL:
                endDrag(); // 结束滚动,会调用 stopNestedScroll()
                break;
        }

        return true;
    }

    @Override
    public void computeScroll() {
        // 惯性滚动前转发PreScroll
        dispatchNestedPreScroll(0, unconsumed, mScrollConsumed, null,
                ViewCompat.TYPE_NON_TOUCH);
        if (unconsumed != 0) {
            // 执行惯性滚动
            final int oldScrollY = getScrollY();
            overScrollByCompat(0, unconsumed, getScrollX(), oldScrollY, 0, range, 0, 0, false);
            final int scrolledByMe = getScrollY() - oldScrollY;
            unconsumed -= scrolledByMe;
            // 惯性滚动后转发PreScroll
            mScrollConsumed[1] = 0;
            dispatchNestedScroll(0, scrolledByMe, 0, unconsumed, mScrollOffset,
                    ViewCompat.TYPE_NON_TOUCH, mScrollConsumed);
            unconsumed -= mScrollConsumed[1];
        }
        if (!mScroller.isFinished()) {
            ViewCompat.postInvalidateOnAnimation(this);
        } else {
            // 惯性滚动完毕,执行stop
            stopNestedScroll(ViewCompat.TYPE_NON_TOUCH);
        }
    }
}

View 联动的应用

这里假设一个由 ViewPager 构建的多 Tab 页,每个 Tab 中有可垂直滚动的列表,需求是当列表上下滚动时上层页面中的某个组件可以向侧边收起和展开,如图所示:

tabs.png

LinearLayout{ // ①
    // 第一个联动View,当列表上拉时此组件隐藏,下拉时此组件出现;伴随着渐隐渐显
    LinearLayout {
    }

    LinearLayout{ // 横向Tab,列表上滑时吸顶
        RecyclerView // Tabs
        View // 第二个联动View,可贴边隐藏的红包组件
    }

    // 多页Fragment承载,ViewPager2内部实际会创建一个横向RecyclerView
    ViewPager2 {
        FrameLayout{
            // 页面列表容器,可能 NestedScrollView、RecyclerView、WebView
            NestedScrollView {
                LinearLayout{floors}
            }
        }
    }
}

由于 Nested 事件共享机制,ViewTree 中近叶端的内容列表滚动会上传到根端,则很容易想到在根 View 处(上述代码①处)监听并响应滚动以实现 View 联动效果。即自定义LinearLayout形如:

class NestedLinearLayout @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : LinearLayoutCompat(context, attrs), NestedScrollingParent2 {
    private val mCollapseHeight by lazy { // 可折叠部分的高度,单位PX
        context.resources.getDimensionPixelSize(R.dimen.collapse_height)
    }
    private val mNestedParentHelper by lazy { NestedScrollingParentHelper(this) }

    private val mTitleContainer: View by lazy { findViewById(R.id.title_container) }
    private val mTitle: View by lazy { findViewById(R.id.cl_title) }

    private val mRedPacket: View by lazy { findViewById(R.id.rl_red_packet) }
    private val mmRedPacketLayoutParams by lazy { mRedPacket.layoutParams as MarginLayoutParams }

    private val mViewPager: View by lazy { findViewById(R.id.viewpager) }

    private var deltaY = 0
    private var gapY = 0
    private var ratio = 0F

    override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
        super.onNestedPreScroll(target, dx, dy, consumed)
        if (mTitle.height <= 0) return
        deltaY = dy - consumed[1]
        // 下拉时不优先处理联动,因为SmartRefresh在顶部时,下拉刷新并不会消耗滚动距离
        // 这样onNestedScroll下移整个ViewTree也不会导致SmartRefreshLayout触发下拉刷新
        if (deltaY <= 0) return
        // 上拉直至全部隐藏
        if (deltaY >= 0) {
            if (mTitleContainer.translationY <= -mTitle.height) return
            gapY = Math.min(deltaY, mTitleContainer.translationY.toInt() + mTitle.height)
        }
        // 注意此处应该采用 translationY 或 offsetTopAndBottom() 方式移动View
        // 若使用 MarginLayoutParam 方式移动View,会影响Fragment内的 SmartRefreshLayout;一些设备上也会存在“抖动”问题
        mTitleContainer.translationY -= gapY
        mViewPager.translationY -= gapY
        consumed[1] += gapY
        ratio = 1 + mTitleContainer.translationY / mTitle.height.toFloat()
        mTitle.alpha = ratio
        mRedPacketLayoutParams.marginEnd = -(mRedPacket.width * ratio).toInt()
        mRedPacket.layoutParams = mRedPacketLayoutParams
    }

    override fun onNestedScroll(
        target: View,
        dxConsumed: Int,
        dyConsumed: Int,
        dxUnconsumed: Int,
        dyUnconsumed: Int,
        type: Int
    ) {
        if (dyUnconsumed >= 0 || mTitle.height <= 0) {
            super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed)
            return
        }
        deltaY = dyUnconsumed
        gapY = Math.max(deltaY, mTitleContainer.translationY.toInt())
        mTitleContainer.translationY -= gapY
        mViewPager.translationY -= gapY
        ratio = 1 + mTitleContainer.translationY / mTitle.height.toFloat()
        mTitle.alpha = ratio
        mRedPacketLayoutParams.marginEnd = -(mRedPacket.width * ratio).toInt()
        mRedPacket.layoutParams = mRedPacketLayoutParams
        super.onNestedScroll(
            target, dxConsumed, dyConsumed + deltaY,
            dxUnconsumed, dyUnconsumed - deltaY
        )
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val mode = MeasureSpec.getMode(heightMeasureSpec)
        val size = MeasureSpec.getSize(heightMeasureSpec)
        if (mode == MeasureSpec.EXACTLY && size > 0) {
            // 这里将屏幕“变高”,以使ViewPager可以满屏展示
            super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(size + mCollapseHeight, mode))
            return
        }
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    }
}

上述的做法非常简洁,侵入性也很低(各 Tab 页无需加入处理联动的代码);但有个限制就是列表的容器必须是NestedScrollingChild2的子类,这里通常作为滚动内容列表的组件 NestedScrollViewRecyclerView均实现了NestedScrollingChild2,唯独WebView是各例外;也就是说若将 WebView 直接作为子 View 联动是无效的,因为 WebView 并未实现事件共享。有 2 种解决方案:(1)将WebView嵌入NestedScrollView,会有一些隐患,比如 H5 的滚动问题。(2)自定义WebView实现NestedScrollingChild2(可参考的开源库1开源库2

另外这种方式虽然解决了联动问题,但需要在代码中记录XML里可折叠展开部分的高度,灵活性不足。那么难道就没有更灵活、更通用、更优雅的方案了吗?当然,Google针对这类联动场景推出了CoordinatorLayout布局。

CoordinatorLayout

Google 对 CoordinatorLayout 的定义是a super-powered FrameLayout,在工程中应用时其布局结构形如:

<CoordinatorLayout>
    <View app:layout_behavior="com.xxx.MyBehavior1" />
    <View app:layout_behavior="com.xxx.MyBehavior1" />
    <View app:layout_behavior="com.xxx.MyBehavior1" />
</CoordinatorLayout>

构建 Behavior

CoordinatorLayout.LayoutParams构建时获取 XML 属性后调静态方法parseBehavior反射创建 Behavior 实例。另外在CoordinatorLayout#onMeasure()前构建 DAG 时 (prepareChildren()) 会再解析子 View 的 LayoutParams(getResolvedLayoutParams()),此时还会(1)通过子 View 的getBehavior方法获取(如果子 View 实现了AttachedBehavior接口的话),(1)通过子 View 继承链上的@DefaultBehavior注解反射构建 Behavior 实例;Behavior 实例保存在子 View 的 LayoutParams 中。

public class CoordinatorLayout {
    // 嵌套类
    public static abstract class Behavior<V extends View> {
        // (1)干涉View上屏过程:Measure、Layout
        public boolean onMeasureChild(@NonNull CoordinatorLayout parent, @NonNull V child,
                int parentWidthMeasureSpec, int widthUsed,
                int parentHeightMeasureSpec, int heightUsed) {
            return false;
        }
        public boolean onLayoutChild(@NonNull CoordinatorLayout parent, @NonNull V child,
                int layoutDirection) {
            return false;
        }

        // (2)朴素的事件分发干涉
        public boolean onInterceptTouchEvent(@NonNull CoordinatorLayout parent, @NonNull V child,
                @NonNull MotionEvent ev) {
            return false;
        }
        public boolean onTouchEvent(@NonNull CoordinatorLayout parent, @NonNull V child,
                @NonNull MotionEvent ev) {
            return false;
        }

        // (3)Nested相关回调
        public void onXxxNestedXxx() {}

        // (4)用于构建DAG,与监听依赖View变化
        public boolean layoutDependsOn(@NonNull CoordinatorLayout parent, @NonNull V child,
                @NonNull View dependency) {
            return false;
        }
        public boolean onDependentViewChanged(@NonNull CoordinatorLayout parent, @NonNull V child,
                @NonNull View dependency) {
            return false;
        }
        public void onDependentViewRemoved(@NonNull CoordinatorLayout parent, @NonNull V child,
                @NonNull View dependency) {
        }
    }
}

使用 Behavior

CoordinatorLayout 的设计使用了中介者、观察者的设计模式思想:

  1. 布局干涉:在 CoordinatorLayout 的onMeasure()onLayout()方法中,会先调用子 View 的 Behavior 的对应方法。特别的,CoordinatorLayout 先通过 dfs 记录了 DAG 的拓扑序,所有被依赖的 View 会先被回调,而非按添加顺序回调。另外,Layout 的规则优先顺序为:

    • 若有为 View 设置 Anchor,则优先对齐 Anchor
    • 否则,若有为 View 设置 keyLine,则优先对齐 keyLine
    • 最后按类 FrameLayout 的规则来对子 View 进行 Layout
  2. 事件干涉:原始的 TouchEvent 在 CoordinatorLayout 中的onInterceptTouchEvent()onTouchEvent()方法中按添加顺序分发给各子 View 的 Behavior;特别的,分发时按 Z 轴值从大到小的顺序,且未限制事件坐标点一定在子 View 布局范围内。Nested 相关的事件,在 onNestedXxx 方法中按添加顺序分发给各子 View 的 Behavior;对于消耗的事件距离为各子 View 累计消耗值。

  3. 自定义依赖:通过在getViewTreeObserver()中添加OnPreDrawListener来监听 ViewTree 变化进而通知满足 DAG 依赖关系的子 View。

Google Sample 拆解

CoordinatorLayout 为实现 View 联动提供了一种可行的方案,但若只有该方案则要做的事情还很多。所幸,Google 为开发者还额外包装了两个类(其常见用法如下代码所示):

  • AppbarLayout:一个支持了很多和 CoordinatorLayout 配合的特效的垂直 LinearLayout
  • AppbarLayout.ScrollingViewBehavior:一个用在 Nested View 上和 AppbarLayout 配合的 Behavior
<androidx.coordinatorlayout.widget.CoordinatorLayout>
    <!-- 顶部布局,有默认Behavior,且其子项支持layout_scrollFlags属性用于联动滚动 -->
    <com.google.android.material.appbar.AppBarLayout>
        <androidx.appcompat.widget.Toolbar
        app:layout_scrollFlags="scroll"/>
    </com.google.android.material.appbar.AppBarLayout>

    <!-- 页面主体滚动内容,指定AppbarLayout.ScrollingViewBehavior后用于和AppbarLayout联动 -->
    <androidx.core.widget.NestedScrollView
        app:layout_behavior="@string/appbar_scrolling_view_behavior">
    </androidx.core.widget.NestedScrollView>
 </androidx.coordinatorlayout.widget.CoordinatorLayout>
public class AppBarLayout extends LinearLayout implements CoordinatorLayout.AttachedBehavior{
    // 提供了默认的Behavior实例
    public CoordinatorLayout.Behavior<AppBarLayout> getBehavior() {
        return new AppBarLayout.Behavior();
    }
    // 用于添加offset监听,注意,用户交互过程中引起offset的原因有2:
    // 1. 用户滚动AppbarLayout.ScrollingViewBehavior所在View而引起的联动滚动
    // 2. 用户直接滚动AppbarLayout
    public void addOnOffsetChangedListener(OnOffsetChangedListener listener) {
        addOnOffsetChangedListener((BaseOnOffsetChangedListener) listener);
    }
}

那么问题来了,AppbarLayoutAppBarLayout.Behavior AppbarLayout.ScrollingViewBehavior 具体做了啥?

  1. AppbarLayout.LayoutParams 解析了子 View 的app:layout_scrollFlags属性并保持在 LayoutParams 中。
  2. AppBarLayout.Behavior主要做了以下操作:
    • 在 Layout 时可针对不同的配置(如滚动 / 收起 / 展开)通过View#offsetXxxAndXxx()对 AppbarLayout 做一些偏移
    • 在 NestedScroll 时仍然还是通过View#offsetXxxAndXxx()对 AppbarLayout 偏移
  3. AppbarLayout.ScrollingViewBehavior 中定义了依赖(当前 View 依赖 AppbarLayout),当依赖的 View 变化时自身会通过View#offsetTopAndBottom()位移

工程应用

基于CoordinatorLayout改造上述的联动

<androidx.coordinatorlayout.widget.CoordinatorLayout>
    <com.chavin.nested.listener.MyAppbarLayout
        android:paddingTop="33dp">
        <!-- 上滑可隐藏的头布局 -->
        <androidx.constraintlayout.widget.ConstraintLayout
            app:layout_scrollFlags="scroll">
            <!-- Logo -->
            <ImageView />
            <!-- 搜索框部分 -->
            <LinearLayout> </LinearLayout>
        </androidx.constraintlayout.widget.ConstraintLayout>

        <!-- Tabs 和红包ICON -->
        <LinearLayout
            android:orientation="horizontal">
            <RelativeLayout>
                <!-- Tabs -->
                <androidx.recyclerview.widget.RecyclerView />
                <ImageView />
            </RelativeLayout>

            <!-- 小的联动Icon -->
            <RelativeLayout
                android:layout_marginEnd="-50dp">
                <androidx.appcompat.widget.AppCompatImageView />
            </RelativeLayout>
        </LinearLayout>
    </com.chavin.nested.listener.MyAppbarLayout>

    <!-- 承载各Fragment的ViewPager -->
    <androidx.viewpager2.widget.ViewPager2
        app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
class MyAppbarLayout @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : AppBarLayout(context, attrs) {
    // 重写AttachedBehavior#getBehavior(),返回自定义Behavior实例并添加Offset监听
    override fun getBehavior(): CoordinatorLayout.Behavior<AppBarLayout> {
        return MyBehavior(this).apply {
            addOnOffsetChangedListener(this)
        }
    }
}

/**
 * 自定义的AppbarBehavior
 * 注意:为处理滑动冲突,需调用package-private的方法,此处包名为google包
 */
package com.google.android.material.appbar
class MyBehavior(parent: AppBarLayout) : AppBarLayout.Behavior(), AppBarLayout.OnOffsetChangedListener {
    private val TAG = "chavin-behavior"
    private val mTitle: View by lazy { parent.findViewById(R.id.cl_title) }
    private val mSearch: View by lazy { parent.findViewById(R.id.rl_search) }
    private val mSearchLayoutParams by lazy { mSearch.layoutParams as ViewGroup.MarginLayoutParams }
    private var ratio: Float = 0F

    override fun onNestedPreScroll(
        coordinatorLayout: CoordinatorLayout,
        child: AppBarLayout,
        target: View,
        dx: Int,
        dy: Int,
        consumed: IntArray,
        type: Int
    ) {
        super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
        // 下拉时,若顶部还未完全展示,则优先消费掉这部分事件(默认是要到onNestedScroll才消费的)
        // 主要是处理和SmartRefreshLayout的冲突
        if (dy < 0 && topBottomOffsetForScrollingSibling + child.totalScrollRange > 0) {
            consumed[1] = scroll(coordinatorLayout, child, dy, -child.totalScrollRange, 0)
            if (child.isLiftOnScroll) {
                child.setLiftedState(child.shouldLift(target))
            }
        }
    }

    // 根据滑动距离处理半透明和小ICON位移
    override fun onOffsetChanged(appBarLayout: AppBarLayout?, verticalOffset: Int) {
        if (mTitle.height <= 0) return
        ratio = 1 - Math.abs(verticalOffset.toFloat() / mTitle.height)
        mTitle.alpha = ratio
        mSearchLayoutParams.marginEnd = -(mSearch.width * ratio).toInt()
        mSearch.layoutParams = mSearchLayoutParams
    }
}