事件分发不够,NestedScrolling来凑

2,667 阅读10分钟

前言

前俩篇文章,咱们聊了事件分发的原理。通过原理和工作经验,我们明白仅靠熟知事件分发远远不足以做出细腻的用户体验。

就比如最常见的一个场景:

很明显,如果想要实现这个效果,通过常规的事件分发机制很显然是没办法实现的。毕竟上面的Bar一旦开始滑动,说明它已经确定消费此事件,那么在一次滑动中,下面的RecyclerView无论如何也拿不到此次事件。

**但是!**既然RecyclerView + CoordinatorLayout实现了这个效果,那就说明有方法做。这个方法也就是今天要聊的NestedScrolling机制~~~

内容简介:这篇文章不聊用法,主要进行源码分析~~~

#正文

如果我们了解事件分发机制,那么我们就会很清楚,事件分发存在的弊端:一旦一个View消费此事件,那么这个消费事件序列将完全有此View承包了。因此我们根本不可能做到一个View消费一半的MOVE事件,然后把余下的MOVE事件给别人。

为了解决这个问题,Google仍然基于事件分发的思想,在事件分发的流程中增加了NestedScrolling机制。提起来很洋气,说白了就是俩个接口:NestedScrollingParentNestedScrollingChild

当然较新的SDK会发现这个接口变成了NestedScrollingParent2NestedScrollingChild2NestedScrollingParent2继承自NestedScrollingParent。因此我们文章也是基于NestedScrollingParent/NestedScrollingChild进行分析的。

此机制其实异常的简单,总结起来就是一句话:实现了NestedScrollingChild接口的内部View在滑动的时候,首先将滑动距离dx和dy交给实现了NestedScrollingParent接口的外部View(可以不是直接父View),外部View可对其进行部分消耗,剩余的部分再还给内部View

NestedScrollingParent

第一次点进这个接口...wtf?这么多方法?...不过冷静下来,其实很简单。

public interface NestedScrollingParent {
    // 开始滑动时被调用。返回值表示是否消费内部View滑动时的参数(x、y)。
    boolean onStartNestedScroll(View child, View target, int nestedScrollAxes);

    //接收内部View(可以是非直接子View)滑动
    void onNestedScrollAccepted(View child, View target, int nestedScrollAxes);

   //停止消费内部View的事件
    void onStopNestedScroll(View target);
    
    // 内部View滑动时调用
    void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed);

    //内部View开始滑动之前调用。参数dx和dy表示滑动的横向和纵向距离,
    //consumed参数表示消耗的横向和纵向距离,如纵向滑动,需要消耗了dy/2,
    //表示外部View和内部View分别处理这次滑动距离的 1/2
    void onNestedPreScroll(View target, int dx, int dy, int[] consumed);

    //内部View开始Fling时调用
    boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed);

    //内部View开始Fling之前调用。参数velocityX 和velocityY 表示水平方向和垂直方向的速度。
    //返回值表示是否处理了这次Fling操作,返回true表示拦截掉这次操作,false表示不拦截。
    boolean onNestedPreFling(View target, float velocityX, float velocityY);

    //纵向滑动或横向滑动
    int getNestedScrollAxes();
}

NestedScrollingChild

public interface NestedScrollingChild {

    // 设置是否支持NestedScrolling
    void setNestedScrollingEnabled(boolean enabled);
    
    boolean isNestedScrollingEnabled();

    //准备开始滑动
    boolean startNestedScroll(int axes);
    
    //停止滑动
    void stopNestedScroll();

   //是否有嵌套滑动的外部View
    boolean hasNestedScrollingParent();
   
    //在内部View滑动的时候,通知外部View。
    boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow);
    
    //在内部View滑动之前,先让外部View处理这次滑动。
    //参数dx 和 dy表示这次滑动的横向和纵向距离,参数consumed表示外部View消耗这次滑动的横向和纵向距离。
    //返回值表示外部View是否有消耗这次滑动。
    boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow);
   
   //在内部View进行Fling操作时,通知外部View。
    boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);
    
   //与dispatchNestedPreScroll 方法相似...
    boolean dispatchNestedPreFling(float velocityX, float velocityY);
}

解读

方法很多,但是真的真的真的很简单!!!

我猜认真看过每一个方法命名的小伙伴,甚至已经猜到这套机制是怎么实现的了。

接下来的解读,直接根据实现代码,来彻底捋清楚NestedScrollingParentNestedScrollingChild这么多方法的用意。

首先,NestedScrolling机制,是有内向外,由子向父进行“试探性询问”的这么一个机制。因此咱们先从实现了NestedScrollingChildRecyclerView看起。

一、RecyclerView

public class RecyclerView extends ViewGroup implements ScrollingView, NestedScrollingChild2 

1.1、startNestedScroll()过程

onInterceptTouchEvent()我们可以看到startNestedScroll()在DOWN事件出现的时候被调用:

@Override
public boolean onInterceptTouchEvent(MotionEvent e) {
    // 省略部分代码
    final int action = e.getActionMasked();
    final int actionIndex = e.getActionIndex();

    switch (action) {
        case MotionEvent.ACTION_DOWN:
            // 调用startNestedScroll()
            startNestedScroll(nestedScrollAxis, TYPE_TOUCH);
            break;
        // 省略部分代码
    // retrun值取决于当前RecyclerView是否滑动
    return mScrollState == SCROLL_STATE_DRAGGING;
}

点进startNestedScroll()后,我们会发现具体实现被代理到NestedScrollingChildHelper中:

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

而Helper内部,通过getParent()拿到父View,然后调用NestedScrollingParent2中的onStartNestedScroll()

public boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type) {
    // 省略部分代码
    // 是否启动NestedScrolling
    if (isNestedScrollingEnabled()) {
        ViewParent p = mView.getParent();
        View child = mView;
        while (p != null) {
            // 如果Parent不为null,调用父类的onStartNestedScroll()方法,如果此方法返回true,则直接return true
            if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)) {
                setNestedScrollingParentForType(type, p);
                // 如果if为true,则调用onNestedScrollAccepted()
                ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes, type);
                return true;
            }
            if (p instanceof View) {
                child = (View) p;
            }
            p = p.getParent();
        }
    }
    return false;
}
public static boolean onStartNestedScroll(ViewParent parent, View child, View target,
            int nestedScrollAxes, int type) {
        if (parent instanceof NestedScrollingParent2) {
            return ((NestedScrollingParent2) parent).onStartNestedScroll(child, target,
                    nestedScrollAxes, type);
        // 省略部分代码

CoordinatorLayout(实现了NestedScrollingParent2)中的onStartNestedScroll()会发现,CoordinatorLayout又将此方法转到了Behavior之中。此时方法的返回值取决于Behavior之中onStartNestedScroll()的返回值。

我用的是AppBarLayout,所以此时的Behavior的实现在AppBarLayout中。

@Override
public boolean onStartNestedScroll(View child, View target, int axes, int type) {
    boolean handled = false;
    final int childCount = getChildCount();
    for (int i = 0; i < childCount; i++) {
        final View view = getChildAt(i);
        // 省略部分代码
        final LayoutParams lp = (LayoutParams) view.getLayoutParams();
        final Behavior viewBehavior = lp.getBehavior();
        if (viewBehavior != null) {
            final boolean accepted = viewBehavior.onStartNestedScroll(this, view, child,
                    target, axes, type);
            handled |= accepted;
            lp.setNestedScrollAccepted(type, accepted);
        } else {
            lp.setNestedScrollAccepted(type, false);
        }
    }
    return handled;
}

如果返回true,那么就意味着Behavior中的setNestedScrollAccepted()被调用。此方法在CoordinatorLayout有一个默认实现,说白了就是一个成员变量赋值为true。

void setNestedScrollAccepted(int type, boolean accept) {
    switch (type) {
        case ViewCompat.TYPE_TOUCH:
            mDidAcceptNestedScrollTouch = accept;
            break;
        case ViewCompat.TYPE_NON_TOUCH:
            mDidAcceptNestedScrollNonTouch = accept;
            break;
    }
}

这个变量说白了,就是记录某个子View能够响应NestedScrolling。

1.2、dispatchNestedPreScroll()过程

接下来我们看点“真刀真枪”的东西。

@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];
            }
        }
        // 省略部分代码
    }
    return true;
}

我们可以看到,在onTouchEvent()中的MOVE事件中,RecyclerView调用了dispatchNestedPreScroll()

而此时也意味着RecyclerView开始消费此事件。

我们可以看出dispatchNestedPreScroll()方法同样通过NestedScrollingChildHelper,然后到ViewParentCompat转到了CoordinatorLayoutonNestedPreScroll()中。

CoordinatorLayout同样通过找到可以响应的Behavior,调用其onNestedPreScroll()的实现。

@Override
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed, int  type) {
    // 遍历子View
    final int childCount = getChildCount();
    for (int i = 0; i < childCount; i++) {
        final View view = getChildAt(i);
        // 省略部分代码
        final LayoutParams lp = (LayoutParams) view.getLayoutParams();
        // 判断当前View是否能够响应NestedScrolling,也就是咱们startNestedScroll()过程中设置的值
        if (!lp.isNestedScrollAccepted(type)) {
            continue;
        }

        final Behavior viewBehavior = lp.getBehavior();
        if (viewBehavior != null) {
            mTempIntPair[0] = mTempIntPair[1] = 0;
            viewBehavior.onNestedPreScroll(this, view, target, dx, dy, mTempIntPair, type);
            // 省略部分代码
        }
    }
    // 省略部分代码
}

AppBarLayout中的实现是这样的:

public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, T child, View target, int dx, int dy, int[] consumed, int type) {
    if(dy != 0) {
        int min;
        int max;
        if(dy < 0) {
            min = -child.getTotalScrollRange();
            max = min + child.getDownNestedPreScrollRange();
        } else {
            min = -child.getUpNestedPreScrollRange();
            max = 0;
        }

        if(min != max) {
            // 调用scroll,滑动自己并把消费了的dy,通过数组传回去。
            consumed[1] = this.scroll(coordinatorLayout, child, dy, min, max);
            // 判断是否需要stop
            this.stopNestedScrollIfNeeded(dy, child, target, type);
        }
    }
}

执行到CoordinatorLayout中的时候,不知道有小伙伴注意到吗,这一系列的方法的返回值已经为void了。因为dispatchNestedPreScroll()的返回值在Helper中进行处理:

public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
        @Nullable int[] offsetInWindow, @NestedScrollType int type) {
    if (isNestedScrollingEnabled()) {
        final ViewParent parent = getNestedScrollingParentForType(type);

        if (dx != 0 || dy != 0) {
            // 省略部分代码
            ViewParentCompat.onNestedPreScroll(parent, mView, dx, dy, consumed, type);
            // 省略部分代码,只要consumed的0、1位不为0,即返回true
            return consumed[0] != 0 || consumed[1] != 0;
            // 省略部分代码
        }
        return false;
    }
}

对于RecyclerView来说,dispatchNestedPreScroll()返回ture,则意味着此次MOVE事件被上级某个View消费了,接下来对于自己来说的就是根据剩余的事件做一些自己该做的消费。

if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset, TYPE_TOUCH)) {
    // dx、dy减去其他View消费的dx、dy剩下的也就是自己能够消费的事件了。
    dx -= mScrollConsumed[0];
    dy -= mScrollConsumed[1];
    // 省略部分代码
}

1.3、dispatchNestedScroll()过程

此方法在RecyclerView自身有滑动动作的时候被调用:

boolean scrollByInternal(int x, int y, MotionEvent ev) {
    // 省略部分代码,dispatchNestedScroll()被调用
    if (dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset,
            TYPE_TOUCH)) {
        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;
}

.........

毫无疑问,此方法又会最终调用到AppBarLayout中的Behavior中:

public void onNestedScroll(CoordinatorLayout coordinatorLayout, T child, View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) {
    if(dyUnconsumed < 0) {
        this.scroll(coordinatorLayout, child, dyUnconsumed, -child.getDownNestedScrollRange(), 0);
        this.stopNestedScrollIfNeeded(dyUnconsumed, child, target, type);
    }

    if(child.isLiftOnScroll()) {
        child.setLiftedState(target.getScrollY() > 0);
    }

}

OK,就这样原本属于RecyclerView的事件,硬生生的传递给了被人。只能“玩”别人“玩”剩下的事件...

1.4、stopNestedScroll()过程

既然是stop,那必然是Parent主动发起了,没错上述过程中stopNestedScrollIfNeeded(dyUnconsumed, child, target, type)被调用,即意味着尝试stop:

private void stopNestedScrollIfNeeded(int dy, T child, View target, int type) {
    if(type == 1) {
        int curOffset = this.getTopBottomOffsetForScrollingSibling();
        if(dy < 0 && curOffset == 0 || dy > 0 && curOffset == -child.getDownNestedScrollRange()) {
            ViewCompat.stopNestedScroll(target, 1);
        }
    }
}

一旦满足条件,通过ViewCompat,反向调用到RecyclerView上:

public static void stopNestedScroll(@NonNull View view, @NestedScrollType int type) {
    if (view instanceof NestedScrollingChild2) {
        ((NestedScrollingChild2) view).stopNestedScroll(type);
    } else if (type == ViewCompat.TYPE_TOUCH) {
        stopNestedScroll(view);
    }
}

..... 总之就是层层调用,完成stop过程个最终通知。

二、CoordinatorLayout

对于CoordinatorLayout来说,已经没有什么好聊的了,因为RecyclerView过程中我们已经基本了解到了它的作用...

作为一个中间人,把NestedScrollingChild传过来的事件,转给Behavior,以达到将一个子View滑动的事件传递给另一个子View

实战

来做个这样的一个效果:

原理啥的上边都已经聊清楚了,这里直接贴代码(很简单,没几行):

class NestedTopLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
        FrameLayout(context, attrs, defStyleAttr), NestedScrollingParent {
    private var mShowTop = false
    private var mHideTop = false
    private val mTopViewHeight = 800
    private val defaultMarginTop = 800

    override fun onFinishInflate() {
        super.onFinishInflate()
        scrollBy(0, -defaultMarginTop)
    }

    override fun onStartNestedScroll(@NonNull child: View, @NonNull target: View, nestedScrollAxes: Int): Boolean {
        return nestedScrollAxes and ViewCompat.SCROLL_AXIS_VERTICAL != 0
    }

    override fun onNestedScrollAccepted(@NonNull child: View, @NonNull target: View, nestedScrollAxes: Int) {}
    override fun onStopNestedScroll(@NonNull target: View) {}
    override fun onNestedScroll(@NonNull target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int) {}

    override fun onNestedPreScroll(@NonNull target: View, dx: Int, dy: Int, @NonNull consumed: IntArray) {
        var dy = dy
        mShowTop = dy < 0 && Math.abs(scrollY) < mTopViewHeight && !target.canScrollVertically(-1)
        if (mShowTop) {
            if (Math.abs(scrollY + dy) > mTopViewHeight) {
                dy = -(mTopViewHeight - Math.abs(scrollY))
            }
        }
        mHideTop = dy > 0 && scrollY < 0
        if (mHideTop) {
            if (dy + scrollY > 0) {
                dy = -scrollY
            }
        }
        if (mShowTop || mHideTop) {
            consumed[1] = dy
            scrollBy(0, dy)
        }
    }
    
    override fun onNestedFling(@NonNull target: View, velocityX: Float, velocityY: Float, consumed: Boolean): Boolean {
        return scrollY != 0
    }

    override fun onNestedPreFling(@NonNull target: View, velocityX: Float, velocityY: Float): Boolean {
        return scrollY != 0
    }

    override fun getNestedScrollAxes(): Int {
        return ViewCompat.SCROLL_AXIS_VERTICAL
    }
}

尾声

加上这篇文章,事件分发这一块,感觉已经是足够了。完全可以应对各种各样的滑动体验需求。

OK,就酱~

我是一个应届生,最近和朋友们维护了一个公众号,内容是我们在从应届生过渡到开发这一路所踩过的坑,以及我们一步步学习的记录,如果感兴趣的朋友可以关注一下,一同加油~

个人公众号:咸鱼正翻身