現代化的 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 效果的糅合,毫無疑問應該從觸摸事件上入手。簡單起見暫時只考慮手指向上滑動(列表向下展現更多內容),大概須要進行如下操做:函數
onInterceptTouchEvent
中攔截事件。onTouchEvent
處理事件,對 HeaderView 進行操做(移動、改變透明度等)。如今已經遇到問題了。由於一開始父佈局攔截了事件,所以根據 Android 事件分發機制,哪怕後續再也不攔截其子控件也沒法收到事件,除非從新觸摸,這就形成了二者的滑動不能無縫銜接。佈局
接着還有一個問題,反過來當 RecyclerView 向下滑動至頂部時,如何通知 HeaderView 展開?post
哪怕解決了上述主要問題,確定還有其餘小毛病,例如子控件沒法觸發點擊事件等等等很是惱人💢。假設你是大佬完美解決了全部問題,確定耦合特別嚴重,又是自定義 View 又是互相引用的亂七八糟😵 因此如今就不往下深究了,有閒情雅緻有能力的同窗能夠嘗試實現。學習
從 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?彷佛依然有點麻煩而且解耦不完全。
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 所對應的子控件)這有什麼用呢?經過一開始的例子來看看吧。
再貼一遍效果圖:
先看看佈局:
<?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 的 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 的偏於與透明度,經過乘以一個係數來實現視差移動。
到此爲止已經基本上實現了上述效果。
若是用戶拖動到一半擡起了手指,讓 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()
// ...
}
複製代碼
到這就完美啦!恭喜🎉