功能

CoordinatorLayout 是一个“增强版”的 FrameLayout,它的主要作用便是作为一系列相互之间有交互行为的子View的容器。CoordinatorLayout像是一个事情转发中心,它感知全部子View的改动,并把这些改动通知给其他子View。

Behavior 就像是CoordinatorLayout与子View之间的通信协议,经过给CoordinatorLayout的子View指定Behavior,就能够完成它们之间的交互行为。Behavior能够用来完成一系列的交互行为和布局改动,比方说侧滑菜单、可滑动删除的UI元素,以及跟从着其他UI控件移动的按钮等。文字表达不行直观,直接看下面的效果图:

深入理解CoordinatorLayout与Behavior的作用

依靠

dependencies {
        implementation "androidx.coordinatorlayout:coordinatorlayout:1.1.0"
}

简略运用

网上讲CoordinatorLayout 时分常将AppBarLayout,CollapsingToolbarLayout放到一起去做Demo,尽管看上去做出来比较酷炫的效果,但是关于初学者而言不太好get到CoordinatorLayout以及Behavior在其中究竟起到什么作用。这儿用如下一个简略的Demo演示下,一个紫色按钮跟从黑块(MoveView)反向移动。

深入理解CoordinatorLayout与Behavior的作用

MoveView的代码十分简略,便是随着Touch事情的改动,改动自身的translation ,不是重点。

定义Behavior

由于咱们这儿只关心MoveView的方位改动,只用完成如下两个办法:

  • layoutDependsOn 回来true表明child依靠dependency , dependency的measure和layout都会在child之前进行,而且当dependency的巨细方位发生改动时分会回调 onDependentViewChanged
  • onDependentViewChanged 当一个依靠的View的巨细或方位发生改动时分会调用
class FollowBehavior : CoordinatorLayout.Behavior<View> {
    constructor() : super()
    constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
    override fun layoutDependsOn(
        parent: CoordinatorLayout,
        child: View,
        dependency: View
    ): Boolean {
        return dependency is MoveView
    }
    private var dependencyX = Float.MAX_VALUE
    private var dependencyY = Float.MAX_VALUE
    override fun onDependentViewChanged(
        parent: CoordinatorLayout,
        child: View,
        dependency: View
    ): Boolean {
        if (dependencyX == Float.MAX_VALUE || dependencyY == Float.MAX_VALUE) {
            dependencyX = dependency.x
 dependencyY = dependency.y
     } else {
                val dX = dependency.x - dependencyX
                val dy = dependency.y - dependencyY
                child.translationX -= dX
                child.translationY -= dy
                dependencyX = dependency.x
     dependencyY = dependency.y
     }
     return true
   }
}

绑定Behavior

绑定Behavior有两种方式:

  1. 经过布局参数去设置,你能够在xml中指定,当然也能够在Java代码中经过CoordinatorLayout.LayoutParams动态指定
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
    <com.threeloe.testdemo.view.MoveView
        android:background="@color/black"
        android:layout_width="100dp"
        android:layout_gravity="center_vertical"
        android:layout_height="100dp"/>
    <Button
        android:id="@+id/btn"
        android:layout_gravity="center_vertical"
        android:layout_marginStart="200dp"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="跟从黑块移动"
        app:layout_behavior="com.threeloe.testdemo.behavior.FollowBehavior"
        />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
  1. 默认绑定Behavior ,让View完成AttachedBehavior接口,完成getBehavior办法即可。这个优先级比布局参数低,当布局参数中没有指定Behavior时分会运用AttachedBehavior回来的。

class FollowTextView : TextView, CoordinatorLayout.AttachedBehavior{
    override fun getBehavior(): CoordinatorLayout.Behavior<*> {
        return FollowBehavior()
    }
}

长处

  • Behavior的复用性十分好。比方FollowBehavior能够给任何其他的子View直接运用
  • 当场景复杂的状况下Behavior也能表现出杰出的解耦。在没有CoordinatorLayout的状况下,咱们会给MoveView规划一个监听改动的接口,然后紫色按钮去监听Move的改动,然后自身移动。这在简略的场景下,不显得有什么,一旦场景变得复杂,相互之间有交互的子View较多的状况下,就会注册各种监听,代码之间的耦合会变得比较严重。CoordinatorLayout将各种子View的布局以及交互等行为笼统为Behavior,完成了代码的解耦,同时Behavior本身也具有很好的复用性。

进阶运用(Behavior阻拦全部)

Behavior简直能够阻拦全部View的行为,给子View添加Behavior之后,能够阻拦到父View CoordinatorLayout的measure,layout, 接触事情,嵌套滑动等等。 咱们经过下面滑动的Demo来说明:

对应的xml如下所示,完成十分简略全体上便是一个AppBarLayout + NestedScrollVIew.

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <androidx.core.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="二月二,龙抬头..." />
    </androidx.core.widget.NestedScrollView>
    <com.google.android.material.appbar.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
        <androidx.appcompat.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="?attr/colorPrimary"
            android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
            app:layout_scrollFlags="scroll|enterAlways"
            app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
            app:title="Title" />
         <TextView
            android:background="@color/purple_200"
            android:textColor="@color/white"
            android:text="惊蛰"
            android:gravity="center"
            android:layout_width="match_parent"
            android:layout_height="45dp"/>
    </com.google.android.material.appbar.AppBarLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

看到这个Demo,关于不太了解的同学会有比较多的疑问,我会经过以下四个问题帮大家更好了解Behavior的作用。

  1. 咱们开篇就说过,CoordinatorLayout是一个“增强版”的FrameLayout,那为什么上述xml中NestedScrollView没有设置任何的marginTop内容却没有被遮挡?
  2. NestedScrollView实践丈量的高度应该是多大?
  3. 为什么手指按在AppBarLayout的区域上也能触发滑动事情?
  4. 为什么手指在NestedScrollView上滑动能把ToolBar “顶出去” ?

阻拦Measure/Layout

第一个问题中按咱们了解ToolBar应该挡住NestedScrollView最上面一部分才对,但展示出来却刚好在ToolBar的下方,这其实是由于Behavior其实供给了onMeasureChild,onLayoutChild让咱们自己去接管对子VIew的丈量和布局。上述中NestedScrollView运用了ScrollingViewBehavior,它是规划给能在竖直方向上滑动而且支撑嵌套滑动的View运用的,运用这个Behavior能够和AppBarLayout之间发生联动效果。

首先看ScrollingViewBehavior的layoutDependsOn办法,是依靠于AppBarLayout的。

@Override
public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
  // We depend on any AppBarLayouts
  return dependency instanceof AppBarLayout;
}

咱们知道View的方位是由layout进程决议的,所以咱们直接看ScrollingViewBehavior的

boolean onLayoutChild(CoordinatorLayout parent, V child, int layoutDirection)

办法,终究找到要害的逻辑在父类HeaderScrollingViewBehavior的layoutChild中,要害代码主要就三行:


@Override
protected void layoutChild(
    @NonNull final CoordinatorLayout parent,
    @NonNull final View child,
    final int layoutDirection) {
  final List<View> dependencies = parent.getDependencies(child);
  //header即是AppBarLayout
  final View header = findFirstDependency(dependencies);
  if (header != null) {
    final CoordinatorLayout.LayoutParams lp =
        (CoordinatorLayout.LayoutParams) child.getLayoutParams();
    final Rect available = tempRect1;
    available.set(
        parent.getPaddingLeft() + lp.leftMargin,
        //top的方位是在header的bottom下
        header.getBottom() + lp.topMargin,
        parent.getWidth() - parent.getPaddingRight() - lp.rightMargin,
        parent.getHeight() + header.getBottom() - parent.getPaddingBottom() - lp.bottomMargin);
        ...
    final Rect out = tempRect2;
    //RTL处理
    GravityCompat.apply(
        resolveGravity(lp.gravity),
        child.getMeasuredWidth(),
        child.getMeasuredHeight(),
        available,
        out,
        layoutDirection);
    final int overlap = getOverlapPixelsForOffset(header);
    child.layout(out.left, out.top - overlap, out.right, out.bottom - overlap);
    verticalLayoutGap = out.top - header.getBottom();
  } else {
    // If we don't have a dependency, let super handle it
    super.layoutChild(parent, child, layoutDirection);
    verticalLayoutGap = 0;
  }
}

咱们给NestedScrollView设置高度为match_parent,那它的实践高度真的便是和CoordinatorLayout相同高么?实践并不是,由于它在屏幕上能展示的最大高度只有如下黄色箭头部分的长度,假如高度太大的话或许会导致一部分内容展示不出来。

深入理解CoordinatorLayout与Behavior的作用

这部分逻辑咱们能够在onMeasureChild办法中找到:

public boolean onMeasureChild(
    @NonNull CoordinatorLayout parent,
    @NonNull View child,
    int parentWidthMeasureSpec,
    int widthUsed,
    int parentHeightMeasureSpec,
    int heightUsed) {
  final int childLpHeight = child.getLayoutParams().height;
  //假如是match_parent或许wrap_content
  if (childLpHeight == ViewGroup.LayoutParams.MATCH_PARENT
 || childLpHeight == ViewGroup.LayoutParams.WRAP_CONTENT) {
    final List<View> dependencies = parent.getDependencies(child);
    //获取到AppBarLayout
    final View header = findFirstDependency(dependencies);
    if (header != null) {
      //父View也便是CoordinatorLayout的高度
      int availableHeight = View.MeasureSpec.getSize(parentHeightMeasureSpec);
      ...
      //getScrollRange(header)是AppBarLayout中能够滑动的规模,关于上述Demo中便是ToolBar的高度
      int height = availableHeight + getScrollRange(header);
      //AppBarLayout的整个高度
      int headerHeight = header.getMeasuredHeight();
      if (shouldHeaderOverlapScrollingChild()) {
        child.setTranslationY(-headerHeight);
      } else {
        //得到屏幕上黄色箭头的高度
        height -= headerHeight;
      }
      final int heightMeasureSpec =
          View.MeasureSpec.makeMeasureSpec(
              height,
              childLpHeight == ViewGroup.LayoutParams.MATCH_PARENT
 ? View.MeasureSpec.EXACTLY
 : View.MeasureSpec.AT_MOST);
      // Now measure the scrolling view with the correct height
      parent.onMeasureChild(
          child, parentWidthMeasureSpec, widthUsed, heightMeasureSpec, heightUsed);
      return true;
    }
  }
  return false;
}

阻拦Touch事情

咱们知道正常状况下,View要呼应Touch时间肯定要覆写View的onTouchEvent办法的,但是AppBarLayout并没有覆写。咱们当然能够持续联想Behavior, 但是上述xml中咱们并没有看到AppBarLayout有经过布局参数指定Behavior,不要忘了还有默认绑定的办法。

@Override
@NonNull
public CoordinatorLayout.Behavior<AppBarLayout> getBehavior() {
  return new AppBarLayout.Behavior();
}

Behavior同样供给了onInterceptTouchEvent和onTouchEvent让子View自己去处理Touch事情。

onInterceptTouchEvent如下:

public boolean onInterceptTouchEvent(
    @NonNull CoordinatorLayout parent, @NonNull V child, @NonNull MotionEvent ev) {
   ...
  // 假如是move事情而且在拖动中,就核算yDiff并阻拦事情
  if (ev.getActionMasked() == MotionEvent.ACTION_MOVE && isBeingDragged) {
    if (activePointerId == INVALID_POINTER) {
      // If we don't have a valid id, the touch down wasn't on content.
      return false;
    }
    int pointerIndex = ev.findPointerIndex(activePointerId);
    if (pointerIndex == -1) {
      return false;
    }
    int y = (int) ev.getY(pointerIndex);
    int yDiff = Math.abs(y - lastMotionY);
    if (yDiff > touchSlop) {
      lastMotionY = y;
      return true;
    }
  }
  if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) {
    activePointerId = INVALID_POINTER;
    int x = (int) ev.getX();
    int y = (int) ev.getY();
    //假如canDragView而且事情是在子View的规模中就认为进入拖动状态
    isBeingDragged = canDragView(child) && parent.isPointInChildBounds(child, x, y);
    if (isBeingDragged) {
      lastMotionY = y;
      activePointerId = ev.getPointerId(0);
      ensureVelocityTracker();
      // There is an animation in progress. Stop it and catch the view.
      if (scroller != null && !scroller.isFinished()) {
        scroller.abortAnimation();
        return true;
      }
    }
  }
  if (velocityTracker != null) {
    velocityTracker.addMovement(ev);
  }
  return false;
}

canDragView的逻辑如下,只有当NestedScrollView的scrollY是0的时分,也便是还没滑动过时分,才能拖动AppBarLayout。

@Override
boolean canDragView(T view) {
  ...
  // Else we'll use the default behaviour of seeing if it can scroll down
  if (lastNestedScrollingChildRef != null) {
    // If we have a reference to a scrolling view, check it
    final View scrollingView = lastNestedScrollingChildRef.get();
    return scrollingView != null
        && scrollingView.isShown()
        && !scrollingView.canScrollVertically(-1);
  } else {
    // Otherwise we assume that the scrolling view hasn't been scrolled and can drag.
    return true;
  }
}

onTouchEvent办法中核算移动距离dy,然后调用scroll办法翻滚。

@Override
public boolean onTouchEvent(
    @NonNull CoordinatorLayout parent, @NonNull V child, @NonNull MotionEvent ev) {
  boolean consumeUp = false;
  switch (ev.getActionMasked()) {
    case MotionEvent.ACTION_MOVE:
      final int activePointerIndex = ev.findPointerIndex(activePointerId);
      if (activePointerIndex == -1) {
        return false;
      }
      final int y = (int) ev.getY(activePointerIndex);
      int dy = lastMotionY - y;
      lastMotionY = y;
      // We're being dragged so scroll the ABL
      scroll(parent, child, dy, getMaxDragOffset(child), 0);
      break;
      ...
  return isBeingDragged || consumeUp;
}

还有一个问题是在AppBarLayout scroll的进程中,NestedScrollView是怎样移动的呢?这个问题其实便是和咱们“简略运用”部分的那个问题相似,毫无疑问是在ScrollingViewBehavior的onDependentViewChanged中完成的,这儿不再具体分析代码了。

阻拦嵌套滑动

最后一个问题,为什么手指在NestedScrollView上滑动能把ToolBar “顶出去” ?这个假如从传统的事情分发视点看的话好像已经超出了咱们的“认知”,一个滑动事情怎样能从一个View搬运给另一个平级的子View,在了解这个之前咱们需求先了解下NestedScroling机制,本文只做简略介绍,需求详细了解的话能够看这篇NestedScrolling机制详解 。

NestedScrolling机制

NestedScroling机制供给两个接口:

  • NestedScrollingParent,嵌套滑动的父View需求完成。已有完成CoordinatorLayout,NestedScroView
  • NestedScrollingChild, 嵌套滑动的子View需求完成。已有完成RecyclerView,NestedScroView

由于发现规划的能力有些不足,Google前后又引进NestedScrollingParent2/NestedScrollingChild2以及NestedScrollingParent3/NestedScrollingChild3。

Google在给我供给这两个接口的时分,同时也给咱们供给了完成这两个接口时一些办法的规范完成,

分别是

  • NestedScrollingChildHelper
  • NestedScrollingParentHelper

咱们在完成上面两个接口的办法时,只需求调用相应Helper中相同签名的办法即可。

基本原理:

对原始的事情分发机制做了一层封装,子View完成NestedScrollingChild接口,父View完成NestedScrollingParent 接口。 在NetstedScroll的世界里,NestedScrollingChild是发动机,它自己和父VIew都能消费滑动事情,但是父VIew具有优先消费权。假定发生一个竖直滑动,简略来说滑动事情会由NestedScrollingChild先接收到发生一个dy,然后问询NestedScrollingParent要耗费多少(dyConsumed),自己再拿dy-dyConsumed来进行滑动。当然NestedScrollingChild有或许自己本身也并不会耗费完,此时会再向父View陈述状况。

深入理解CoordinatorLayout与Behavior的作用

在咱们的Demo中CoordinatorLayout便是这个滑动事情的转发中心,它接收到来自NestedScrollView的滑动事情,并将这些事情经过Behavior转发给AppBarLayout。

AppBarLayout.Behavior相关完成

  1. onStartNestedScroll 决议是否要接受嵌套滑动事情
@Override
public boolean onStartNestedScroll(
    @NonNull CoordinatorLayout parent,
    @NonNull T child,
    @NonNull View directTargetChild,
    View target,
    int nestedScrollAxes,
    int type) {
  // 假如是竖直方向的翻滚而且有可翻滚的child
  final boolean started =
      (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0
          && (child.isLiftOnScroll() || canScrollChildren(parent, child, directTargetChild));
  if (started && offsetAnimator != null) {
    // Cancel any offset animation
    offsetAnimator.cancel();
  }
  // A new nested scroll has started so clear out the previous ref
  lastNestedScrollingChildRef = null;
  // Track the last started type so we know if a fling is about to happen once scrolling ends
  lastStartedType = type;
  return started;
}

private boolean canScrollChildren(
    @NonNull CoordinatorLayout parent, @NonNull T child, @NonNull View directTargetChild) {
   //总滑动规模大约0 而且 CoordinatorLayout 减去NestedScrollView的高度小于 AppBarLayout的高度
  return child.hasScrollableChildren()
      && parent.getHeight() - directTargetChild.getHeight() <= child.getHeight();
}
  1. onNestedPreScroll 在NestedScrollChild滑动之前决议自己是否要耗费
@Override
public void onNestedPreScroll(
    CoordinatorLayout coordinatorLayout,
    @NonNull 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) {
     // 竖直方向的耗费仿制,传回给NestedScrollView
      consumed[1] = scroll(coordinatorLayout, child, dy, min, max);
    }
  }
  if (child.isLiftOnScroll()) {
    child.setLiftedState(child.shouldLift(target));
  }
}
final int scroll(
    CoordinatorLayout coordinatorLayout, V header, int dy, int minOffset, int maxOffset) {
  return setHeaderTopBottomOffset(
      coordinatorLayout,
      header,
      //核算新的offset
      getTopBottomOffsetForScrollingSibling() - dy,
      minOffset,
      maxOffset);
}
int setHeaderTopBottomOffset(
    CoordinatorLayout parent, V header, int newOffset, int minOffset, int maxOffset) {
  final int curOffset = getTopAndBottomOffset();
  int consumed = 0;
  if (minOffset != 0 && curOffset >= minOffset && curOffset <= maxOffset) {
    //鸿沟处理
    newOffset = MathUtils.clamp(newOffset, minOffset, maxOffset);
    if (curOffset != newOffset) {
     //将整个View的方位再竖直方向上平移
      setTopAndBottomOffset(newOffset);
      // Update how much dy we have consumed
      consumed = curOffset - newOffset;
    }
  }
  return consumed;
}
  1. 子View滑动完毕之后决议自己是否要耗费滑动事情
@Override
public void onNestedScroll(
    CoordinatorLayout coordinatorLayout,
    @NonNull T child,
    View target,
    int dxConsumed,
    int dyConsumed,
    int dxUnconsumed,
    int dyUnconsumed,
    int type,
    int[] consumed) {
  if (dyUnconsumed < 0) {
    //NestedScroll View向下滑,滑动到自己内容的顶部时分,dy并没有耗费完毕,这个时分事情给AppBarLayout持续滑动
    consumed[1] =
        scroll(coordinatorLayout, child, dyUnconsumed, -child.getDownNestedScrollRange(), 0);
  }
  if (dyUnconsumed == 0) {
    // The scrolling view may scroll to the top of its content without updating the actions, so
    // update here.
    updateAccessibilityActions(coordinatorLayout, child);
  }
}
  1. 中止嵌套滑动
@Override
public void onStopNestedScroll(
    CoordinatorLayout coordinatorLayout, @NonNull T abl, View target, int type) {
  // onStartNestedScroll for a fling will happen before onStopNestedScroll for the scroll. This
  // isn't necessarily guaranteed yet, but it should be in the future. We use this to our
  // advantage to check if a fling (ViewCompat.TYPE_NON_TOUCH) will start after the touch scroll
  // (ViewCompat.TYPE_TOUCH) ends
  if (lastStartedType == ViewCompat.TYPE_TOUCH || type == ViewCompat.TYPE_NON_TOUCH) {
    // If we haven't been flung, or a fling is ending
    snapToChildIfNeeded(coordinatorLayout, abl);
    if (abl.isLiftOnScroll()) {
      abl.setLiftedState(abl.shouldLift(target));
    }
  }
  // Keep a reference to the previous nested scrolling child
  lastNestedScrollingChildRef = new WeakReference<>(target);
}