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

CoordinatorLayout 的此生前世

聯動效果

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

這種搭配的教程已經很是多了,不是本文的重點。在使用 xml 時候確定很多同窗掉過一個坑:界面主要內容與頭部元素重疊了!粗略瞭解一下由於 CoordinatorLayout 的佈局方式相似 FrameLayout 默認狀況下全部元素都會疊加在一塊兒,解決方案也很是玄學,就是給內容元素添加一個 app:layout_behavior="@string/appbar_scrolling_view_behavior" 屬性就行了,簡直像黑魔法!android

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

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

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

事件攔截實現

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

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

如今已經遇到問題了。由於一開始父佈局攔截了事件,所以根據 Android 事件分發機制,哪怕後續再也不攔截其子控件也沒法收到事件,除非從新觸摸,這就形成了二者的滑動不能無縫銜接。佈局

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

哪怕解決了上述主要問題,確定還有其餘小毛病,例如子控件沒法觸發點擊事件等等等很是惱人💢。假設你是大佬完美解決了全部問題,確定耦合特別嚴重,又是自定義 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()
        // ...
    }
複製代碼

到這就完美啦!恭喜🎉

參考

相關文章
相關標籤/搜索