开发者

一文带你彻底搞懂Behavior实现复杂的视觉联动效果原理

开发者 https://www.devze.com 2023-04-23 10:18 出处:网络 作者: 开发者如是说
目录1、什么是 Behavior ?2、Behavior 接口的重要方法2.1 Behavior 生命周期相关的回调方法2.2 子控件着色相关的回调方法2.3 测量和布局相关的回调方法2.4 描述子控件之间依赖关系的回调2.5 与窗口变化和状态保存与
目录
  • 1、什么是 Behavior ?
  • 2、Behavior 接口的重要方法
    • 2.1 Behavior 生命周期相关的回调方法
    • 2.2 子控件着色相关的回调方法
    • 2.3 测量和布局相关的回调方法
    • 2.4 描述子控件之间依赖关系的回调
    • 2.5 与窗口变化和状态保存与恢复相关的事件
  • 3、Behavior 的事件分发机制
    • 3.1 安卓的触摸事件分发机制
    • 3.2 与触摸事件分发机制相关的方法
    • 3.3 安卓的 NestedScrolling 机制
    • 3.4 与 NestedScrolling 相关的方法
    • 3.5 触摸事件分发机制小结
  • 4、总结

    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)
    

    这些事件一般python不会用到。

    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 里则会判断事件的类型,比如点击和长按之类的,而且可以看到系统源码在判断具体的事件类型的时候开发者_Go学习使用了 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 消费 50dpjs 的事件,剩下的再交给列表处理,让其向上滑动 6dp 的距离。

    在 NestedScrolling 机制中定义了 NestedScrollingChildNestedScrollingParent 两个接口(为了支持更多功能后续又定义了 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: {
                // ..www.devze.com.
                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, @NonNandroidull 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 方法。另外,只有当一个 CphpoordinatorLayout 包含的所有的 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 事件进行统筹拦截和调度。用一个图来表示整体分发逻辑,如下,

    一文带你彻底搞懂Behavior实现复杂的视觉联动效果原理

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

    4、总结

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

    以上就是一文带你彻底搞懂Behavior实现复杂的视觉联动效果原理的详细内容,更多关于Behavior复杂视觉联动的资料请关注我们其它相关文章!

    0

    精彩评论

    暂无评论...
    验证码 换一张
    取 消