深入理解CoordinatorLayout與Behavior的作用

功能

CoordinatorLayout 是一個“增強版”的 FrameLayout,它的主要作用就是作為一系列相互之間有交互行為的子View的容器。CoordinatorLayout像是一個事件轉發(fā)中心,它感知所有子View的變化,并把這些變化通知給其他子View。

Behavior就像是CoordinatorLayout與子View之間的通信協(xié)議,通過給CoordinatorLayout的子View指定Behavior,就可以實現(xiàn)它們之間的交互行為。Behavior可以用來實現(xiàn)一系列的交互行為和布局變化,比如說側滑菜單、可滑動刪除的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的位置變化,只用實現(xiàn)如下兩個方法:

  • layoutDependsOn 返回true表示child依賴dependency , dependency的measure和layout都會在child之前進行,并且當dependency的大小位置發(fā)生變化時候會回調 onDependentViewChanged
  • onDependentViewChanged 當一個依賴的View的大小或位置發(fā)生變化時候會調用
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. 通過布局參數(shù)去設置,你可以在xml中指定,當然也可以在Java代碼中通過CoordinatorLayout.LayoutParams動態(tài)指定
<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實現(xiàn)AttachedBehavior接口,實現(xiàn)getBehavior方法即可。這個優(yōu)先級比布局參數(shù)低,當布局參數(shù)中沒有指定Behavior時候會使用AttachedBehavior返回的。 之前的版本是使用DefaultBehavior注解,由于性能原因已經(jīng)棄用。

class FollowTextView : TextView, CoordinatorLayout.AttachedBehavior{

    override fun getBehavior(): CoordinatorLayout.Behavior<*> {
        return FollowBehavior()
    }

}

優(yōu)點

  • Behavior的復用性非常好,比如FollowBehavior可以給任何其他的子View直接使用。
  • 當場景復雜的情況下Behavior也能表現(xiàn)出良好的解耦,在沒有CoordinatorLayout的情況下,我們會給MoveView設計一個監(jiān)聽變化的接口,然后紫色按鈕去監(jiān)聽Move的變化,然后自身移動。這在簡單的場景下,不顯得有什么,一旦場景變得復雜,相互之間有交互的子View較多的情況下,就會注冊各種監(jiān)聽,代碼之間的耦合會變得比較嚴重。CoordinatorLayout將各種子View的布局以及交互等行為抽象為Behavior,并對Behavior進行管理,實現(xiàn)了代碼的解耦。

進階使用(Behavior攔截一切)

Behavior幾乎可以攔截所有View的行為,給子View添加Behavior之后,可以攔截到父View CoordinatorLayout的measure,layout, 觸摸事件,嵌套滑動等等。 我們通過下面這個常見的Demo來說明:

進階使用.gif

對應的xml如下所示,實現(xiàn)非常簡單整體上就是一個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="二月二,龍?zhí)ь^..." />

    </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的區(qū)域上也能觸發(fā)滑動事件?
  4. 為什么手指在NestedScrollView上滑動能把ToolBar “頂出去” ?

我會通過以上四個問題幫大家更好理解Behavior的作用

攔截Measure/Layout

第一個問題中按我們理解ToolBar應該擋住NestedScrollView最上面一部分才對,但展示出來卻剛好在ToolBar的下方,這其實是因為Behavior其實提供了onMeasureChild,onLayoutChild讓我們自己去接管對子VIew的測量和布局。上述中NestedScrollView使用了ScrollingViewBehavior,它是設計給能在豎直方向上滑動并且支持嵌套滑動的View使用的,使用這個Behavior能夠和AppBarLayout之間產(chǎn)生聯(lián)動效果。

首先看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并沒有覆寫。我們當然可以繼續(xù)聯(lián)想Behavior, 但是上述xml中并沒有看到AppBarLayout有通過布局參數(shù)指定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的范圍中就認為進入拖動狀態(tài)
    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中實現(xiàn)的,這里不再具體分析代碼了。

攔截嵌套滑動

最后一個問題,為什么手指在NestedScrollView上滑動能把ToolBar “頂出去” ?這個如果從傳統(tǒng)的事件分發(fā)角度看的話好像已經(jīng)超出了我們的“認知”,一個滑動事件怎么能從一個View轉移給另一個平級的子View,在了解這個之前我們需要先了解下NestedScroling機制,本文只做簡單介紹,需要詳細了解的話可以看這篇NestedScrolling機制詳解 。

NestedScrolling機制

NestedScroling機制提供兩個接口:

  • NestedScrollingParent,嵌套滑動的父View需要實現(xiàn)。已有實現(xiàn)CoordinatorLayout,NestedScroView
  • NestedScrollingChild, 嵌套滑動的子View需要實現(xiàn)。已有實現(xiàn)RecyclerView,NestedScroView

由于發(fā)現(xiàn)設計的能力有些不足,Google前后又引入NestedScrollingParent2/NestedScrollingChild2以及NestedScrollingParent3/NestedScrollingChild3。

Google在給我提供這兩個接口的時候,同時也給我們提供了實現(xiàn)這兩個接口時一些方法的標準實現(xiàn),

分別是

  • NestedScrollingChildHelper
  • NestedScrollingParentHelper

我們在實現(xiàn)上面兩個接口的方法時,只需要調用相應Helper中相同簽名的方法即可。

基本原理:

對原始的事件分發(fā)機制做了一層封裝,子View實現(xiàn)NestedScrollingChild接口,父View實現(xiàn)NestedScrollingParent 接口。 在NetstedScroll的世界里,NestedScrollingChild是發(fā)動機,它自己和父VIew都能消費滑動事件,但是父VIew具有優(yōu)先消費權。假設產(chǎn)生一個豎直滑動,簡單來說滑動事件會由NestedScrollingChild先接收到產(chǎn)生一個dy,然后詢問NestedScrollingParent要消耗多少(dyConsumed),自己再拿dy-dyConsumed來進行滑動。當然NestedScrollingChild有可能自己本身也并不會消耗完,此時會再向父View報告情況。

嵌套滑動.png

在我們的Demo中CoordinatorLayout就是這個滑動事件的轉發(fā)中心,它接收到來自NestedScrollView的滑動事件,并將這些事件通過Behavior轉發(fā)給AppBarLayout。

AppBarLayout.Behavior相關實現(xiàn)

  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繼續(xù)滑動
    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);
}

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容