仿寫豆瓣詳情頁(一)開篇
仿寫豆瓣詳情頁(二)底部浮層
仿寫豆瓣詳情頁(三)內容列表
仿寫豆瓣詳情頁(四)彈性佈局 doing
仿寫豆瓣詳情頁(五)聯動和其餘細節 doingjava
查看動圖git
若是不考慮浮層,這其實就是一個大的可滑動列表。我一開始想,這個頁面不就是個 NestedScrollView
加 LinearLayout
,裏面放不一樣的卡片,最後再來一個 ViewPager
。後來發現事情沒那麼簡單,僅僅用 NestedScrollView
會有問題,最後還須要經過自定義 View 來解決,解決的關鍵依然是「滾動量」的分發問題,下面請聽我細細道來。github
NestedScrollView
加 LinearLayout
這個方案在交互效果上能夠說和豆瓣詳情頁沒有差異,從直覺上看也是如此,並且是且實可行的。可是上面說這個方案有問題,有啥問題呢?咱們先看下這樣實現的話,View 的佈局是啥樣的。ide
因爲 NestedScrollView
不會限制子 View 的高度,因此會致使 LinearLayout
裏面放的 View 全都 layout 出來。就會致使性能不好,用戶只看到了一兩個卡片,卻把因此卡片都給 layout 處理了;其實卡片少的話還好,可是豆瓣詳情頁不只卡片多,並且還有兩三個橫向滑動的嵌套 RecyclerView
,這種方案在性能上就存在嚴重問題。並且不利於數據統計,由於咱們沒法得知哪一個卡片展示出來了,那些沒有,固然了,經過計算卡片位置和滾動位置是能夠獲得這些數據,但仍是麻煩。佈局
RecyclerView
既然 NestedScrollView
不行,那我很快就想到 RecyclerView
,用不一樣的 ViewType
和 ViewHolder
就是實現,這裏推薦下本源碼倉庫下本源碼倉庫下的 SimpleAdapter
,可以方便實現這種效果。post
不過這種方案有個嵌套滑動衝突的問題,水平滑動卻是無所謂,最下面的 ViewPager
裏是有垂直滑動的 RecyclerView
的,因爲暫時無法先什麼現成的解決方案,又不想繼承 RecyclerView
進行衝突處理,固然也是怕改出 bug,就放棄這種方案了。性能
NestedScrollView
加 LinearLayout
加 RecyclerView
既然 NestedScrollView
有性能問題,而 RecyclerView
有滑動衝突,那就二者結合一下,在 LinearLayout
裏只放 RecyclerView
和 ViewPager
,RecyclerView
裏只上面的那些卡片,這樣問題就解決了。ui
這裏須要注意的是 RecyclerView
的 layout_height
不能是 wrap_content
的,而是須要寫死高度,否則因爲 NestedScrollView
不會限制子 View 的高度,就會讓 RecyclerView
無限高,把子 View 全都 layout 出來。spa
手指往上滑動的時候,RecyclerView
的內容先往下滾,滾到頭了 NestedScrollView
會開始滾,接着露出下面的 ViewPager
。若是此時 RecyclerView
和 ViewPager
都顯示了一部分,就有個比較尷尬的問題,滑上面的 RecyclerView
是能夠滑的(滑不動了,NestedScrollView
纔會滾動),下面的 ViewPager
也是能夠滑的。還有就是,連續滑動時,不能實現 RecyclerView
和 NestedScrollView
聯動起來滾動的效果。code
怎麼會這樣呢?這個就是本文要解決的一個核心問題:父 View 和子 View 均可以滾動時,如何分發滾動量?
要解決這個問題就須要自定義一套規則來解決,既然要自定義,咱們就不用這個方案了,這裏不論是繼承 NestedScrollView
仍是 RecyclerView
都挺麻煩,仍是單獨搞把。
方便起見,這裏繼承自 FrameLayout
,命名爲 LinkedScrollView
,旨在實現能夠聯動的滾動效果。只設置 topContainer
和 bottomContainer
兩個容器子 View,二者上下挨着,使用 scroll 方式實現 View 的位移。
指望實現 topContainer
的子 View 裏的內容滾到底時,整個 LinkedScrollView
開始滾到,滾到 bottomContainer
所有露出來時再滾到 topContainer
的子 View 的內容。
這裏須要解答下 如何分發滾動量
的核心問題:
topContainer
或 bottomContainer
)徹底顯示出來,且容器中有能夠處理「滾到量」的 View,則分發給該 View 處理LinkedScrollView
)優先,本身能夠處理「滾到量」就直接處理bottomContainer
的子 View 處理,向上的交給 topContainer
的子 View 處理這麼說太抽象了,咱們拿最終實現的 demo 來講明吧。
結構上,會在 topContainer
放一個 RecyclerView
暫且命名爲 RecyclerViewTop
,bottomContainer
放一個 ViewPager
,裏面放兩個 RecyclerView
分別命名爲 RecyclerView1
和 RecyclerView2
。
交互上:
topContainer
全屏,bottomContainer
則佈局到 topContainer
下面RecyclerViewTop
裏的內容先開始向底部滾動,直到滾動到底部LinkedScrollView
開始向底部滾動,bottomContainer
露出LinkedScrollView
,由於topContainer
和 bottomContainer
都沒有徹底顯示出來bottomContainer
徹底顯示出來後,開始滾動 ViewPager
裏對應 RecyclerView1
或 RecyclerView2
的內容Fling 比較特殊,這裏單獨說下。簡單的看,fling 就是一系列的滾動,因此也遵循上述規則,fling 的速度大的時候有兩個稍特殊的狀況:
bottomContainer
裏的 RecyclerView1
或 RecyclerView2
向上的 fling(快速下滑),滾動會通過 RecyclerView1/2
-> LinkedScrollView
,當 LinkedScrollView
滾到頂,即 topContainer
徹底顯示出來後,會繼續將「滾動量」傳遞到 RecyclerViewTop
RecyclerViewTop
向下的 fling(快速上滑)的滾動會通過: RecyclerViewTop
-> LinkedScrollView
,當 LinkedScrollView
滾到底,即 bottomContainer
徹底顯示出來後,會繼續將「滾動量」傳遞到 ViewPager
的 RecyclerView1/2
(不過豆瓣的 Android 版沒作這個處理,iOS 版卻是有)效果以下:
對外主要提供上下兩個容器的操做,topContainer
和 bottomContainer
中子 View 的添加和刪除。topScrollableView
和 bottomScrollableView
的設置,這兩個會用於 fling,LinkedScrollView
沒法處理滾動時,會根據 fling 方向分發給 topContainer
的 topScrollableView
或者 bottomContainer
的 bottomScrollableView
所指向的 View。scrollableChild
以 lambda 表達式的形式提供,主要是由於像 ViewPager
,在切到不一樣的 page 時,須要滾動的 View 也是不一樣的。
fun setTopView(v: View, scrollableChild: (()->View?)? = null) {
topContainer.removeAllViews()
topContainer.addView(v)
topScrollableView = scrollableChild requestLayout() } fun removeTopView() {
topContainer.removeAllViews()
topScrollableView = null
}
fun setBottomView(v: View, scrollableChild: (()->View?)? = null) {
bottomContainer.removeAllViews()
bottomContainer.addView(v)
bottomScrollableView = scrollableChild requestLayout() } fun removeBottomView() {
bottomContainer.removeAllViews()
bottomScrollableView = null
}
複製代碼
除此以外,因爲 LinkedScrollView
是經過 scroll 的方式移動 View 的,因此相關的 scroll 方法也是可用的。
佈局的處理比較簡單,topContainer
和 bottomContainer
上下佈局,佈局完成後會計算最大滾動範圍 maxScrollY
/** * 佈局時,topContainer 在頂部,bottomContainer 緊挨着 topContainer 底部 * 佈局完還要計算下最大的滾動距離 */
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
topContainer.layout(0, 0, topContainer.measuredWidth, topContainer.measuredHeight)
bottomContainer.layout(0, topContainer.measuredHeight, bottomContainer.measuredWidth,
topContainer.measuredHeight + bottomContainer.measuredHeight)
maxScrollY = topContainer.measuredHeight + bottomContainer.measuredHeight - height
}
複製代碼
滾動範圍是從 0 到 maxScrollY
,同時在 scrollTo
的時候也會進行邊界限制。
/** * 滾動範圍是[0, [maxScrollY]],根據方向判斷垂直方向是否能夠滾動 */
override fun canScrollVertically(direction: Int): Boolean {
return if (direction > 0) {
scrollY < maxScrollY
} else {
scrollY > 0
}
}
/** * 滾動前作範圍限制 */
override fun scrollTo(x: Int, y: Int) {
super.scrollTo(x, when {
y < 0 -> 0
y > maxScrollY -> maxScrollY
else -> y
})
}
複製代碼
事件攔截在 仿寫豆瓣詳情頁(二)底部浮層 中有過詳細探討,這裏就不贅述了,這裏仍是採用「儘量攔截」的思想,攔截後再將 touch 移動產生的「滾動量」進行分發。
LinkedScrollView
只處理 y 軸的滾動,因此只要 y 軸的移動大於 x 軸就攔截。
override fun onInterceptTouchEvent(e: MotionEvent): Boolean {
return when (e.action) {
// ...
MotionEvent.ACTION_MOVE -> {
if (abs(lastX - e.x) < abs(lastY - e.y)) {
true
} else {
// ...
false
}
}
// ...
}
}
複製代碼
在 move 時要計算「滾動量」dScrollY
,findChildUnder
找到觸點所在的直接子 View child
用來判斷其是否徹底顯示出來,同時還要 child?.findScrollableTarget
找到 child
中能夠處理「滾動量」的 View,最後 dispatchScrollY
進行滾動的分發。
override fun onTouchEvent(e: MotionEvent): Boolean {
return when (e.action) {
// ...
MotionEvent.ACTION_MOVE -> {
// 移動時分發滾動量
val dScrollY = (lastY - e.y).toInt()
val child = findChildUnder(e.rawX, e.rawY)
dispatchScrollY(dScrollY, child, child?.findScrollableTarget(e.rawX, e.rawY, dScrollY))
lastY = e.y
// ...
true
}
// ...
}
}
複製代碼
「滾動量」分發的邏輯在「2.4」中已經闡明過,代碼中實現起來更加簡明一點。
private fun dispatchScrollY(dScrollY: Int, child: View?, target: View?) {
if (dScrollY == 0) {
return
}
// 滾動所處的位置沒有在子 view,或者子 view 沒有徹底顯示出來
// 或者子 view 中沒有要處理滾動的 target,或者 target 不在可以滾動
if (child == null || !isChildTotallyShowing(child)
|| target == null || !target.canScrollVertically(dScrollY)) {
// 優先本身處理,處理不了再根據滾動方向交給頂部或底部的 view 處理
when {
canScrollVertically(dScrollY) -> scrollBy(0, dScrollY)
dScrollY > 0 -> bottomScrollableView?.invoke()?.scrollBy(0, dScrollY)
else -> topScrollableView?.invoke()?.scrollBy(0, dScrollY)
}
} else {
target.scrollBy(0, dScrollY)
}
}
複製代碼
Fling 的處理須要兩個輔助類,VelocityTracker
用於計算擡手時的速度,Scroller
用於計算 fling 每次滾動的距離。
在 onTouchEvent
中經過 VelocityTracker
記錄每次事件,在 up 時計算擡手時的速度 yv
(這裏取反的緣由以前也說過,就是 touch 事件的方向和 scroll 的方向恰好相反)。和 move 時同樣,還須要 findChildUnder
找到 child
,child?.findScrollableTarget
找到能夠處理 fling 的目標 View。
override fun onTouchEvent(e: MotionEvent): Boolean {
return when (e.action) {
MotionEvent.ACTION_DOWN -> {
// 手指按下時記錄 y 軸初始位置
lastY = e.y
velocityTracker.clear()
velocityTracker.addMovement(e)
true
}
MotionEvent.ACTION_MOVE -> {
// ...
velocityTracker.addMovement(e)
true
}
MotionEvent.ACTION_UP -> {
// 手指擡起時計算 y 軸速度,而後自身處理 fling
velocityTracker.addMovement(e)
velocityTracker.computeCurrentVelocity(1000)
val yv = -velocityTracker.yVelocity.toInt()
val child = findChildUnder(e.rawX, e.rawY)
handleFling(yv, child, child?.findScrollableTarget(e.rawX, e.rawY, yv))
true
}
// ...
}
}
複製代碼
Fling 的處理只要靠 Scroller
來進行計算,以前也說過 fling 是一些列的滾動,因此須要臨時存放一些參數,好比上次 fling 計算的 y 值 lastFlingY
(這裏從 0 開始,咱們只須要相對值就行),觸點所在的直接子 View flingChild
和能夠處理 fling 的目標 View flingTarget
。
/** * 處理 fling,經過 scroller 計算 fling,暫存 fling 的初值和須要 fling 的 view */
private fun handleFling(yv: Int, child: View?, target: View?) {
lastFlingY = 0
scroller.fling(0, lastFlingY, 0, yv, 0, 0, Int.MIN_VALUE, Int.MAX_VALUE)
flingChild = child
flingTarget = target invalidate() } 複製代碼
在 computeScroll
計算「滾動量」dScrollY
,和 move 事件同樣進行 dispatchScrollY
分發。
/** * 計算 fling 的滾動量,並將其分發到真正須要處理的 view */
override fun computeScroll() {
if (scroller.computeScrollOffset()) {
val currentFlingY = scroller.currY
val dScrollY = currentFlingY - lastFlingY dispatchScrollY(dScrollY, flingChild, flingTarget) lastFlingY = currentFlingY invalidate() } else {
flingChild = null
}
}
複製代碼
LinkedScrollView
的事件處理方式和 BottomSheetLayout
同樣,具體邏輯實現還更簡單一點,不過我自身文筆很差,講的有點囉嗦,大佬們有什麼不一樣意見,歡迎在評論區交(dui)流(xian)。
接下來會實現一個彈性佈局 JellyLayout
來實現豆瓣詳情頁橫向滑動列表的彈性效果。