美文网首页
深入理解CoordinatorLayout与Behavior的作

深入理解CoordinatorLayout与Behavior的作

作者: 三雒 | 来源:发表于2021-07-25 00:19 被阅读0次

    功能

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

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

    image

    依赖

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

    简单使用

    很多文章讲CoordinatorLayout 时候常将AppBarLayout,CollapsingToolbarLayout放到一起去做Demo,虽然看上去做出来比较酷炫的效果,但是对于初学者而言不太好get到CoordinatorLayout以及Behavior在其中到底起到什么作用。这里用如下一个简单的Demo演示下,一个紫色按钮跟随黑块(MoveView)反向移动。

    简单Demo.gif

    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返回的。 之前的版本是使用DefaultBehavior注解,由于性能原因已经弃用。
    
    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来说明:

    进阶使用.gif

    对应的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非常常见,但是我相信并不是所有的同学都能回答出来下面几个问题:

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

    我会通过以上四个问题帮大家更好理解Behavior的作用

    拦截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一样高么?实际并不是,因为它在屏幕上能展示的最大高度只有如下黄色箭头部分的长度,如果高度太大的话可能会导致一部分内容展示不出来。

    image

    这部分逻辑我们可以在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报告情况。

    嵌套滑动.png

    在我们的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);
    }
    
    

    相关文章

      网友评论

          本文标题:深入理解CoordinatorLayout与Behavior的作

          本文链接:https://www.haomeiwen.com/subject/rqmvmltx.html