功能
CoordinatorLayout 是一個“增強版”的 FrameLayout,它的主要作用就是作為一系列相互之間有交互行為的子View的容器。CoordinatorLayout像是一個事件轉發(fā)中心,它感知所有子View的變化,并把這些變化通知給其他子View。
Behavior就像是CoordinatorLayout與子View之間的通信協(xié)議,通過給CoordinatorLayout的子View指定Behavior,就可以實現(xiàn)它們之間的交互行為。Behavior可以用來實現(xiàn)一系列的交互行為和布局變化,比如說側滑菜單、可滑動刪除的UI元素,以及跟隨著其他UI控件移動的按鈕等。文字表達不夠直觀,直接看下面的效果圖:
依賴
dependencies {
implementation "androidx.coordinatorlayout:coordinatorlayout:1.1.0"
}
簡單使用
很多文章講CoordinatorLayout 時候常將AppBarLayout,CollapsingToolbarLayout放到一起去做Demo,雖然看上去做出來比較酷炫的效果,但是對于初學者而言不太好get到CoordinatorLayout以及Behavior在其中到底起到什么作用。這里用如下一個簡單的Demo演示下,一個紫色按鈕跟隨黑塊(MoveView)反向移動。

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有兩種方式:
- 通過布局參數(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>
- 默認綁定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來說明:

對應的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非常常見,但是我相信并不是所有的同學都能回答出來下面幾個問題:
- 我們開篇就說過,CoordinatorLayout是一個“增強版”的FrameLayout,那為什么上述xml中NestedScrollView沒有設置任何的marginTop內容卻沒有被遮擋?
- NestedScrollView實際測量的高度應該是多大?
- 為什么手指按在AppBarLayout的區(qū)域上也能觸發(fā)滑動事件?
- 為什么手指在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一樣高么?實際并不是,因為它在屏幕上能展示的最大高度只有如下黃色箭頭部分的長度,如果高度太大的話可能會導致一部分內容展示不出來。
這部分邏輯我們可以在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報告情況。

在我們的Demo中CoordinatorLayout就是這個滑動事件的轉發(fā)中心,它接收到來自NestedScrollView的滑動事件,并將這些事件通過Behavior轉發(fā)給AppBarLayout。
AppBarLayout.Behavior相關實現(xiàn)
- 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();
}
- 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;
}
- 子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);
}
}
- 停止嵌套滑動
@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);
}