仿寫豆瓣詳情頁(三)內容列表

仿寫豆瓣詳情頁(一)開篇
仿寫豆瓣詳情頁(二)底部浮層
仿寫豆瓣詳情頁(三)內容列表
仿寫豆瓣詳情頁(四)彈性佈局 doing
仿寫豆瓣詳情頁(五)聯動和其餘細節 doingjava

一、前言

查看動圖git

若是不考慮浮層,這其實就是一個大的可滑動列表。我一開始想,這個頁面不就是個 NestedScrollViewLinearLayout,裏面放不一樣的卡片,最後再來一個 ViewPager。後來發現事情沒那麼簡單,僅僅用 NestedScrollView 會有問題,最後還須要經過自定義 View 來解決,解決的關鍵依然是「滾動量」的分發問題,下面請聽我細細道來。github

二、方案選擇

2.一、NestedScrollViewLinearLayout

這個方案在交互效果上能夠說和豆瓣詳情頁沒有差異,從直覺上看也是如此,並且是且實可行的。可是上面說這個方案有問題,有啥問題呢?咱們先看下這樣實現的話,View 的佈局是啥樣的。ide

因爲 NestedScrollView 不會限制子 View 的高度,因此會致使 LinearLayout 裏面放的 View 全都 layout 出來。就會致使性能不好,用戶只看到了一兩個卡片,卻把因此卡片都給 layout 處理了;其實卡片少的話還好,可是豆瓣詳情頁不只卡片多,並且還有兩三個橫向滑動的嵌套 RecyclerView,這種方案在性能上就存在嚴重問題。並且不利於數據統計,由於咱們沒法得知哪一個卡片展示出來了,那些沒有,固然了,經過計算卡片位置和滾動位置是能夠獲得這些數據,但仍是麻煩。佈局

2.二、RecyclerView

既然 NestedScrollView 不行,那我很快就想到 RecyclerView,用不一樣的 ViewTypeViewHolder 就是實現,這裏推薦下本源碼倉庫下本源碼倉庫下SimpleAdapter,可以方便實現這種效果。post

不過這種方案有個嵌套滑動衝突的問題,水平滑動卻是無所謂,最下面的 ViewPager 裏是有垂直滑動的 RecyclerView 的,因爲暫時無法先什麼現成的解決方案,又不想繼承 RecyclerView 進行衝突處理,固然也是怕改出 bug,就放棄這種方案了。性能

2.三、NestedScrollViewLinearLayoutRecyclerView

既然 NestedScrollView 有性能問題,而 RecyclerView 有滑動衝突,那就二者結合一下,在 LinearLayout 裏只放 RecyclerViewViewPagerRecyclerView 裏只上面的那些卡片,這樣問題就解決了。ui

這裏須要注意的是 RecyclerViewlayout_height 不能是 wrap_content 的,而是須要寫死高度,否則因爲 NestedScrollView 不會限制子 View 的高度,就會讓 RecyclerView 無限高,把子 View 全都 layout 出來。spa

手指往上滑動的時候,RecyclerView 的內容先往下滾,滾到頭了 NestedScrollView 會開始滾,接着露出下面的 ViewPager。若是此時 RecyclerViewViewPager 都顯示了一部分,就有個比較尷尬的問題,滑上面的 RecyclerView 是能夠滑的(滑不動了,NestedScrollView 纔會滾動),下面的 ViewPager 也是能夠滑的。還有就是,連續滑動時,不能實現 RecyclerViewNestedScrollView 聯動起來滾動的效果。code

怎麼會這樣呢?這個就是本文要解決的一個核心問題:父 View 和子 View 均可以滾動時,如何分發滾動量?

要解決這個問題就須要自定義一套規則來解決,既然要自定義,咱們就不用這個方案了,這裏不論是繼承 NestedScrollView 仍是 RecyclerView 都挺麻煩,仍是單獨搞把。

2.4 本文方案

方便起見,這裏繼承自 FrameLayout,命名爲 LinkedScrollView,旨在實現能夠聯動的滾動效果。只設置 topContainerbottomContainer 兩個容器子 View,二者上下挨着,使用 scroll 方式實現 View 的位移。

指望實現 topContainer 的子 View 裏的內容滾到底時,整個 LinkedScrollView 開始滾到,滾到 bottomContainer 所有露出來時再滾到 topContainer 的子 View 的內容。

這裏須要解答下 如何分發滾動量 的核心問題:

  1. 觸點位置的容器(topContainerbottomContainer)徹底顯示出來,且容器中有能夠處理「滾到量」的 View,則分發給該 View 處理
  2. 其餘狀況本身(LinkedScrollView)優先,本身能夠處理「滾到量」就直接處理
  3. 本身不處理時,向下的「滾到量」(大於 0)交給 bottomContainer 的子 View 處理,向上的交給 topContainer 的子 View 處理

這麼說太抽象了,咱們拿最終實現的 demo 來講明吧。

結構上,會在 topContainer 放一個 RecyclerView 暫且命名爲 RecyclerViewTopbottomContainer 放一個 ViewPager,裏面放兩個 RecyclerView 分別命名爲 RecyclerView1RecyclerView2

交互上:

  1. 初始化後,topContainer 全屏,bottomContainer 則佈局到 topContainer 下面
  2. 手指上滑時,RecyclerViewTop 裏的內容先開始向底部滾動,直到滾動到底部
  3. 手指繼續上滑,整個 LinkedScrollView 開始向底部滾動,bottomContainer 露出
  4. 此時無論手指在那個方位上下滑,都會滾動 LinkedScrollView,由於topContainerbottomContainer 都沒有徹底顯示出來
  5. 繼續上滑,直到 bottomContainer 徹底顯示出來後,開始滾動 ViewPager 裏對應 RecyclerView1RecyclerView2 的內容
  6. 手指下滑的狀況同理

Fling 比較特殊,這裏單獨說下。簡單的看,fling 就是一系列的滾動,因此也遵循上述規則,fling 的速度大的時候有兩個稍特殊的狀況:

  1. bottomContainer 裏的 RecyclerView1RecyclerView2 向上的 fling(快速下滑),滾動會通過 RecyclerView1/2 -> LinkedScrollView,當 LinkedScrollView 滾到頂,即 topContainer 徹底顯示出來後,會繼續將「滾動量」傳遞到 RecyclerViewTop
  2. 同理 RecyclerViewTop 向下的 fling(快速上滑)的滾動會通過: RecyclerViewTop -> LinkedScrollView,當 LinkedScrollView 滾到底,即 bottomContainer 徹底顯示出來後,會繼續將「滾動量」傳遞到 ViewPagerRecyclerView1/2(不過豆瓣的 Android 版沒作這個處理,iOS 版卻是有)

效果以下:

[查看動圖](https://user-gold-cdn.xitu.io/2020/4/25/171b0a1dd781872e?w=360&h=640&f=gif&s=1070853)

三、對外暴露的方法和屬性

對外主要提供上下兩個容器的操做,topContainerbottomContainer 中子 View 的添加和刪除。topScrollableViewbottomScrollableView 的設置,這兩個會用於 fling,LinkedScrollView 沒法處理滾動時,會根據 fling 方向分發給 topContainertopScrollableView 或者 bottomContainerbottomScrollableView 所指向的 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 方法也是可用的。

四、Layout 處理和滾動範圍的肯定

佈局的處理比較簡單,topContainerbottomContainer 上下佈局,佈局完成後會計算最大滾動範圍 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 事件攔截

事件攔截在 仿寫豆瓣詳情頁(二)底部浮層 中有過詳細探討,這裏就不贅述了,這裏仍是採用「儘量攔截」的思想,攔截後再將 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
                }
            }
            // ...
        }
    }
複製代碼

六、Touch 事件的處理和滾動的分發

在 move 時要計算「滾動量」dScrollYfindChildUnder 找到觸點所在的直接子 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 的處理

Fling 的處理須要兩個輔助類,VelocityTracker 用於計算擡手時的速度,Scroller 用於計算 fling 每次滾動的距離。

onTouchEvent 中經過 VelocityTracker 記錄每次事件,在 up 時計算擡手時的速度 yv(這裏取反的緣由以前也說過,就是 touch 事件的方向和 scroll 的方向恰好相反)。和 move 時同樣,還須要 findChildUnder 找到 childchild?.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 來實現豆瓣詳情頁橫向滑動列表的彈性效果。

github.com/funnywolfda…

相關文章
相關標籤/搜索