一起動纔夠嗨!Android CoordinatorLayout 自定義 Behavior

CoordinatorLayout 的今生前世

聯動效果

現代化的 Android 開發一定對 CoordinatorLayout 不陌生,CoordinatorLayout + AppBarLayout + CollapsingToolbarLayout + Toolbar 的全家桶更是信手拈來,無需一行代碼光靠 xml 就能實現下面這種摺疊導航欄的炫酷效果:

 

                                                  

這種搭配的教程已經非常多了,不是本文的重點。在使用 xml 時候肯定不少同學掉過一個坑:界面主要內容與頭部元素重疊了!粗略瞭解一下因爲 CoordinatorLayout 的佈局方式類似 FrameLayout 默認情況下所有元素都會疊加在一起,解決方案也非常玄學,就是給內容元素添加一個 app:layout_behavior="@string/appbar_scrolling_view_behavior" 屬性就好了,簡直像黑魔法!

Unfortunately,代碼並沒有魔法,我們能偷懶是因爲有人封裝好了。跟蹤進這個字符串是 com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior 顯然這是個類!事實上這就是今天的重頭戲 —— Behavior.

這個效果太複雜了,所以 Google 纔會幫我們包裝好,下面換一個簡單的例子便於學習:

                                    

 

 

這是仿三星 One UI 的界面。上面是一個頭佈局,下面是一個 RecyclerView,向上滑動時首先頭佈局收縮漸隱並有個視差效果,頭部徹底隱藏後 RecyclerView 無縫銜接。向下滑動時同理。

事件攔截實現

在繼續探索之前,先思考一下如果沒有 CoordinatorLayout 這種現代化東西怎麼辦?因爲這牽扯到滑動手勢與 View 效果的糅合,毫無疑問應該從觸摸事件上入手。簡單起見暫時只考慮手指向上滑動(列表向下展示更多內容),大概需要進行以下操作:

  1. 在父佈局 onInterceptTouchEvent 中攔截事件。
  2. 父佈局 onTouchEvent 處理事件,對 HeaderView 進行操作(移動、改變透明度等)。
  3. HeaderView 完全摺疊後父佈局不再攔截事件,RecyclerView 正常處理滑動。

現在已經遇到問題了。因爲一開始父佈局攔截了事件,因此根據 Android 事件分發機制,哪怕後續不再攔截其子控件也無法收到事件,除非重新觸摸,這就造成了兩者的滑動不能無縫銜接。

接着還有一個問題,反過來當 RecyclerView 向下滑動至頂部時,如何通知 HeaderView 展開?

哪怕解決了上述主要問題,肯定還有其他小毛病,例如子控件無法觸發點擊事件等等等非常惱人💢。假設你是大佬完美解決了所有問題,肯定耦合特別嚴重,又是自定義 View 又是互相引用的亂七八糟😵 所以現在就不往下深究了,有閒情雅緻有能力的同學可以嘗試實現。

NestingScroll

從 Android 5.0 (API21) 開始 Google 給出了官方解決方案 - NestingScroll,這是一個嵌套滑動機制,用於協調父/子控件對滑動事件的處理。他的基本思想就是,事件直接傳到子控件,由子控件詢問父控件是否需要滑動,父控件處理後給出已消耗的距離,子控件繼續處理未消耗的距離。當子控件也滑到頂(底)時將剩餘距離交給父控件處理。讓我來生動地解釋一下:

子:開始滑動嘍,準備滑300px,爸爸你要不要先滑?
父:好嘞,我先滑100px到頂了,你繼續。
子:收到,我接着滑160px到底了,爸爸剩下的交給你了。
父:好的還有40px,我繼續滑(也可以不滑忽略此回調)

就這樣,父控件沒有攔截事件,而是子控件收到事件後主動詢問,在他們的協調配合之下完成了無縫滑動銜接。爲了實現這點,Google 準備了兩個接口:NestedScrollingParent, NestedScrollingChild.

NestedScrollingParent 主要方法如下:

  • onStartNestedScroll : Boolean - 是否需要消費這次滑動事件。(爸爸你要不要先滑?)
  • onNestedScrollAccepted - 確認消費滑動回調,可以執行初始化工作。(好嘞我先滑)
  • onNestedPreScroll - 在子控件處理滑動事件之前回調。(我先滑了100px)
  • onNestedScroll - 子控件滑動之後的回調,可以繼續執行剩餘距離。(還有40px我繼續滑)
  • onStopNestedScroll - 事件結束,可以做一些收尾工作。

類似的還有 Fling 相關接口。

NestedScrollingChild 主要方法如下:

  • startNestedScroll - 開始滑動。
  • dispatchNestedPreScroll - 在自己滑動之前詢問父組件。
  • dispatchNestedScroll - 在自己滑動之後把剩餘距離通知父組件。
  • stopNestedScroll - 結束滑動。

以及 Fling 相關接口和其他一些東西。

最終執行順序如下(父控件接受事件、用戶觸發了拋擲):子startNestedScroll → 父onStartNestedScroll → 父onNestedScrollAccepted ||→ 子dispatchNestedPreScroll → 父onNestedPreScroll ||→ 子dispatchNestedScroll → 父onNestedScroll ||→ 子dispatchNestedPreFling → 父onNestedPreFling ||→ 子dispatchNestedFling → 父onNestedFling ||→ 子stopNestedScroll → 父onStopNestedScroll

RecyclerView 已經默認實現了 Child 接口,現在只要給外層佈局實現 Parent 接口並作出正確反應,應該就可以達到目的了,最麻煩的事件轉發已經在 RecyclerView 內部實現。但是... 還是需要自己定義個外部 Layout?似乎依然有點麻煩並且解耦不徹底。

噹噹噹!Behavior 登場!

CoordinatorLayout 名副其實,它是一個可以協調各個子 View 的佈局。注意區別 NestedScrolling 機制,後者只能調度父子兩者的滑動,而前者可以協調所有子 View 的所有動作。有了這個神器後我們不再需要自定義 Layout 來實現嵌套滑動接口了,並且可以實現更復雜的效果。CoordinatorLayout 只能提供一個平臺,具體效果的實現需要依賴 Behavior. CoordinatorLayout 的所有直接子控件都可以設置 Behavior,其定義了這個 View 應當對觸摸事件做何反應,或者對其他 View 的變化做何反應,成功地將具體實現從 View 中抽離出來。

CoordinatorLayout 類似於網遊的中央服務器。對於嵌套滑動來說,它實現了 NestedScrollingParent 接口因此可以接受到子 View 的滑動信息,並且分發給所有子 View 的 Behavior 並將它們的響應彙總起來返回給滑動 View。對於依賴其他 View 的功能,當有 View 屬性發生改變時它會通知所有聲明瞭監聽的子 View 的 Behavior.

注意:無論嵌套多少級的滑動事件都可以被轉發。但是隻有直接子 View 可以設置 Behavior (響應事件)或作爲被監聽的對象。


 

除此之外,Behavior 還有 onInterceptTouchEvent, onTouchEvent 方法,重點是它接收到的不僅僅是自己範圍內的事件。也就是說現在子 View 可以直接攔截父佈局的事件了。利用這一點我們可以輕鬆做出拖拽移動,其他 View 跟隨的效果,比如這樣

                                                

Behavior 像是一個集大成者,它能夠進行事件處理、嵌套滑動協調、子控件變化監聽,甚至還能直接修改佈局(onMeasureChild, onLayoutChild 這裏面的 Child 指的就是 Behavior 所對應的子控件)這有什麼用呢?通過一開始的例子來看看吧。

實戰:仿三星 One UI

再貼一遍效果圖:

 

                                               

先看看佈局:

<?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"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <LinearLayout
        android:id="@+id/imagesTitleBlockLayout"
        android:layout_width="match_parent"
        android:layout_height="@dimen/title_block_height"
        android:gravity="center"
        android:orientation="vertical"
        app:layout_behavior=".ui.images.NestedHeaderScrollBehavior">

        <TextView
            style="@style/text_view_primary"
            android:text="@string/nav_menu_images"
            android:textSize="40sp" />

        <TextView
            android:id="@+id/imagesSubtitleTextView"
            style="@style/text_view_secondary"
            android:textSize="18sp"
            tools:text="183 images" />
    </LinearLayout>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/imagesRecyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior=".ui.images.NestedContentScrollBehavior"
        tools:listitem="@layout/rv_item_images_img" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

一般來說爲了簡單,我們會選定1個 View 用於響應嵌套滑動,其他 View 監聽此 View來同步改變。HeaderView 的效果比較複雜我不希望它承擔太多工作,因此這裏讓 RecyclerView 自己處理嵌套滑動問題。

這裏一個重要原因是 HeaderView 有了視差效果。否則的話讓 HeaderView 響應滑動,RecyclerView 只需要緊貼着 HeaderView 移動就行了,更簡單。

處理嵌套滑動

現在開始編寫 RecyclerView 所需的 Behavior. 第一個要解決的問題就是重疊,這就需要剛剛提到的干預佈局。核心思想是一開始獲取 HeaderView 的高度,作爲 RecyclerView 的 Top 屬性,就可以實現類似 LinearLayout 的佈局了。

注意:①爲了能夠在 xml 中直接設置 Behavior 我們得寫一個帶有 attrs 參數的構造函數。② <View> 表示 Behavior 所設置到的 View 類型,因爲這裏不需要用到 RecyclerView 的特有 API 所以直接寫 View 了。

class NestedContentScrollBehavior(context: Context?, attrs: AttributeSet?) :
        CoordinatorLayout.Behavior<View>(context, attrs) {
    private var headerHeight = 0

    override fun onLayoutChild(parent: CoordinatorLayout, child: View, layoutDirection: Int): Boolean {
        // 首先讓父佈局按照標準方式解析
        parent.onLayoutChild(child, layoutDirection)
        // 獲取到 HeaderView 的高度
        headerHeight = parent.findViewById<View>(R.id.imagesTitleBlockLayout).height
        // 設置 top 從而排在 HeaderView的下面
        ViewCompat.offsetTopAndBottom(child, headerHeight)
        return true // true 表示我們自己完成了解析 不要再自動解析了
    }
}

正式開始嵌套滑動的處理,先處理手指向上滑動的情況。因爲只有在 HeaderView 摺疊後才允許 RecyclerView 滑動,因此要寫在 onNestedPreScroll 方法裏。對這些滑動回調不清楚的看看上面第二節 NestingScroll 相關部分。

 

    override fun onStartNestedScroll(coordinatorLayout: CoordinatorLayout, child: View, directTargetChild: View,
                                     target: View, axes: Int, type: Int): Boolean {
        // 如果是垂直滑動的話就聲明需要處理
        // 只有這裏返回 true 纔會收到下面一系列滑動事件的回調
        return (axes and ViewCompat.SCROLL_AXIS_VERTICAL) != 0
    }

    override fun onNestedPreScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, dx: Int, dy: Int,
                                   consumed: IntArray, type: Int) {
        // 此時 RecyclerView 還沒開始滑動
        super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
        if (dy > 0) { // 只處理手指上滑
            val newTransY = child.translationY - dy
            if (newTransY >= -headerHeight) {
                // 完全消耗滑動距離後沒有完全貼頂或剛好貼頂
                // 那麼就聲明消耗所有滑動距離,並上移 RecyclerView
                consumed[1] = dy // consumed[0/1] 分別用於聲明消耗了x/y方向多少滑動距離
                child.translationY = newTransY
            } else {
                // 如果完全消耗那麼會導致 RecyclerView 超出可視區域
                // 那麼只消耗恰好讓 RecyclerView 貼頂的距離
                consumed[1] = headerHeight + child.translationY.toInt()
                child.translationY = -headerHeight.toFloat()
            }
        }
    }

並不複雜,核心思想是判斷 RecyclerView 在移動用戶請求的距離後,會不會超出窗口區域。如果不超出那麼就全部消耗,RV 自己不再滑動。如果超出那麼就只消耗不超出的那一部分,剩餘距離由 RV 內部滑動。

接着寫手指向下滑動的部分。因爲這時候需要優先讓 RecyclerView 滑動,在它滑動到頂的時候才需要整體下移讓 HeaderView 顯示出來,所以要在 onNestedScroll 裏寫。

    override fun onNestedScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, dxConsumed: Int,
                                dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int, type: Int, consumed: IntArray) {
        // 此時 RV 已經完成了滑動,dyUnconsumed 表示剩餘未消耗的滑動距離
        super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
                type, consumed)
        if (dyUnconsumed < 0) { // 只處理手指向下滑動的情況
            val newTransY = child.translationY - dyUnconsumed
            if (newTransY <= 0) {
                child.= newTransY
            } else {
                child.translationY = 0f
            }
        }
    }

比上一個簡單一些。如果滑動後 RV 的偏移小於0(Y偏移<0代表向上移動)那麼就表示還沒有完全歸位,那麼消耗全部剩餘距離。否則直接讓 RV 歸位就行了。

offsetTopAndBottom 與 translationY 的關係

從用途出發,offsetTopAndBottom 常用於永久性修改,translationY 常用於臨時性修改(例如動畫)這裏我們也遵循了這個約定

從效果出發,offsetTopAndBottom(offset) 是累加的,其內部相當於 mTop+=offset,而 translationY 每次都是重新設置與已有值無關。

最關鍵是,onLayoutChild 有可能被多次觸發,因此動畫所使用的方法必須與調整佈局所使用的方法不同。否則有可能出現滑動執行到一半結果觸發了重新佈局,結果自動歸位,視覺上就是胡亂跳動。

處理 HeaderView

接下來開始寫 HeaderView 的 Behavior 它的主要任務是監聽 RecyclerView 的變化來改變 HeaderView 的屬性。

class NestedHeaderScrollBehavior constructor(context: Context?, attrs: AttributeSet?) :
        CoordinatorLayout.Behavior<View>(context, attrs) {

    override fun layoutDependsOn(parent: CoordinatorLayout, child: View, dependency: View): Boolean {
        // child: 當前 Behavior 所關聯的 View,此處是 HeaderView
        // dependency: 待判斷是否需要監聽的其他子 View
        return dependency.id == R.id.imagesRecyclerView
    }

    override fun onDependentViewChanged(parent: CoordinatorLayout, child: View, dependency: View): Boolean {
        child.translationY = dependency.translationY * 0.5f
        child.alpha = 1 + dependency.translationY / (child.height * 0.6f)
        // 如果改變了 child 的大小位置必須返回 true 來刷新
        return true
    }
}

這一個簡單多了。layoutDependsOn 會對每一個子 View 觸發一遍,通過某種方法判斷是不是要監聽的 View,只有這裏返回了 true 才能收到對應 View 的後續回調。我們在 onDependentViewChanged 中根據 RecyclerView 的偏移量來計算 HeaderView 的偏於與透明度,通過乘以一個係數來實現視差移動。

到此爲止已經基本上實現了上述效果。

Surprise! 自動歸位

如果用戶拖動到一半擡起了手指,讓 UI 停留在半摺疊狀態是不合適的,應當根據具體位置自動完全摺疊或完全展開。

                                      

 

 

實現思路不難,監聽停止滑動事件,判斷當前 RecyclerView 的偏移量,若超過一半就完全摺疊否則就完全展開。這裏需要藉助 Scroller 實現動畫。

Scroller 本質上是個計算器,你只需告訴它起始值、變化量、持續時間,就可以幫你算出任意時刻應該處於的位置,還可以定製不同緩動效果。通過高頻率不斷地計算不斷地刷新不斷地移動從而實現平滑動畫。

OverScroller 包含了 Scroller 的全部功能並增加了額外功能,因此現在 Scroller 現在已被標註爲棄用。

我們來修改一下 RV 對應的 NestedContentScrollBehavior.

    private lateinit var contentView: View // 其實就是 RecyclerView
    private var scroller: OverScroller? = null
    private val scrollRunnable = object : Runnable {
        override fun run() {
            scroller?.let { scroller ->
                if (scroller.computeScrollOffset()) {
                    contentView.translationY = scroller.currY.toFloat()
                    ViewCompat.postOnAnimation(contentView, this)
                }
            }
        }
    }

    override fun onLayoutChild(parent: CoordinatorLayout, child: View, layoutDirection: Int): Boolean {
        contentView = child
        // ...
    }

    private fun startAutoScroll(current: Int, target: Int, duration: Int) {
        if (scroller == null) {
            scroller = OverScroller(contentView.context)
        }
        if (scroller!!.isFinished) {
            contentView.removeCallbacks(scrollRunnable)
            scroller!!.startScroll(0, current, 0, target - current, duration)
            ViewCompat.postOnAnimation(contentView, scrollRunnable)
        }
    }

    private fun stopAutoScroll() {
        scroller?.let {
            if (!it.isFinished) {
                it.abortAnimation()
                contentView.removeCallbacks(scrollRunnable)
            }
        }
    }

首先定義三個變量並在合適的時候賦值。解釋一下 scrollRunnable,在得到不同時間應該處於的不同位置後該怎麼刷新 View 呢?因爲滑動事件已經停止,我們得不到任何回調。王進喜說 沒有條件就創造條件,這裏通過 ViewCompat.postOnAnimation 讓 View 在下一次繪製時執行定義好的 Runnable,在 Runnable 內部改變 View 位置,如果動畫還沒結束那麼就再提交一個 Runnable,於是實現了連續不斷的刷新。再寫兩個輔助函數便於開始和停止動畫。

下面監聽一下停止滑動的回調,根據情況來啓動動畫:

    override fun onStopNestedScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, type: Int) {
        super.onStopNestedScroll(coordinatorLayout, child, target, type)
        if (child.translationY >= 0f || child.translationY <= -headerHeight) {
            // RV 已經歸位(完全摺疊或完全展開)
            return
        }
        if (child.translationY <= -headerHeight * 0.5f) {
            stopAutoScroll()
            startAutoScroll(child.translationY.toInt(), -headerHeight, 1000)
        } else {
            stopAutoScroll()
            startAutoScroll(child.translationY.toInt(), 0, 600)
        }
    }

最後完善一下,開始滑動時要停止動畫,以免動畫還沒結束用戶就迫不及待地又滑了一次:

    override fun onNestedPreScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, dx: Int, dy: Int,
                                   consumed: IntArray, type: Int) {
        super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
        stopAutoScroll()
        // ...
    }

    override fun onNestedScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, dxConsumed: Int,
                                dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int, type: Int, consumed: IntArray) {
        super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
                type, consumed)
        stopAutoScroll()
        // ...
    }

到這就完美啦!恭喜🎉

粉絲技術交流裙:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章