Android進階寶典 -- CoordinatorLayout協調者佈局原理分析並實現吸頂效果

語言: CN / TW / HK

在上一節Android進階寶典 -- NestedScroll嵌套滑動機制實現吸頂效果 中,我們通過自定義View的形式完成了TabBar的吸頂效果,其實除了這種方式之外,MD控件中提供了一個CoordinatorLayout,協調者佈局,這種佈局同樣可以實現吸頂效果,但是很多夥伴們對於CoordinatorLayout有點兒陌生,或者認為它用起來比較麻煩,其實大多數原因是因為對於它的原理不太熟悉,不知道什麼時候該用什麼樣的組件或者behavior,所以首先了解它的原理,就能夠對CoordinatorLayout駕輕就熟。

1 CoordinatorLayout功能介紹

首先我們先從源碼中能夠看到,CoordinatorLayout只實現了parent接口(這裏如果不清楚parent接口是幹什麼的,建議看看前面的文章,不然根本不清楚我講的是什麼),説明CoordinatorLayout只能作為父容器來使用。 java public class CoordinatorLayout extends ViewGroup implements NestedScrollingParent2, NestedScrollingParent3 所以對於CoordinatorLayout來説,它的主要作用就是用來管理子View或者子View之間的聯動交互。所以在上一篇文章中,我們介紹的NestScroll嵌套滑動機制,它其實能夠實現child與parent的嵌套滑動,但是是1對1的;而CoordinatorLayout是能夠管理子View之間的交互,屬於1對多的。

那麼CoordinatorLayout能夠實現哪些功能呢?\ (1)子控件之間的交互依賴;\ (2)子控件之間的嵌套滑動;\ (3)子控件寬高的測量;\ (4)子控件事件攔截與響應;

那麼以上所有的功能實現,全部都是依賴於CoordinatorLayout中提供的一個Behavior插件。CoordinatorLayout將所有的事件交互都扔給了Behavior,目的就是為了解耦;這樣就不需要在父容器中做太多的業務邏輯,而是通過不同的Behavior控制子View產生不同的行為

1.1 CoordinatorLayout的依賴交互原理

首先我們先看第一個功能,處理子控件之間的依賴交互,這種處理方式其實在很多地方我們都能看到,例如一些小的懸浮窗,你可以拖動它到任何地方,點擊讓其消失的時候,跟隨這個View的其他View也會一併消失。

那麼如何使用CoordinatorLayout來實現這個功能呢?首先我們先看一下CoordinatorLayout處理這種事件的原理。

image.png

看一下上面的圖,在協調者佈局中,有3個子View:dependcy、child1、child2;當dependcy的發生位移或者消失的時候,那麼CoordinatorLayout會通知所有與dependcy依賴的控件,並且調用他們內部聲明的Behavior,告知其依賴的dependcy發生變化了。

那麼如何判斷依賴哪個控件,CoordinatorLayout-Behavior提供一個方法:layoutDependsOn,接收到的通知是什麼樣的呢?onDependentViewChanged / onDependentViewRemoved 分別代表依賴的View位置發生了變化和依賴的View被移除,這些都會交給Behavior來處理。

1.2 CoordinatorLayout的嵌套滑動原理

這部分其實還是挺簡單的,如果有上一篇文章的基礎,那麼對於嵌套滑動就非常熟悉了

image.png

因為我們前面説過, CoordinatorLayout只能作為父容器,因為只實現了parent接口,所以在CoordinatorLayout內部需要有一個child,那麼當child滑動時,首先會把實現傳遞給父容器,也就是CoordinatorLayout,再由CoordinatorLayout分發給每個child的Behavior,由Behavior來完成子控件的嵌套滑動。

image.png

這裏有個問題,每個child都一定是CoordinatorLayout的直接子View嗎?

剩下的兩個功能就比較簡單了,同樣也是在Behavior中進行處理,就不做介紹了。

2 CoordinatorLayout源碼分析

首先這裏先跟大家説一下,在看源碼的時候,我們最好依託於一個實例的實現,從而帶着問題去源碼中尋找答案,例如我們在第一節中提到過的CoordinatorLayout的四大功能,可能都會有這些問題:

(1)e.g. 控件之間的交互依賴,為什麼在一個child下設置一個Behavior,就能夠跟隨DependentView的位置變化一起變化,他們是如何做依賴通信的?

(2)我們在XML中設置Behavior,是在什麼時候實例化的?

(3)我們既然使用了CoordinatorLayout佈局,那麼內部是如何區分誰依賴誰呢?依賴關係是如何確定的?

(4)什麼時候需要重新 onMeasureChild?什麼時候需要重新onLayoutChild?

(5)每個設置Behavior的子View,一定要是CoordinatorLayout的直接子View嗎?

那麼帶着這些問題,我們通過源碼來得到答案。

2.1 CoordinatorLayout的依賴交互實現

如果要實現依賴交互效果,首先需要兩個角色,分別是:DependentView和子View

```kotlin class DependentView @JvmOverloads constructor( val mContext: Context, val attributeSet: AttributeSet? = null, val flag: Int = 0 ) : View(mContext, attributeSet, flag) {

private var paint: Paint
private var mStartX = 0
private var mStartY = 0

init {
    paint = Paint()
    paint.color = Color.parseColor("#000000")
    paint.style = Paint.Style.FILL
    isClickable = true
}

override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
    canvas?.let {
        it.drawRect(
            Rect().apply {
                left = 200
                top = 200
                right = 400
                bottom = 400
            },
            paint
        )
    }
}

override fun onTouchEvent(event: MotionEvent?): Boolean {

    when (event?.action) {
        MotionEvent.ACTION_DOWN -> {
            Log.e("TAG","ACTION_DOWN")
            mStartX = event.rawX.toInt()
            mStartY = event.rawY.toInt()
        }
        MotionEvent.ACTION_MOVE -> {
            Log.e("TAG","ACTION_MOVE")

            val endX = event.rawX.toInt()
            val endY = event.rawY.toInt()
            val dx = endX - mStartX
            val dy = endY - mStartY

            ViewCompat.offsetTopAndBottom(this, dy)
            ViewCompat.offsetLeftAndRight(this, dx)
            postInvalidate()

            mStartX = endX
            mStartY = endY
        }
    }

    return super.onTouchEvent(event)
}

} ``` 這裏寫了一個很簡單的View,能夠跟隨手指滑動並一起移動,然後我們在當前View下加一個TextView,並讓這個TextView跟着DependentView一起滑動。

```kotlin class DependBehavior @JvmOverloads constructor(context: Context, attributeSet: AttributeSet) : CoordinatorLayout.Behavior(context, attributeSet) {

override fun layoutDependsOn(
    parent: CoordinatorLayout,
    child: View,
    dependency: View
): Boolean {
    return dependency is DependentView
}


override fun onDependentViewChanged(
    parent: CoordinatorLayout,
    child: View,
    dependency: View
): Boolean {

    //獲取dependency的位置
    child.x = dependency.x
    child.y = dependency.bottom.toFloat()

    return true
}

} ``` 如果想要達到隨手的效果,那麼就需要給TextView設置一個Behavior,上面我們定義了一個Behavior,它的主要作用就是,當DependentView滑動的時候,通過CoordinatorLayout來通知所有的DependBehavior修飾的View。

在DependBehavior中,我們看主要有兩個方法:layoutDependsOn和onDependentViewChanged,這兩個方法之前在原理中提到過,layoutDependsOn主要是用來決定依賴關係,看child依賴的是不是DependentView;如果依賴的是DependentView,那麼在DependentView滑動的時候,就會通過回調onDependentViewChanged,告知子View當前dependency的位置信息,從而完成聯動。

2.2 CoordinatorLayout交互依賴的源碼分析

那麼接下來,我們看下CoordinatorLayout是如何實現這個效果的。

在看CoordinatorLayout源碼之前,我們首先需要知道View的生命週期,我們知道在onCreate的時候通過setContentView設置佈局文件,如下所示: ```xml

<com.lay.learn.asm.DependentView
    android:layout_width="200dp"
    android:layout_height="200dp"/>

<TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="我是跟隨者"
    app:layout_behavior="com.lay.learn.asm.behavior.DependBehavior"
    android:textStyle="bold"
    android:textColor="#000000"/>

如果我們熟悉setContentView的源碼,系統是通過Inflate的方式解析佈局文件,然後在onResume的時候顯示佈局,然後隨之會調用onAttachedToWindow將佈局顯示在Window上,我們看下onAttachedToWindow這個方法。java @Override public void onAttachedToWindow() { super.onAttachedToWindow(); resetTouchBehaviors(false); if (mNeedsPreDrawListener) { if (mOnPreDrawListener == null) { mOnPreDrawListener = new OnPreDrawListener(); } final ViewTreeObserver vto = getViewTreeObserver(); vto.addOnPreDrawListener(mOnPreDrawListener); } if (mLastInsets == null && ViewCompat.getFitsSystemWindows(this)) { // We're set to fitSystemWindows but we haven't had any insets yet... // We should request a new dispatch of window insets ViewCompat.requestApplyInsets(this); } mIsAttachedToWindow = true; } 在這個方法中,設置了addOnPreDrawListener監聽,此監聽在頁面發生變化(滑動、旋轉、重新獲取焦點)會產生回調;java class OnPreDrawListener implements ViewTreeObserver.OnPreDrawListener { @Override public boolean onPreDraw() { onChildViewsChanged(EVENT_PRE_DRAW); return true; } } java final void onChildViewsChanged(@DispatchChangeEvent final int type) { final int layoutDirection = ViewCompat.getLayoutDirection(this); final int childCount = mDependencySortedChildren.size(); final Rect inset = acquireTempRect(); final Rect drawRect = acquireTempRect(); final Rect lastDrawRect = acquireTempRect();

for (int i = 0; i < childCount; i++) {
    final View child = mDependencySortedChildren.get(i);
    final LayoutParams lp = (LayoutParams) child.getLayoutParams();
    if (type == EVENT_PRE_DRAW && child.getVisibility() == View.GONE) {
        // Do not try to update GONE child views in pre draw updates.
        continue;
    }

    // Update any behavior-dependent views for the change
    for (int j = i + 1; j < childCount; j++) {
        final View checkChild = mDependencySortedChildren.get(j);
        final LayoutParams checkLp = (LayoutParams) checkChild.getLayoutParams();
        final Behavior b = checkLp.getBehavior();

        if (b != null && b.layoutDependsOn(this, checkChild, child)) {
            if (type == EVENT_PRE_DRAW && checkLp.getChangedAfterNestedScroll()) {
                // If this is from a pre-draw and we have already been changed
                // from a nested scroll, skip the dispatch and reset the flag
                checkLp.resetChangedAfterNestedScroll();
                continue;
            }

            final boolean handled;
            switch (type) {
                case EVENT_VIEW_REMOVED:
                    // EVENT_VIEW_REMOVED means that we need to dispatch
                    // onDependentViewRemoved() instead
                    b.onDependentViewRemoved(this, checkChild, child);
                    handled = true;
                    break;
                default:
                    // Otherwise we dispatch onDependentViewChanged()
                    handled = b.onDependentViewChanged(this, checkChild, child);
                    break;
            }

            if (type == EVENT_NESTED_SCROLL) {
                // If this is from a nested scroll, set the flag so that we may skip
                // any resulting onPreDraw dispatch (if needed)
                checkLp.setChangedAfterNestedScroll(handled);
            }
        }
    }
}

releaseTempRect(inset);
releaseTempRect(drawRect);
releaseTempRect(lastDrawRect);

} ``` 在onChildViewsChanged這個方法中,我們看到有兩個for循環,從mDependencySortedChildren中取出元素,首先我們先不需要關心mDependencySortedChildren這個數組,這個雙循環的目的就是用來判斷View之間是否存在綁定關係

首先我們看下第二個循環,當拿到LayoutParams中的Behavior之後,就會調用Behavior的layoutDependsOn方法,假設此時child為DependentView,checkChild為TextView; ```java for (int j = i + 1; j < childCount; j++) { final View checkChild = mDependencySortedChildren.get(j); final LayoutParams checkLp = (LayoutParams) checkChild.getLayoutParams(); final Behavior b = checkLp.getBehavior();

if (b != null && b.layoutDependsOn(this, checkChild, child)) {
    if (type == EVENT_PRE_DRAW && checkLp.getChangedAfterNestedScroll()) {
        // If this is from a pre-draw and we have already been changed
        // from a nested scroll, skip the dispatch and reset the flag
        checkLp.resetChangedAfterNestedScroll();
        continue;
    }

    final boolean handled;
    switch (type) {
        case EVENT_VIEW_REMOVED:
            // EVENT_VIEW_REMOVED means that we need to dispatch
            // onDependentViewRemoved() instead
            b.onDependentViewRemoved(this, checkChild, child);
            handled = true;
            break;
        default:
            // Otherwise we dispatch onDependentViewChanged()
            handled = b.onDependentViewChanged(this, checkChild, child);
            break;
    }

    if (type == EVENT_NESTED_SCROLL) {
        // If this is from a nested scroll, set the flag so that we may skip
        // any resulting onPreDraw dispatch (if needed)
        checkLp.setChangedAfterNestedScroll(handled);
    }
}

} ``` 從上面的佈局文件中看,TextView的Behavior中,layoutDependsOn返回的就是true,那麼此時可以進入到代碼塊中,這裏會判斷type類型:EVENT_VIEW_REMOVED和其他type,因為此時的type不是REMOVE,所以就會調用BeHavior的onDependentViewChanged方法。

因為在onAttachedToWindow中,對View樹中所有的元素都設置了OnPreDrawListener的監聽,所以只要某個View發生了變化,都會走到onChildViewsChanged方法中,進行相應的Behavior檢查並實現聯動

所以第2節開頭的第一個問題,當DependentView發生位置變化時,是如何通信到child中的,這裏就是通過設置了onPreDrawListener來監聽。

第二個問題,Behavior是如何被初始化的?如果自定義過XML屬性,那麼大概就能瞭解,一般都是在佈局初始化的時候,拿到layout_behavior屬性初始化,我們看下源碼。 java if (mBehaviorResolved) { mBehavior = parseBehavior(context, attrs, a.getString( R.styleable.CoordinatorLayout_Layout_layout_behavior)); }

```java static Behavior parseBehavior(Context context, AttributeSet attrs, String name) { if (TextUtils.isEmpty(name)) { return null; }

final String fullName;
if (name.startsWith(".")) {
    // Relative to the app package. Prepend the app package name.
    fullName = context.getPackageName() + name;
} else if (name.indexOf('.') >= 0) {
    // Fully qualified package name.
    fullName = name;
} else {
    // Assume stock behavior in this package (if we have one)
    fullName = !TextUtils.isEmpty(WIDGET_PACKAGE_NAME)
            ? (WIDGET_PACKAGE_NAME + '.' + name)
            : name;
}

try {
    Map<String, Constructor<Behavior>> constructors = sConstructors.get();
    if (constructors == null) {
        constructors = new HashMap<>();
        sConstructors.set(constructors);
    }
    Constructor<Behavior> c = constructors.get(fullName);
    if (c == null) {
        final Class<Behavior> clazz =
                (Class<Behavior>) Class.forName(fullName, false, context.getClassLoader());
        c = clazz.getConstructor(CONSTRUCTOR_PARAMS);
        c.setAccessible(true);
        constructors.put(fullName, c);
    }
    return c.newInstance(context, attrs);
} catch (Exception e) {
    throw new RuntimeException("Could not inflate Behavior subclass " + fullName, e);
}

} ``` 通過源碼我們可以看到,拿到全類名之後,通過反射的方式來創建Behavior,這裏需要注意一點,在自定義Behavior的時候,需要兩個構造參數CONSTRUCTOR_PARAMS,否則在創建Behavior的時候會報錯,因為在反射創建Behavior的時候需要獲取這兩個構造參數。

java static final Class<?>[] CONSTRUCTOR_PARAMS = new Class<?>[] { Context.class, AttributeSet.class }; 報錯類型就是:

java Could not inflate Behavior subclass com.lay.learn.asm.behavior.DependBehavior

2.3 CoordinatorLayout子控件攔截事件源碼分析

其實只要瞭解了其中一個功能的原理之後,其他功能都是類似的。對於CoordinatorLayout中的子View攔截事件,我們可以先看看CoordinatorLayout中的onInterceptTouchEvent方法。 ```java @Override public boolean onInterceptTouchEvent(MotionEvent ev) { final int action = ev.getActionMasked();

// Make sure we reset in case we had missed a previous important event.
if (action == MotionEvent.ACTION_DOWN) {
    resetTouchBehaviors(true);
}

final boolean intercepted = performIntercept(ev, TYPE_ON_INTERCEPT);

if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
    resetTouchBehaviors(true);
}

return intercepted;

} 其中有一個核心方法performIntercept方法,這個方法中我們可以看到,同樣也是拿到了Behavior的onInterceptTouchEvent方法,來優先判斷子View是否需要攔截這個事件,如果不攔截,那麼交給父容器消費,當前一般Behavior中也不會攔截。java private boolean performIntercept(MotionEvent ev, final int type) { boolean intercepted = false; boolean newBlock = false;

MotionEvent cancelEvent = null;

final int action = ev.getActionMasked();

final List<View> topmostChildList = mTempList1;
getTopSortedChildren(topmostChildList);

// Let topmost child views inspect first
final int childCount = topmostChildList.size();
for (int i = 0; i < childCount; i++) {
    final View child = topmostChildList.get(i);
    final LayoutParams lp = (LayoutParams) child.getLayoutParams();
    final Behavior b = lp.getBehavior();

    if ((intercepted || newBlock) && action != MotionEvent.ACTION_DOWN) {
        // Cancel all behaviors beneath the one that intercepted.
        // If the event is "down" then we don't have anything to cancel yet.
        if (b != null) {
            if (cancelEvent == null) {
                final long now = SystemClock.uptimeMillis();
                cancelEvent = MotionEvent.obtain(now, now,
                        MotionEvent.ACTION_CANCEL, 0.0f, 0.0f, 0);
            }
            switch (type) {
                case TYPE_ON_INTERCEPT:
                    b.onInterceptTouchEvent(this, child, cancelEvent);
                    break;
                case TYPE_ON_TOUCH:
                    b.onTouchEvent(this, child, cancelEvent);
                    break;
            }
        }
        continue;
    }

    if (!intercepted && b != null) {
        switch (type) {
            case TYPE_ON_INTERCEPT:
                intercepted = b.onInterceptTouchEvent(this, child, ev);
                break;
            case TYPE_ON_TOUCH:
                intercepted = b.onTouchEvent(this, child, ev);
                break;
        }
        if (intercepted) {
            mBehaviorTouchView = child;
        }
    }

    // Don't keep going if we're not allowing interaction below this.
    // Setting newBlock will make sure we cancel the rest of the behaviors.
    final boolean wasBlocking = lp.didBlockInteraction();
    final boolean isBlocking = lp.isBlockingInteractionBelow(this, child);
    newBlock = isBlocking && !wasBlocking;
    if (isBlocking && !newBlock) {
        // Stop here since we don't have anything more to cancel - we already did
        // when the behavior first started blocking things below this point.
        break;
    }
}

topmostChildList.clear();

return intercepted;

} ```

2.4 CoordinatorLayout嵌套滑動原理分析

對於嵌套滑動,其實在上一篇文章中已經介紹的很清楚了,加上CoordinatorLayout自身的特性,我們知道當子View(指的是實現了nestscrollchild接口的View)嵌套滑動的時候,那麼首先會將事件向上分發到CoordinatorLayout中,所以在parent中的onNestedPreScroll的方法中會拿到回調。 ```java public void onNestedPreScroll(View target, int dx, int dy, int[] consumed, int type) { int xConsumed = 0; int yConsumed = 0; boolean accepted = false;

final int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
    final View view = getChildAt(i);
    if (view.getVisibility() == GONE) {
        // If the child is GONE, skip...
        continue;
    }

    final LayoutParams lp = (LayoutParams) view.getLayoutParams();
    if (!lp.isNestedScrollAccepted(type)) {
        continue;
    }

    final Behavior viewBehavior = lp.getBehavior();
    if (viewBehavior != null) {
        mBehaviorConsumed[0] = 0;
        mBehaviorConsumed[1] = 0;
        viewBehavior.onNestedPreScroll(this, view, target, dx, dy, mBehaviorConsumed, type);

        xConsumed = dx > 0 ? Math.max(xConsumed, mBehaviorConsumed[0])
                : Math.min(xConsumed, mBehaviorConsumed[0]);
        yConsumed = dy > 0 ? Math.max(yConsumed, mBehaviorConsumed[1])
                : Math.min(yConsumed, mBehaviorConsumed[1]);

        accepted = true;
    }
}

consumed[0] = xConsumed;
consumed[1] = yConsumed;

if (accepted) {
    onChildViewsChanged(EVENT_NESTED_SCROLL);
}

} ``` 我們詳細看下這個方法,對於parent的onNestedPreScroll方法,當然也是會獲取到Behavior,這裏也是拿到了子View的Behavior之後,調用其onNestedPreScroll方法,會把手指滑動的距離傳遞到子View的Behavior中

```xml

<TextView
    android:layout_width="match_parent"
    android:layout_height="80dp"
    android:background="#2196F3"
    android:text="這是頂部TextView"
    android:gravity="center"
    android:textColor="#FFFFFF"/>
<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/rv_child"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginTop="80dp"/>

```

所以這裏我們先定義一個Behavior,這個Behavior是用來接收滑動事件分發的。當手指向上滑動的時候,首先將TextView隱藏,然後才能滑動RecyclerView。

```kotlin class ScrollBehavior @JvmOverloads constructor( val mContext: Context, val attributeSet: AttributeSet ) : CoordinatorLayout.Behavior(mContext, attributeSet) {

//相對於y軸滑動的距離
private var mScrollY = 0

//總共滑動的距離
private var totalScroll = 0


override fun onLayoutChild(
    parent: CoordinatorLayout,
    child: TextView,
    layoutDirection: Int
): Boolean {
    Log.e("TAG", "onLayoutChild----")
    //實時測量
    parent.onLayoutChild(child, layoutDirection)
    return true
}

override fun onStartNestedScroll(
    coordinatorLayout: CoordinatorLayout,
    child: TextView,
    directTargetChild: View,
    target: View,
    axes: Int,
    type: Int
): Boolean {
    //目的為了dispatch成功
    return true
}

override fun onNestedPreScroll(
    coordinatorLayout: CoordinatorLayout,
    child: TextView,
    target: View,
    dx: Int,
    dy: Int,
    consumed: IntArray,
    type: Int
) {
    //邊界處理
    var cosumedy = dy
    Log.e("TAG","onNestedPreScroll $totalScroll dy $dy")
    var scroll = totalScroll + dy
    if (abs(scroll) > getMaxScroll(child)) {
        cosumedy = getMaxScroll(child) - abs(totalScroll)
    } else if (scroll < 0) {
        cosumedy = 0
    }
    //在這裏進行事件消費,我們只需要關心豎向滑動
    ViewCompat.offsetTopAndBottom(child, -cosumedy)
    //重新賦值
    totalScroll += cosumedy
    consumed[1] = cosumedy
}

private fun getMaxScroll(child: TextView): Int {
    return child.height
}

} 對應的佈局文件,區別在於TextView設置了ScrollBehavior。xml

<TextView
    android:layout_width="match_parent"
    android:layout_height="80dp"
    android:background="#2196F3"
    android:text="這是頂部TextView"
    android:gravity="center"
    android:textColor="#FFFFFF"
    app:layout_behavior=".behavior.ScrollBehavior"/>
<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/rv_child"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginTop="80dp"/>

``` 當滾動RecyclerView的時候,因為RecyclerView屬於nestscrollchild,所以事件先被傳遞到了CoordinatorLayout中,然後通過分發調用了TextView中的Behavior中的onNestedPreScroll,在這個方法中,我們是進行了TextView的上下滑動(邊界處理我這邊就不説了,其實還蠻簡單的),看下效果。

43c8cf1c-0388-47a7-a2cf-754ef91da793.gif

我們發現有個問題,就是在TextView上滑離開的之後,RecyclerView上方有一處空白,這個就是因為在TextView滑動的時候,RecyclerView沒有跟隨TextView一起滑動。

這個不就是我們在2.1中提到的這個效果嗎,所以RecyclerView是需要依賴TextView的,我們需要再次自定義一個Behavior,完成這種聯動效果。

```kotlin class RecyclerViewBehavior @JvmOverloads constructor( val context: Context, val attributeSet: AttributeSet ) : CoordinatorLayout.Behavior(context, attributeSet) {

override fun layoutDependsOn(
    parent: CoordinatorLayout,
    child: RecyclerView,
    dependency: View
): Boolean {
    return dependency is TextView
}

override fun onDependentViewChanged(
    parent: CoordinatorLayout,
    child: RecyclerView,
    dependency: View
): Boolean {

    Log.e("TAG","onDependentViewChanged ${dependency.bottom} ${child.top}")
    ViewCompat.offsetTopAndBottom(child,(dependency.bottom - child.top))
    return true
}

} 對應的佈局文件,區別在於RecyclerView設置了RecyclerViewBehavior。xml

<TextView
    android:layout_width="match_parent"
    android:layout_height="80dp"
    android:background="#2196F3"
    android:text="這是頂部TextView"
    android:gravity="center"
    android:textColor="#FFFFFF"
    app:layout_behavior=".behavior.ScrollBehavior"/>
<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/rv_child"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginTop="80dp"
    app:layout_behavior=".behavior.RecyclerViewBehavior"/>

``` 這裏我設置了RecyclerView依賴於TextView,當TextView的位置發生變化的時候,就會通知RecyclerView的Behavior中的onDependentViewChanged方法,在這個方法中可以設置RecyclerView豎直方向上的偏移量。

image.png

具體的偏移量計算,可以根據上圖自行推理,因為TextView移動的時候,會跟RecyclerView產生一塊位移,RecyclerView需要補上這塊,在onDependentViewChanged方法中。

6ae44051-6780-45bf-b49a-0115e260be28.gif

這時候我們會發現,即便最外層沒有使用可滑動的佈局,依然能夠完成吸頂的效果,這就顯示了CoordinatorLayout的強大之處,當然除了移動之外,控制View的顯示與隱藏、動畫效果等等都可以完成,只要熟悉了CoordinatorLayout內部的原理,就不怕UI跟設計老師的任意需求了。