实现复杂视觉联动特效,彻底搞懂 Behavior

1

什么是 Behavior ?

Behavior 是谷歌 Material 设计中重要的一员,用来实现复杂的视觉联动效果。

使用 Behavior 的控件需要被包裹在 CoordinateLayout 内部。Behavior 就是一个接口。Behavior 实际上就是通过将 CoordinateLayout 的布局和触摸事件传递给 Behavior 来实现的。

从设计模式上讲,就一个 Behavior 而言,它是一种访问者模式,相当于将 CoordinateLayout 的布局和触摸过程对外提供的访问器;而多个 Behavior 在 CoordinateLayout 内部的事件分发则是一种责任链机制,呈现出长幼有序的状态。

以 layout 过程为例,

 
// androidx.coordinatorlayout.widget.CoordinatorLayout#onLayout
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    final int layoutDirection = ViewCompat.getLayoutDirection(this);
    final int childCount = mDependencySortedChildren.size();
    for (int i = 0; i < childCount; i++) {
        final View child = mDependencySortedChildren.get(i);
        if (child.getVisibility() == GONE) {
            continue;
        }

        final LayoutParams lp = (LayoutParams) child.getLayoutParams();
        final Behavior behavior = lp.getBehavior();

        // 这里
        if (behavior == null || !behavior.onLayoutChild(this, child, layoutDirection)) {
            onLayoutChild(child, layoutDirection);
        }
    }
}

可见 Behavior 就是将子控件的布局通过 onLayoutChild() 方法对外回调了出来。控件的 behavior 优先拦截和处理 layout 事件。

那 Behavior 相比于我们直接覆写触摸事件的形式处理手势有什么优点呢?

其优点在于,我们可以将页面的布局、触摸和滑动等事件封装到 Behavior 接口的实现类中以达到交互逻辑的复用和解耦的目的。

2

Behavior 接口的重要方法

Behavior 接口定义了许多方法,用于将 CoordinateLayout 的布局、测量和事件分发事件向外传递。这里我根据其作用将其归纳为以下几组。

2.1 Behavior 生命周期相关的回调方法

首先是 Behavior 和 LayoutParams 关联和接触绑定时回调的方法。它们被回调的世纪分别是,

  • onAttachedToLayoutParams:LayoutParams 的构造函数中回调

  • onDetachedFromLayoutParams:调用 LayoutParams 的 setBehavior,用一个新的 Behavior 覆盖旧的 Behavior 时回调

 
public void onAttachedToLayoutParams(@NonNull CoordinatorLayout.LayoutParams params) {}
public void onDetachedFromLayoutParams() {}

2.2 子控件着色相关的回调方法

然后是跟 scrim color 相关的方法,这些方法会在 CoordinateLayout 的绘制过程中被调用。主要是跟绘制相关的,即用来对指定的 child 进行着色。

这里的 child 是指该 Behavior 所关联的控件,parent 就是指包裹这个 child 的最外层的 CoordinatorLayout. 后面的方法都是如此。

 
public int getScrimColor(@NonNull CoordinatorLayout parent, @NonNull V child)
public float getScrimOpacity(@NonNull CoordinatorLayout parent, @NonNull V child)
public boolean blocksInteractionBelow(@NonNull CoordinatorLayout parent, @NonNull V child)

2.3 测量和布局相关的回调方法

然后一组方法是用来将 CoordinatorLayout 的测量和布局过程对外回调。不论是测量还是布局的回调方法,优先级都是回调方法优先。也就是回调方法可以通过返回 true 拦截 CoordinatorLayout 的逻辑。

另外,CoordinatorLayout 里使用 Behavior 的时候只会从直系子控件上读取,所以,子控件的子控件上即便有 Behavior 也不会被拦截处理。所以,在一般使用 CoordinatorLayout 的时候,如果我们需要在某个控件上使用 Behavior,都是将其作为 CoordinatorLayout 的直系子控件。

还要注意,一个 CoordinatorLayout 的直系子控件包含多个 Behavior 的时候,这些 Behavior 被回调的先后顺序和它们在 CoordinatorLayout 里布局的先后顺序一致。也就是说,排序在前的子控件优先拦截和处理事件。这和中国古代的王位继承制差不多。

 
public boolean onMeasureChild(@NonNull CoordinatorLayout parent, @NonNull V child, int parentWidthMeasureSpec, 
                int widthUsed, int parentHeightMeasureSpec, int heightUsed)
public boolean onLayoutChild(@NonNull CoordinatorLayout parent, @NonNull V child, int layoutDirection)

2.4 描述子控件之间依赖关系的回调

接下来的一组方法用来描述子控件之间的依赖关系。它的作用原理是,当 CoordinatorLayout 发生以下三类事件:

  • NestedScroll 滚动事件,通过 onNestedScroll() 获取(后面会分析这个事件工作原理)。

  • PreDraw 事件,通过 ViewTreeObserver.OnPreDrawListener 获取到该事件。

  • 控件被移除事件,通过 OnHierarchyChangeListener 获取到该事件。

的时候会使用 layoutDependsOn() 方法,针对 CoordinatorLayout 的每个子控件,判断其他子控件与其是否构成依赖关系。如果构成了依赖关系,就回调其对应的 Behavior 的 onDependentViewChanged() 或者 onDependentViewRemoved() 方法。

 
public boolean layoutDependsOn(@NonNull CoordinatorLayout parent, @NonNull V child, @NonNull View dependency)
public boolean onDependentViewChanged(@NonNull CoordinatorLayout parent, @NonNull V child, @NonNull View dependency)
public void onDependentViewRemoved(@NonNull CoordinatorLayout parent, @NonNull V child, @NonNull View dependency)

2.5 与窗口变化和状态保存与恢复相关的事件

然后是与窗口变化和状态保存与恢复相关的事件。

 
public WindowInsetsCompat onApplyWindowInsets(@NonNull CoordinatorLayout coordinatorLayout,
                @NonNull V child, @NonNull WindowInsetsCompat insets)
public boolean onRequestChildRectangleOnScreen(@NonNull CoordinatorLayout coordinatorLayout,
                @NonNull V child, @NonNull Rect rectangle, boolean immediate)
public void onRestoreInstanceState(@NonNull CoordinatorLayout parent, @NonNull V child,
                @NonNull Parcelable state)
public Parcelable onSaveInstanceState(@NonNull CoordinatorLayout parent, @NonNull V child)
public boolean getInsetDodgeRect(@NonNull CoordinatorLayout parent, @NonNull V child,
                @NonNull Rect rect)

这些事件一般不会用到。

3

Behavior 的事件分发机制

以上是 Behavior 内定义的一些方法。Behavior 主要的用途还是用来做触摸事件的分发。这里,我们来重点关注和触摸事件分发相关的方法。

3.1 安卓的触摸事件分发机制

首先我们来回顾传统的事件分发机制。当 window 将触摸事件交给 DecorView 之后,触摸事件在 ViewGroup 和 View 之间传递遵循如下模型:

 
// ViewGroup
public boolean dispatchTouchEvent(MotionEvent ev) {
    if ACTION_DOWN 事件并且 FLAG_DISALLOW_INTERCEPT 允许拦截 {
        final boolean intercepted = onInterceptTouchEvent(ev) // 注意 onInterceptTouchEvent 的位置
    }
    boolean handled;
    if !intercepted {
        if child == null {
            handled = super.dispatchTouchEvent(ev)
        } else {
            handled = child.dispatchTouchEvent(ev)
        }
    }
    return handled;
}

// View
public boolean dispatchTouchEvent(MotionEvent event) {
    if mOnTouchListener.onTouch(this, event) {
        return true
    }
    if onTouchEvent(event) { // 注意 onTouchEvent 的位置
        return true
    }
    return false
}

所以,子控件可以通过调用父控件的 requestDisallowInterceptTouchEvent() 方法不让父控件拦截事件。但是这种拦截机制完全是基于默认的实现逻辑。如果父控件修改了 requestDisallowInterceptTouchEvent() 方法或者 dispatchTouchEvent() 方法的逻辑,子控件的约束效果是无效的。

父控件通过 onInterceptTouchEvent() 拦截事件只能拦截部分事件。

相比于父控件,子控件的事件分发则简单得多。首先是先将事件交给自定义的 mOnTouchListener 来处理,其没有消费才将其交给默认的 onTouchEvent 来处理。在 onTouchEvent 里则会判断事件的类型,比如点击和长按之类的,而且可以看到系统源码在判断具体的事件类型的时候使用了 post Runnable 的方式。

在父控件中如果子控件没有处理,则父控件将会走 View 的 dispatchTouchEvent() 逻辑,也就是去判断事件的类型来消费了。

3.2 与触摸事件分发机制相关的方法

在 Behavior 中定义了两个与触摸事件分发相关的方法,

 
public boolean onInterceptTouchEvent(@NonNull CoordinatorLayout parent, @NonNull V child, @NonNull MotionEvent ev)
public boolean onTouchEvent(@NonNull CoordinatorLayout parent, @NonNull V child, @NonNull MotionEvent ev)

对照上面的事件分发机制中 onInterceptTouchEvent 和 onTouchEvent 的逻辑,这里的 Behavior 的拦截逻辑是:CoordinatorLayout 按照 Behavior 的出现顺序进行遍历,先走 CoordinatorLayout 的 onInterceptTouchEvent,如果一个 Behavior 的 onInterceptTouchEvent 拦截了该事件,则会记录拦截该事件的 View 并给其他 Behavior 的 onInterceptTouchEvent 发送给一个 Cancel 类型的触摸事件。然后,在 CoordinatorLayout 的 onTouchEvent 方法中会执行该 View 对应的 Behavior 的 onTouchEvent 方法。

3.3 安卓的 NestedScrolling 机制

安卓在 5.0 上引入了 NestedScrolling 机制。之所以引入该事件是因为传统的事件分发机制 MOVE 事件当父控件拦截了之后就无法再交给子 View. 而 NestedScrolling 机制可以指定在一个滑动事件中,父控件和子控件分别消费多少。比如,在一个向上的滑动事件中,我们需要 toolbar 先向上滑动 50dp,然后列表再向上滑动。此时,我们可以先让 toolbar 消费 50dp 的事件,剩下的再交给列表处理,让其向上滑动 6dp 的距离。

在 NestedScrolling 机制中定义了 NestedScrollingChild 和 NestedScrollingParent 两个接口(为了支持更多功能后续又定义了 NestedScrollingChild2 和 NestedScrollingChild3 等接口)。外部容器通常实现 NestedScrollingParent 接口,而子控件通常实现 NestedScrollingChild 接口。在常规的事件分发机制中,子控件(比如 RecyclerView 或者 NestedScrollView )会在 Move 事件中找到父控件,如果该父控件实现了 NestedScrollingParent 接口,就会通知该父控件发生了滑动事件。然后,父控件可以对滑动事件进行进一步的分发。以 RecyclerView 为例:

 
// androidx.recyclerview.widget.RecyclerView#onTouchEvent
public boolean onTouchEvent(MotionEvent e) {
    // ...
    switch (action) {
        case MotionEvent.ACTION_MOVE: {
            // ...
            if (dispatchNestedPreScroll(
                canScrollHorizontally ? dx : 0,
                canScrollVertically ? dy : 0,
                mReusableIntPair, mScrollOffset, TYPE_TOUCH
            )) {
                // ...
            }
        }
    }
}

这里 dispatchNestedPreScroll() 就是滑动事件的分发逻辑,它最终会走到 ViewParentCompat 的 onNestedPreScroll() 方法,并在该方法中向上交给父控件进行分发。代码如下,

 
public static void onNestedPreScroll(ViewParent parent, View target, int dx, int dy,
        int[] consumed, int type) {
    if (parent instanceof NestedScrollingParent2) {
        ((NestedScrollingParent2) parent).onNestedPreScroll(target, dx, dy, consumed, type);
    } else if (type == ViewCompat.TYPE_TOUCH) {
        if (Build.VERSION.SDK_INT >= 21) {
            parent.onNestedPreScroll(target, dx, dy, consumed);
        } else if (parent instanceof NestedScrollingParent) {
            ((NestedScrollingParent) parent).onNestedPreScroll(target, dx, dy, consumed);
        }
    }
}

3.4 与 NestedScrolling 相关的方法

在 CoordinatorLayout 中,与 NestedScrolling 机制相关的方法主要分成 scroll 和 fling 两类。

 
public boolean onStartNestedScroll(@NonNull CoordinatorLayout coordinatorLayout,
                @NonNull V child, @NonNull View directTargetChild, @NonNull View target,
                @ScrollAxis int axes, @NestedScrollType int type)
public void onNestedScrollAccepted(@NonNull CoordinatorLayout coordinatorLayout,
                @NonNull V child, @NonNull View directTargetChild, @NonNull View target,
                @ScrollAxis int axes, @NestedScrollType int type)
public void onStopNestedScroll(@NonNull CoordinatorLayout coordinatorLayout,
                @NonNull V child, @NonNull View target, @NestedScrollType int type)
public void onNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull V child,
                @NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed,
                int dyUnconsumed, @NestedScrollType int type, @NonNull int[] consumed)
public void onNestedPreScroll(@NonNull CoordinatorLayout coordinatorLayout,
                @NonNull V child, @NonNull View target, int dx, int dy, @NonNull int[] consumed,
                @NestedScrollType int type)

public boolean onNestedFling(@NonNull CoordinatorLayout coordinatorLayout,
                @NonNull V child, @NonNull View target, float velocityX, float velocityY,
                boolean consumed)
public boolean onNestedPreFling(@NonNull CoordinatorLayout coordinatorLayout,
                @NonNull V child, @NonNull View target, float velocityX, float velocityY)

以 scroll 类型的事件为例,其工作的原理:

CoordinatorLayout 中会对子控件进行遍历,然后将对应的事件传递给子控件的 Behavior (若有)的对应方法。对于滑动类型的事件,在滑动事件传递的时候先传递 onStartNestedScroll 事件,用来判断某个 View 是否拦截滑动事件。而在 CoordinatorLayout 中,会交给 Beahvior 判断是否处理该事件。然后 CoordinatorLayout 会将该 Behavior 是否拦截该事件的状态记录到对应的 View 的 LayoutParam. 然后,当 CoordinatorLayout 的 onNestedPreScroll 被调用的时候,会读取 LayoutParame 上的状态以决定是否调用该 Behavior 的 onNestedPreScroll 方法。另外,只有当一个 CoordinatorLayout 包含的所有的 Behavior 都不处理该滑动事件的时候,才判定 CoordinatorLayout 不处理该滑动事件。

伪代码如下,

 
// CoordinatorLayout
public boolean onStartNestedScroll(View child, View target, int axes, int type) {
    boolean handled = false;
    for 遍历子 view {
        Behavior viewBehavior = view.getLayoutParams().getBehavior()
        final boolean accepted = viewBehavior.onStartNestedScroll();
        handled |= accepted;
        // 根据 accepted 给 view 的 layoutparams 置位
        view.getLayoutParams().setNestedScrollAccepted(accepted) 
    }
    return handled;
}

// CoordinatorLayout
public void onStopNestedScroll(View target, int type) {
    for 遍历子 view {
        // 读取 view 的 layoutparams 的标记位
        if view.getLayoutParams().isNestedScrollAccepted(type) {
            Behavior viewBehavior = view.getLayoutParams().getBehavior()
            // 将事件交给 behavior
            viewBehavior.onStopNestedScroll(this, view, target, type)
        }
    }
}

在消费事件的时候是通过覆写 onNestedPreScroll() 等方法,以该方法为例,

 
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed, int  type) {}

这里的 dx 和 dy 是滚动在水平和方向上的总的值,我们消费的值通过 consumed 指定。比如 dy 表示向上一共滚动了 50dp,而我们的 toolbar 需要先向上滚动 44dp,那么我们就将 44dp 的数值赋值给 consumed 数组(方法签名中的数组是按引用传递的)。这样父控件就可以将剩下的 6dp 交给列表,所以列表最终会向上滚动 6dp。

3.5 触摸事件分发机制小结

按照上述 Behavior 的实现方式,一个 Behavior 是可以拦截到 CoordinatorLayout 内所有的 View 的 NestedScrolling 事件的。因而,我们可以在一个 Behavior 内部对 CoordinatorLayout 内的所有的 NestedScrolling 事件进行统筹拦截和调度。用一个图来表示整体分发逻辑,如下,

这里需要注意,按照我们上面的分析,CoordinatorLayout 收集到的事件 NestedScrolling 事件,如果一个控件并没有实现 NestedScrollingChild 接口,或者更严谨的说,没有将滚动事件传递给 CoordinatorLayout,那么 Behavior 就无法接受到滚动事件。但是对于普通的触摸事件 Behavior 是可以拦截到的。

4

总结

这篇文章主要用来分析 Behavior 的整个工作原理。因为篇幅已经比较长,这里就不再拿具体的案例进行分析了。对于 Behavior,只要摸透了它是如何工作的,具体的案例分析起来也不会太难。

 

转自:实现复杂视觉联动特效,彻底搞懂 Behavior

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值