仿寫豆瓣詳情頁(一)開篇
仿寫豆瓣詳情頁(二)底部浮層
仿寫豆瓣詳情頁(三)內容列表
仿寫豆瓣詳情頁(四)彈性佈局 doing
仿寫豆瓣詳情頁(五)聯動和其餘細節 doingjava
查看動圖git
浮層的交互,簡單來講,就是github
以前說過改變 View 的位置的方法有多種,BottomSheetBehavior
經過 ViewDragHelper
採用改變 View 的佈局位置 top/bottom/left/right 的方式移動 View,這裏擬採用 scroll 的方式進行處理。ide
爲何要採用 scroll 方式?其實徹底是我的喜愛,我以爲 View 本身自帶了不少現成的關於 scroll 的方法(scrollTo
、scrollBy
、canScrollVertically
、onScrollChanged
等),不須要我本身再訂一套。佈局
本文自定義的浮層視圖名爲 BottomSheetLayout
,繼承自 FrameLayout
,就不本身處理 measure 和 layout 了。post
首先咱們須要定義浮層的狀態,儘可能簡化,只定義三種狀態:動畫
BOTTOM_SHEET_STATE_COLLAPSED
:摺疊狀態,此時只露出最小顯示高度BOTTOM_SHEET_STATE_SCROLLING
:正在滾動中的狀態BOTTOM_SHEET_STATE_EXTENDED
:展開狀態,此時露出所有內容進度即 View 移動的相對位置的百分比,根據 View 露出的最小高度,以及徹底展開時的高度,確認 View 的移動範圍,進而根據 View 的當前位置計算當前進度。this
BOTTOM_SHEET_STATE_COLLAPSED
時進度爲 0,BOTTOM_SHEET_STATE_EXTENDED
時進度爲 1,BOTTOM_SHEET_STATE_SCROLLING
則根據實際位置進行計算。spa
固然還須要支持進度的設置,方便外部進行一些動畫等操做。3d
因爲 BottomSheetLayout
採用滾動的方式移動 View,因此進度就和 View.scrollY
相關,對進度的設置也就是 View.scrollTo
。
/** * 當前滾動的進度,[BOTTOM_SHEET_STATE_COLLAPSED] 時是 0,[BOTTOM_SHEET_STATE_EXTENDED] 時是 1 */
@FloatRange(from = 0.0, to = 1.0)
var process = 0F
fun setProcess(@FloatRange(from = 0.0, to = 1.0) process: Float, smoothly: Boolean = true)
複製代碼
因爲咱們須要一些特殊的屬性,因此不能直接採用 addView
的方式。除了內容視圖 contentView
,還須要同時設置最小的顯示高度 minShowingHeight
來計算滾動範圍,初始狀態 initState
來肯定 contentView
的初始位置。
fun setContentView( contentView: View, minShowingHeight: Int, initState: Int = BOTTOM_SHEET_STATE_COLLAPSED ) 複製代碼
這裏不改變原有的佈局方式,只在佈局後肯定滾動範圍,scrollY
的最小值 minScrollY
和最大值 maxScrollY
,並根據初始狀態設置進度(其實就是設置 scrollY
)。
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
// ...
contentView?.also {
// ...
minScrollY = it.top + minShowingHeight - height
maxScrollY = it.bottom - height if (initState == BOTTOM_SHEET_STATE_EXTENDED) {
setProcess(1F, false)
} else {
setProcess(0F, false)
}
}
}
複製代碼
肯定的滾動範圍會用於 canScrollVertically
和 scrollTo
,固然也會用在當前狀態和進度的計算上。
/** * 滾動範圍是[[minScrollY], [maxScrollY]],根據方向判斷垂直方向是否能夠滾動 */
override fun canScrollVertically(direction: Int): Boolean {
return if (direction > 0) {
scrollY < maxScrollY
} else {
scrollY > minScrollY
}
}
/** * 滾動前作範圍限制 */
override fun scrollTo(x: Int, y: Int) {
super.scrollTo(x, when {
y < minScrollY -> minScrollY
y > maxScrollY -> maxScrollY
else -> y
})
}
複製代碼
事件攔截的關鍵在於父 View 和子 View 都能處理事件時,事件要怎麼分發的問題。一種是不攔截,讓子 View 處理,子 View 不處理了父 View 在攔截;另外一種是父 View 直接攔截,而後本身在事件處理的時候在進行分發。
攔截的方式有個蛋疼的地方在於只要你攔截了事件,那以後子 View 就再也沒法處理了。因此也就有了嵌套滾動的處理方式,不攔截事件,可是子 View 滾動時會先回調父 View,父 View 能夠在那裏進行攔截。
事件攔截的邏輯是受滾動處理方式的影響的,最開始的時候我是採用嵌套滾動的方式處理滾動的,即:能不攔截事件就不攔截,交給子 View 處理,子 View 在發生滾動時,在收到的滾動時的 onNestedPreScroll
中判斷是要本身滾仍是子 View 滾。固然還要考慮子 View 不能滾動的狀況,這時候就攔截下來本身進行滾動。
後來發現既要處理事件的攔截,又要處理滾動的攔截,太麻煩了(也多是殺雞用牛刀了)。我就改爲了儘量地攔截事件,而後根據手指的滑動計算須要的「滾動量」,再對「滾動量」進行分發,決定是本身滾,仍是子 View 滾,邏輯爲:
BOTTOM_SHEET_STATE_SCROLLING
只是一箇中間狀態,確定攔截ACTION_MOVE
時,若是觸點在 contentView
上,且垂直移動大於水平移動,就攔截override fun onInterceptTouchEvent(e: MotionEvent): Boolean {
// 正在滾動中確定要本身攔截處理
if (state == BOTTOM_SHEET_STATE_SCROLLING) {
return true
}
// move 時,在內容 view 區域,且 y 軸偏移更大,就攔截
return if (e.action == MotionEvent.ACTION_MOVE) {
contentView?.isUnder(e.rawX, e.rawY) == true && abs(lastX - e.x) < abs(lastY - e.y)
} else {
lastX = e.x
lastY = e.y
super.onInterceptTouchEvent(e)
}
}
複製代碼
儘量攔截事件,而後本身分發「滾動量」稍微有點麻煩,可是處理邏輯更加明確。以前還作過體驗不是很好的其餘嘗試:BOTTOM_SHEET_STATE_EXTENDED
時判斷子 View 是否可以處理,子 View 能處理就不攔截事件,不能處理才攔截下來本身滾動。這種方式看起來沒啥問題,可是在 BOTTOM_SHEET_STATE_EXTENDED
邊界處體驗就不是很好。
好比剛開始是未所有展開 BOTTOM_SHEET_STATE_SCROLLING
,手指向上滑,咱們本身攔截了事件,而後到滾動展開狀態 BOTTOM_SHEET_STATE_EXTENDED
,以後輪到子 View 處理了,這時候因爲事件已經被咱們攔截了,無法交給子 View 處理,只能擡手而後再上滑,才能讓子 View 的內容開始滾動。下拉的時候也存在這樣的問題,滾動不連貫,體驗很差。
在事件攔截中,咱們的策略是儘量地攔截事件,垂直方向的事件都被攔截了,那子 View (不管是直接的仍是間接的)的滾動和自身的滾動都須要咱們來進行分發。
在 ACTION_MOVE
時要計算「滾動量」,等於上次觸點的 y 值減去此次觸點的 y 值。爲何是上次減去此次呢?由於手指上滑時,觸點的 y 值減小,列表內容向下滾動(是的,是向下),此時 scrollY 是會增大。還不明白的話就本身打 log 看下。
有了「滾動量」,還須要找到可以處理該「滾動量」的子 View,方法 fun View.findScrollableTarget(rawX: Float, rawY: Float, dScrollY: Int): View?
就是經過遞歸的方式,在觸點 (rawX, rawY) 位置所處的 View 中,從父級一層一層向裏,找到能夠處理「滾動量」dScrollY
的 View。
override fun onTouchEvent(e: MotionEvent): Boolean {
return when (e.action) {
// ...
// move 時分發滾動量
MotionEvent.ACTION_MOVE -> {
val dy = (lastY - e.y).toInt()
lastY = e.y dispatchScrollY(dy, contentView?.findScrollableTarget(e.rawX, e.rawY, dy)) } // ... } } 複製代碼
滾動的分發是由 dispatchScrollY
方法處理的,邏輯暫時還不復雜,BOTTOM_SHEET_STATE_EXTENDED
時,優先滾動 target
(就是 findScrollableTarget
找到的處理 dScrollY
的 View),再滾動本身,其餘狀態就只滾動本身。
private fun dispatchScrollY(dScrollY: Int, target: View?) {
if (state == BOTTOM_SHEET_STATE_EXTENDED) {
if (target != null && target.canScrollVertically(dScrollY)) {
target.scrollBy(0, dScrollY)
} else {
scrollBy(0, dScrollY)
}
} else if (canScrollVertically(dScrollY)) {
scrollBy(0, dScrollY)
}
}
複製代碼
這樣咱們就解決了交互上不連貫的問題。
Up 和 Cancel 時,若是狀態是 BOTTOM_SHEET_STATE_SCROLLING
,此時須要經過動畫滾動到 BOTTOM_SHEET_STATE_EXTENDED
或 BOTTOM_SHEET_STATE_COLLAPSED
狀態進行復位,具體是哪一個狀態還看具體需求吧,我這裏是按最後一次移動的方向來算的。
這裏爲外部攔截處理提供了 onReleaseListener
,能夠先不考慮。
放在 dispatchTouchEvent
是爲了提早處理,而直接返回 true,再也不分發是由於 onTouchEvent
的 up 會處理子 View 的 fling,若是這裏處理了復位,同時又讓子 View fling 的話,看起來會很奇怪,感興趣的能夠去掉試下。
override fun dispatchTouchEvent(e: MotionEvent): Boolean {
when (e.action) {
// ...
// up 或 cancel 時判斷是否要平滑滾動到穩定位置
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
// 發生了移動,且處於滾動中的狀態,且未被攔截,則本身處理
if (lastDir != 0
&& state == BOTTOM_SHEET_STATE_SCROLLING
&& onReleaseListener?.invoke(this) != true) {
smoothScrollToY(if (lastDir > 0) { maxScrollY } else { minScrollY })
// 這裏返回 true 防止分發給子 view 致使其抖動
return true
}
}
}
return super.dispatchTouchEvent(e)
}
複製代碼
smoothScrollToY
經過 Scroller
實現動畫效果,lastComputeY
是爲了在 computeScroll
中輔助計算「滾動量」的。flingTarget
是用於 fling,由於 fling 也是用 Scroller
進行處理的,因此 flingTarget
起到了區分 fling 和普通滾動的做用。
/** * 利用 [scroller] 平滑滾動到目標位置,只用於自身的滾動 */
private fun smoothScrollToY(y: Int) {
if (scrollY == y) {
return
}
lastComputeY = scrollY
flingTarget = null
scroller.startScroll(0, scrollY, 0, y - scrollY)
invalidate()
}
複製代碼
這裏的 dispatchScrollY
多了個 boolean 返回值,用於表示是否處理這個「滾動量」,不處理的話會把動畫關掉,這個主要和 fling 有關,下面會繼續介紹。
/** * 計算 [scroller] 當前的滾動量並分發,再也不處理就關掉動畫 * 動畫結束時及時復位 fling 的目標 view */
override fun computeScroll() {
if (scroller.computeScrollOffset()) {
val currentY = scroller.currY
val dScrollY = currentY - lastComputeY
lastComputeY = currentY if (!dispatchScrollY(dScrollY, flingTarget)) {
scroller.abortAnimation()
}
invalidate()
} else {
flingTarget = null
}
}
複製代碼
Fling 其實就是擡手後的一系列減速的滾動事件,首先須要明確一點,fling 只做用於子 View 的滾動,不用於自身的滾動。
這是由於在所有展開時,向下的 fling 會把整個內容帶下來,而 up 時狀態又不是 BOTTOM_SHEET_STATE_SCROLLING
,這時整個內容視圖會懸在半空中(BOTTOM_SHEET_STATE_SCROLLING
狀態),若是咱們在 fling 結束後像 up 時同樣進行復位,再進行滾動,又會速度不一致,體驗很差。
Fling 須要用到 VelocityTracker
,在 onTouchEvent
收集一系列事件,在 up 時計算垂直方向的速度,進行 fling。
這裏對 velocityTracker.yVelocity
取反纔是 Scroller
處理 fling 的速度,和 move 的滑動事件一個緣由,再也不贅述。一樣的,也須要 findScrollableTarget
找到可以處理這個 fling 的子 View。
override fun onTouchEvent(e: MotionEvent): Boolean {
return when (e.action) {
// down 時,觸點在內容視圖上時才繼續處理
MotionEvent.ACTION_DOWN -> {
velocityTracker.clear()
velocityTracker.addMovement(e)
contentView?.isUnder(e.rawX, e.rawY) == true
}
// move 時分發滾動量
MotionEvent.ACTION_MOVE -> {
velocityTracker.addMovement(e)
val dy = (lastY - e.y).toInt()
lastY = e.y dispatchScrollY(dy, contentView?.findScrollableTarget(e.rawX, e.rawY, dy)) } // up 時要處理子 view 的 fling MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
velocityTracker.addMovement(e)
velocityTracker.computeCurrentVelocity(1000)
val yv = -velocityTracker.yVelocity.toInt()
handleFling(yv, contentView?.findScrollableTarget(e.rawX, e.rawY, yv))
true
}
else -> super.onTouchEvent(e)
}
}
複製代碼
前面說了 fling 只用於子 View,沒有要處理的子 View 就直接返回。 flingTarget
用於記錄本次 fling 的目標子 View。以後仍是交給 Scroller
處理,在 computeScroll
中分發滾動。
private fun handleFling(yv: Int, target: View?) {
target ?: return
lastComputeY = 0
flingTarget = target
scroller.fling(0, lastComputeY, 0, yv, 0, 0, Int.MIN_VALUE, Int.MAX_VALUE)
invalidate()
}
複製代碼
dispatchScrollY
的完整代碼以下,加入了對 fling 的判斷和是否可以處理的判斷。
/** * 分發 y 軸滾動事件 * 展開狀態:優先處理 [target],而後若是不是 fling (fling 不用於自身的滾動)才處理本身 * 非展開狀態:只處理本身 * * @param dScrollY y 軸的滾動量 * @param target 能夠處理改滾動量的目標 view * @return 是否能夠處理 */
private fun dispatchScrollY(dScrollY: Int, target: View?): Boolean {
// 0 默承認以處理
if (dScrollY == 0) {
return true
}
return if (state == BOTTOM_SHEET_STATE_EXTENDED) {
if (target != null && target.canScrollVertically(dScrollY)) {
target.scrollBy(0, dScrollY)
true
} else if (!isFling() && canScrollVertically(dScrollY)) {
scrollBy(0, dScrollY)
true
} else {
false
}
} else if (canScrollVertically(dScrollY)) {
scrollBy(0, dScrollY)
true
} else {
false
}
}
複製代碼
這樣咱們算是完成了底部浮層,但並不完美,好比每次分發「滾動量」時都要 findScrollableTarget
尋找處理滾動的 View,那若是一個 View 和它的子 View 都能處理呢?這時候優先級不該該有咱們決定,可是 findScrollableTarget
是直接返回第一個的。
這裏的確是有點小問題,但就像我在 仿寫豆瓣詳情頁(一)開篇 結尾處所說的,不要過於追求大而全,目前我也沒遇到這樣的 case,就先無論了。若是真要解決,就須要更改攔截事件的方案,或者利用嵌套滾動來處理。