验证码: 看不清楚,换一张 查询 注册会员,免验证
  • {{ basic.site_slogan }}
  • 打开微信扫一扫,
    您还可以在这里找到我们哟

    关注我们

Behavior怎么实现复杂的视觉联动效果

阅读:843 来源:乙速云 作者:代码code

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 机制中定义了 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: {
                  // ...
                  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 事件进行统筹拦截和调度。用一个图来表示整体分发逻辑,如下,

      Behavior怎么实现复杂的视觉联动效果

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

    分享到:
    *特别声明:以上内容来自于网络收集,著作权属原作者所有,如有侵权,请联系我们: hlamps#outlook.com (#换成@)。
    相关文章
    {{ v.title }}
    {{ v.description||(cleanHtml(v.content)).substr(0,100)+'···' }}
    你可能感兴趣
    推荐阅读 更多>