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实现复杂的视觉联动效果原理的详细内容,更多关于Behavior复杂视觉联动的资料请关注编程网其它相关文章!