聯(lián)動(dòng)效果
現(xiàn)代化的 Android 開發(fā)一定對(duì) CoordinatorLayout 不陌生,CoordinatorLayout + AppBarLayout + CollapsingToolbarLayout + Toolbar 的全家桶更是信手拈來,無需一行代碼光靠 xml 就能實(shí)現(xiàn)下面這種折疊導(dǎo)航欄的炫酷效果:
著作權(quán)歸作者所有。商業(yè)轉(zhuǎn)載請(qǐng)聯(lián)系作者獲得授權(quán),非商業(yè)轉(zhuǎn)載請(qǐng)注明出處。
這種搭配的教程已經(jīng)非常多了,不是本文的重點(diǎn)。在使用 xml 時(shí)候肯定不少同學(xué)掉過一個(gè)坑:界面主要內(nèi)容與頭部元素重疊了!粗略了解一下因?yàn)?CoordinatorLayout 的布局方式類似 FrameLayout 默認(rèn)情況下所有元素都會(huì)疊加在一起,解決方案也非常玄學(xué),就是給內(nèi)容元素添加一個(gè) app:layout_behavior="@string/appbar_scrolling_view_behavior" 屬性就好了,簡(jiǎn)直像黑魔法!
Unfortunately,代碼并沒有魔法,我們能偷懶是因?yàn)橛腥朔庋b好了。跟蹤進(jìn)這個(gè)字符串是 com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior 顯然這是個(gè)類!事實(shí)上這就是今天的重頭戲 —— Behavior.
這個(gè)效果太復(fù)雜了,所以 Google 才會(huì)幫我們包裝好,下面換一個(gè)簡(jiǎn)單的例子便于學(xué)習(xí):
這是仿三星 One UI 的界面。上面是一個(gè)頭布局,下面是一個(gè) RecyclerView,向上滑動(dòng)時(shí)首先頭布局收縮漸隱并有個(gè)視差效果,頭部徹底隱藏后 RecyclerView 無縫銜接。向下滑動(dòng)時(shí)同理。
事件攔截實(shí)現(xiàn)
在繼續(xù)探索之前,先思考一下如果沒有 CoordinatorLayout 這種現(xiàn)代化東西怎么辦?因?yàn)檫@牽扯到滑動(dòng)手勢(shì)與 View 效果的糅合,毫無疑問應(yīng)該從觸摸事件上入手。簡(jiǎn)單起見暫時(shí)只考慮手指向上滑動(dòng)(列表向下展示更多內(nèi)容),大概需要進(jìn)行以下操作:
- 在父布局
onInterceptTouchEvent中攔截事件。 - 父布局
onTouchEvent處理事件,對(duì) HeaderView 進(jìn)行操作(移動(dòng)、改變透明度等)。 - HeaderView 完全折疊后父布局不再攔截事件,RecyclerView 正常處理滑動(dòng)。
現(xiàn)在已經(jīng)遇到問題了。因?yàn)橐婚_始父布局?jǐn)r截了事件,因此根據(jù) Android 事件分發(fā)機(jī)制,哪怕后續(xù)不再攔截其子控件也無法收到事件,除非重新觸摸,這就造成了兩者的滑動(dòng)不能無縫銜接。
接著還有一個(gè)問題,反過來當(dāng) RecyclerView 向下滑動(dòng)至頂部時(shí),如何通知 HeaderView 展開?
哪怕解決了上述主要問題,肯定還有其他小毛病,例如子控件無法觸發(fā)點(diǎn)擊事件等等等非常惱人??。假設(shè)你是大佬完美解決了所有問題,肯定耦合特別嚴(yán)重,又是自定義 View 又是互相引用的亂七八糟?? 所以現(xiàn)在就不往下深究了,有閑情雅致有能力的同學(xué)可以嘗試實(shí)現(xiàn)。
NestingScroll
從 Android 5.0 (API21) 開始 Google 給出了官方解決方案 - NestingScroll,這是一個(gè)嵌套滑動(dòng)機(jī)制,用于協(xié)調(diào)父/子控件對(duì)滑動(dòng)事件的處理。他的基本思想就是,事件直接傳到子控件,由子控件詢問父控件是否需要滑動(dòng),父控件處理后給出已消耗的距離,子控件繼續(xù)處理未消耗的距離。當(dāng)子控件也滑到頂(底)時(shí)將剩余距離交給父控件處理。讓我來生動(dòng)地解釋一下:
子:開始滑動(dòng)嘍,準(zhǔn)備滑300px,爸爸你要不要先滑?
父:好嘞,我先滑100px到頂了,你繼續(xù)。
子:收到,我接著滑160px到底了,爸爸剩下的交給你了。
父:好的還有40px,我繼續(xù)滑(也可以不滑忽略此回調(diào))
就這樣,父控件沒有攔截事件,而是子控件收到事件后主動(dòng)詢問,在他們的協(xié)調(diào)配合之下完成了無縫滑動(dòng)銜接。為了實(shí)現(xiàn)這點(diǎn),Google 準(zhǔn)備了兩個(gè)接口:NestedScrollingParent, NestedScrollingChild.
NestedScrollingParent 主要方法如下:
-
onStartNestedScroll : Boolean- 是否需要消費(fèi)這次滑動(dòng)事件。(爸爸你要不要先滑?) -
onNestedScrollAccepted- 確認(rèn)消費(fèi)滑動(dòng)回調(diào),可以執(zhí)行初始化工作。(好嘞我先滑) -
onNestedPreScroll- 在子控件處理滑動(dòng)事件之前回調(diào)。(我先滑了100px) -
onNestedScroll- 子控件滑動(dòng)之后的回調(diào),可以繼續(xù)執(zhí)行剩余距離。(還有40px我繼續(xù)滑) -
onStopNestedScroll- 事件結(jié)束,可以做一些收尾工作。
類似的還有 Fling 相關(guān)接口。
NestedScrollingChild 主要方法如下:
-
startNestedScroll- 開始滑動(dòng)。 -
dispatchNestedPreScroll- 在自己滑動(dòng)之前詢問父組件。 -
dispatchNestedScroll- 在自己滑動(dòng)之后把剩余距離通知父組件。 -
stopNestedScroll- 結(jié)束滑動(dòng)。
以及 Fling 相關(guān)接口和其他一些東西。
最終執(zhí)行順序如下(父控件接受事件、用戶觸發(fā)了拋擲):子startNestedScroll → 父onStartNestedScroll → 父onNestedScrollAccepted ||→ 子dispatchNestedPreScroll → 父onNestedPreScroll ||→ 子dispatchNestedScroll → 父onNestedScroll ||→ 子dispatchNestedPreFling → 父onNestedPreFling ||→ 子dispatchNestedFling → 父onNestedFling ||→ 子stopNestedScroll → 父onStopNestedScroll
RecyclerView 已經(jīng)默認(rèn)實(shí)現(xiàn)了 Child 接口,現(xiàn)在只要給外層布局實(shí)現(xiàn) Parent 接口并作出正確反應(yīng),應(yīng)該就可以達(dá)到目的了,最麻煩的事件轉(zhuǎn)發(fā)已經(jīng)在 RecyclerView 內(nèi)部實(shí)現(xiàn)。但是... 還是需要自己定義個(gè)外部 Layout?似乎依然有點(diǎn)麻煩并且解耦不徹底。
當(dāng)當(dāng)當(dāng)!Behavior 登場(chǎng)!
CoordinatorLayout 名副其實(shí),它是一個(gè)可以協(xié)調(diào)各個(gè)子 View 的布局。注意區(qū)別 NestedScrolling 機(jī)制,后者只能調(diào)度父子兩者的滑動(dòng),而前者可以協(xié)調(diào)所有子 View 的所有動(dòng)作。有了這個(gè)神器后我們不再需要自定義 Layout 來實(shí)現(xiàn)嵌套滑動(dòng)接口了,并且可以實(shí)現(xiàn)更復(fù)雜的效果。CoordinatorLayout 只能提供一個(gè)平臺(tái),具體效果的實(shí)現(xiàn)需要依賴 Behavior. CoordinatorLayout 的所有直接子控件都可以設(shè)置 Behavior,其定義了這個(gè) View 應(yīng)當(dāng)對(duì)觸摸事件做何反應(yīng),或者對(duì)其他 View 的變化做何反應(yīng),成功地將具體實(shí)現(xiàn)從 View 中抽離出來。
CoordinatorLayout 類似于網(wǎng)游的中央服務(wù)器。對(duì)于嵌套滑動(dòng)來說,它實(shí)現(xiàn)了 NestedScrollingParent 接口因此可以接受到子 View 的滑動(dòng)信息,并且分發(fā)給所有子 View 的 Behavior 并將它們的響應(yīng)匯總起來返回給滑動(dòng) View。對(duì)于依賴其他 View 的功能,當(dāng)有 View 屬性發(fā)生改變時(shí)它會(huì)通知所有聲明了監(jiān)聽的子 View 的 Behavior.
注意:無論嵌套多少級(jí)的滑動(dòng)事件都可以被轉(zhuǎn)發(fā)。但是只有直接子 View 可以設(shè)置
Behavior(響應(yīng)事件)或作為被監(jiān)聽的對(duì)象。


除此之外,Behavior 還有 onInterceptTouchEvent, onTouchEvent 方法,重點(diǎn)是它接收到的不僅僅是自己范圍內(nèi)的事件。也就是說現(xiàn)在子 View 可以直接攔截父布局的事件了。利用這一點(diǎn)我們可以輕松做出拖拽移動(dòng),其他 View 跟隨的效果,比如這樣:
?
Behavior 像是一個(gè)集大成者,它能夠進(jìn)行事件處理、嵌套滑動(dòng)協(xié)調(diào)、子控件變化監(jiān)聽,甚至還能直接修改布局(onMeasureChild, onLayoutChild 這里面的 Child 指的就是 Behavior 所對(duì)應(yīng)的子控件)這有什么用呢?通過一開始的例子來看看吧。
實(shí)戰(zhàn):仿三星 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>

一般來說為了簡(jiǎn)單,我們會(huì)選定1個(gè) View 用于響應(yīng)嵌套滑動(dòng),其他 View 監(jiān)聽此 View來同步改變。HeaderView 的效果比較復(fù)雜我不希望它承擔(dān)太多工作,因此這里讓 RecyclerView 自己處理嵌套滑動(dòng)問題。
這里一個(gè)重要原因是 HeaderView 有了視差效果。否則的話讓 HeaderView 響應(yīng)滑動(dòng),RecyclerView 只需要緊貼著 HeaderView 移動(dòng)就行了,更簡(jiǎn)單。
處理嵌套滑動(dòng)
現(xiàn)在開始編寫 RecyclerView 所需的 Behavior. 第一個(gè)要解決的問題就是重疊,這就需要?jiǎng)倓偺岬降母深A(yù)布局。核心思想是一開始獲取 HeaderView 的高度,作為 RecyclerView 的 Top 屬性,就可以實(shí)現(xiàn)類似 LinearLayout 的布局了。
注意:①為了能夠在 xml 中直接設(shè)置 Behavior 我們得寫一個(gè)帶有
attrs參數(shù)的構(gòu)造函數(shù)。②<View>表示 Behavior 所設(shè)置到的 View 類型,因?yàn)檫@里不需要用到 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 {
// 首先讓父布局按照標(biāo)準(zhǔn)方式解析
parent.onLayoutChild(child, layoutDirection)
// 獲取到 HeaderView 的高度
headerHeight = parent.findViewById<View>(R.id.imagesTitleBlockLayout).height
// 設(shè)置 top 從而排在 HeaderView的下面
ViewCompat.offsetTopAndBottom(child, headerHeight)
return true // true 表示我們自己完成了解析 不要再自動(dòng)解析了
}
}

正式開始嵌套滑動(dòng)的處理,先處理手指向上滑動(dòng)的情況。因?yàn)橹挥性?HeaderView 折疊后才允許 RecyclerView 滑動(dòng),因此要寫在 onNestedPreScroll 方法里。對(duì)這些滑動(dòng)回調(diào)不清楚的看看上面第二節(jié) NestingScroll 相關(guān)部分。
override fun onStartNestedScroll(coordinatorLayout: CoordinatorLayout, child: View, directTargetChild: View,
target: View, axes: Int, type: Int): Boolean {
// 如果是垂直滑動(dòng)的話就聲明需要處理
// 只有這里返回 true 才會(huì)收到下面一系列滑動(dòng)事件的回調(diào)
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) {
// 此時(shí) RecyclerView 還沒開始滑動(dòng)
super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
if (dy > 0) { // 只處理手指上滑
val newTransY = child.translationY - dy
if (newTransY >= -headerHeight) {
// 完全消耗滑動(dòng)距離后沒有完全貼頂或剛好貼頂
// 那么就聲明消耗所有滑動(dòng)距離,并上移 RecyclerView
consumed[1] = dy // consumed[0/1] 分別用于聲明消耗了x/y方向多少滑動(dòng)距離
child.translationY = newTransY
} else {
// 如果完全消耗那么會(huì)導(dǎo)致 RecyclerView 超出可視區(qū)域
// 那么只消耗恰好讓 RecyclerView 貼頂?shù)木嚯x
consumed[1] = headerHeight + child.translationY.toInt()
child.translationY = -headerHeight.toFloat()
}
}
}

并不復(fù)雜,核心思想是判斷 RecyclerView 在移動(dòng)用戶請(qǐng)求的距離后,會(huì)不會(huì)超出窗口區(qū)域。如果不超出那么就全部消耗,RV 自己不再滑動(dòng)。如果超出那么就只消耗不超出的那一部分,剩余距離由 RV 內(nèi)部滑動(dòng)。
接著寫手指向下滑動(dòng)的部分。因?yàn)檫@時(shí)候需要優(yōu)先讓 RecyclerView 滑動(dòng),在它滑動(dòng)到頂?shù)臅r(shí)候才需要整體下移讓 HeaderView 顯示出來,所以要在 onNestedScroll 里寫。
override fun onNestedScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, dxConsumed: Int,
dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int, type: Int, consumed: IntArray) {
// 此時(shí) RV 已經(jīng)完成了滑動(dòng),dyUnconsumed 表示剩余未消耗的滑動(dòng)距離
super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
type, consumed)
if (dyUnconsumed < 0) { // 只處理手指向下滑動(dòng)的情況
val newTransY = child.translationY - dyUnconsumed
if (newTransY <= 0) {
child.= newTransY
} else {
child.translationY = 0f
}
}
}

比上一個(gè)簡(jiǎn)單一些。如果滑動(dòng)后 RV 的偏移小于0(Y偏移<0代表向上移動(dòng))那么就表示還沒有完全歸位,那么消耗全部剩余距離。否則直接讓 RV 歸位就行了。
offsetTopAndBottom 與 translationY 的關(guān)系
從用途出發(fā),offsetTopAndBottom 常用于永久性修改,translationY 常用于臨時(shí)性修改(例如動(dòng)畫)這里我們也遵循了這個(gè)約定
從效果出發(fā),
offsetTopAndBottom(offset)是累加的,其內(nèi)部相當(dāng)于mTop+=offset,而 translationY 每次都是重新設(shè)置與已有值無關(guān)。最關(guān)鍵是,
onLayoutChild有可能被多次觸發(fā),因此動(dòng)畫所使用的方法必須與調(diào)整布局所使用的方法不同。否則有可能出現(xiàn)滑動(dòng)執(zhí)行到一半結(jié)果觸發(fā)了重新布局,結(jié)果自動(dòng)歸位,視覺上就是胡亂跳動(dòng)。
處理 HeaderView
接下來開始寫 HeaderView 的 Behavior 它的主要任務(wù)是監(jiān)聽 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: 當(dāng)前 Behavior 所關(guān)聯(lián)的 View,此處是 HeaderView
// dependency: 待判斷是否需要監(jiān)聽的其他子 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
}
}

這一個(gè)簡(jiǎn)單多了。layoutDependsOn 會(huì)對(duì)每一個(gè)子 View 觸發(fā)一遍,通過某種方法判斷是不是要監(jiān)聽的 View,只有這里返回了 true 才能收到對(duì)應(yīng) View 的后續(xù)回調(diào)。我們?cè)?onDependentViewChanged 中根據(jù) RecyclerView 的偏移量來計(jì)算 HeaderView 的偏于與透明度,通過乘以一個(gè)系數(shù)來實(shí)現(xiàn)視差移動(dòng)。
到此為止已經(jīng)基本上實(shí)現(xiàn)了上述效果。
Surprise! 自動(dòng)歸位
如果用戶拖動(dòng)到一半抬起了手指,讓 UI 停留在半折疊狀態(tài)是不合適的,應(yīng)當(dāng)根據(jù)具體位置自動(dòng)完全折疊或完全展開。
實(shí)現(xiàn)思路不難,監(jiān)聽停止滑動(dòng)事件,判斷當(dāng)前 RecyclerView 的偏移量,若超過一半就完全折疊否則就完全展開。這里需要借助 Scroller 實(shí)現(xiàn)動(dòng)畫。
Scroller 本質(zhì)上是個(gè)計(jì)算器,你只需告訴它起始值、變化量、持續(xù)時(shí)間,就可以幫你算出任意時(shí)刻應(yīng)該處于的位置,還可以定制不同緩動(dòng)效果。通過高頻率不斷地計(jì)算不斷地刷新不斷地移動(dòng)從而實(shí)現(xiàn)平滑動(dòng)畫。
OverScroller包含了Scroller的全部功能并增加了額外功能,因此現(xiàn)在Scroller現(xiàn)在已被標(biāo)注為棄用。
我們來修改一下 RV 對(duì)應(yīng)的 NestedContentScrollBehavior.
private lateinit var contentView: View // 其實(shí)就是 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)
}
}
}

首先定義三個(gè)變量并在合適的時(shí)候賦值。解釋一下 scrollRunnable,在得到不同時(shí)間應(yīng)該處于的不同位置后該怎么刷新 View 呢?因?yàn)榛瑒?dòng)事件已經(jīng)停止,我們得不到任何回調(diào)。王進(jìn)喜說 沒有條件就創(chuàng)造條件,這里通過 ViewCompat.postOnAnimation 讓 View 在下一次繪制時(shí)執(zhí)行定義好的 Runnable,在 Runnable 內(nèi)部改變 View 位置,如果動(dòng)畫還沒結(jié)束那么就再提交一個(gè) Runnable,于是實(shí)現(xiàn)了連續(xù)不斷的刷新。再寫兩個(gè)輔助函數(shù)便于開始和停止動(dòng)畫。
下面監(jiān)聽一下停止滑動(dòng)的回調(diào),根據(jù)情況來啟動(dòng)動(dòng)畫:
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 已經(jīng)歸位(完全折疊或完全展開)
return
}
if (child.translationY <= -headerHeight * 0.5f) {
stopAutoScroll()
startAutoScroll(child.translationY.toInt(), -headerHeight, 1000)
} else {
stopAutoScroll()
startAutoScroll(child.translationY.toInt(), 0, 600)
}
}

最后完善一下,開始滑動(dòng)時(shí)要停止動(dòng)畫,以免動(dòng)畫還沒結(jié)束用戶就迫不及待地又滑了一次:
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()
// ...
}

到這就完美啦!恭喜??
文章每周持續(xù)更新,可以微信搜索「 程序猿養(yǎng)成中心 」第一時(shí)間閱讀和催更(比博客早一到兩篇喲),另外“點(diǎn)擊公眾號(hào)下方面試/更多資料”,直接免費(fèi)獲取一二線互聯(lián)網(wǎng)企業(yè)Android開發(fā)崗面試題匯總(答案解析)以及Android架構(gòu)知識(shí)點(diǎn)匯總pdf+超清Android進(jìn)階思維腦圖。