• 首页 首页 icon
  • 工具库 工具库 icon
    • IP查询 IP查询 icon
  • 内容库 内容库 icon
    • 快讯库 快讯库 icon
    • 精品库 精品库 icon
    • 问答库 问答库 icon
  • 更多 更多 icon
    • 服务条款 服务条款 icon

android的CoordinatorLayout和Behavior的作用

武飞扬头像
juejin
帮助419

前言

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

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

依赖

dependencies {

        implementation "androidx.coordinatorlayout:coordinatorlayout:1.1.0"

}

简单使用

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

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

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

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

}

这篇好文章是转载于:学新通技术网

  • 版权申明: 本站部分内容来自互联网,仅供学习及演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,请提供相关证据及您的身份证明,我们将在收到邮件后48小时内删除。
  • 本站站名: 学新通技术网
  • 本文地址: /boutique/detail/taneiba
系列文章
更多 icon
同类精品
更多 icon
继续加载