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 效果的糅合,毫無疑問應該從觸摸事件上入手。簡單起見暫時只考慮手指向上滑動(列表向下展示更多內容),大概需要進行以下操作:
- 在父佈局
onInterceptTouchEvent
中攔截事件。 - 父佈局
onTouchEvent
處理事件,對 HeaderView 進行操作(移動、改變透明度等)。 - 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()
// ...
}
到這就完美啦!恭喜🎉
粉絲技術交流裙: