仿寫豆瓣詳情頁(二)底部浮層

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

一、前言

查看動圖git

浮層的交互,簡單來講,就是github

  1. 未所有展開時,隨着滑動進行拖拽
  2. 所有展開後,上滑交給子 View 處理
  3. 所有展開後,下滑時若是子 View 能夠處理滑動,就給子 View 處理
  4. 所有展開後,下滑時若是子 View 不可處理,就隨着下滑把整個內容再託拖拽下來

以前說過改變 View 的位置的方法有多種,BottomSheetBehavior 經過 ViewDragHelper 採用改變 View 的佈局位置 top/bottom/left/right 的方式移動 View,這裏擬採用 scroll 的方式進行處理。ide

爲何要採用 scroll 方式?其實徹底是我的喜愛,我以爲 View 本身自帶了不少現成的關於 scroll 的方法(scrollToscrollBycanScrollVerticallyonScrollChanged 等),不須要我本身再訂一套。佈局

本文自定義的浮層視圖名爲 BottomSheetLayout,繼承自 FrameLayout,就不本身處理 measure 和 layout 了。post

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

2.一、當前狀態

首先咱們須要定義浮層的狀態,儘可能簡化,只定義三種狀態:動畫

  • BOTTOM_SHEET_STATE_COLLAPSED:摺疊狀態,此時只露出最小顯示高度
  • BOTTOM_SHEET_STATE_SCROLLING:正在滾動中的狀態
  • BOTTOM_SHEET_STATE_EXTENDED:展開狀態,此時露出所有內容

2.二、當前進度

進度即 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)
複製代碼

2.三、設置內容視圖

因爲咱們須要一些特殊的屬性,因此不能直接採用 addView 的方式。除了內容視圖 contentView,還須要同時設置最小的顯示高度 minShowingHeight 來計算滾動範圍,初始狀態 initState 來肯定 contentView 的初始位置。

fun setContentView( contentView: View, minShowingHeight: Int, initState: Int = BOTTOM_SHEET_STATE_COLLAPSED ) 複製代碼

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

這裏不改變原有的佈局方式,只在佈局後肯定滾動範圍,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)
        }
    }
}
複製代碼

肯定的滾動範圍會用於 canScrollVerticallyscrollTo,固然也會用在當前狀態和進度的計算上。

/** * 滾動範圍是[[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
    })
}
複製代碼

四、Touch 事件攔截

4.一、攔截的方案探討

事件攔截的關鍵在於父 View 和子 View 都能處理事件時,事件要怎麼分發的問題。一種是不攔截,讓子 View 處理,子 View 不處理了父 View 在攔截;另外一種是父 View 直接攔截,而後本身在事件處理的時候在進行分發。

攔截的方式有個蛋疼的地方在於只要你攔截了事件,那以後子 View 就再也沒法處理了。因此也就有了嵌套滾動的處理方式,不攔截事件,可是子 View 滾動時會先回調父 View,父 View 能夠在那裏進行攔截。

事件攔截的邏輯是受滾動處理方式的影響的,最開始的時候我是採用嵌套滾動的方式處理滾動的,即:能不攔截事件就不攔截,交給子 View 處理,子 View 在發生滾動時,在收到的滾動時的 onNestedPreScroll 中判斷是要本身滾仍是子 View 滾。固然還要考慮子 View 不能滾動的狀況,這時候就攔截下來本身進行滾動。

4.二、本文的攔截方案

後來發現既要處理事件的攔截,又要處理滾動的攔截,太麻煩了(也多是殺雞用牛刀了)。我就改爲了儘量地攔截事件,而後根據手指的滑動計算須要的「滾動量」,再對「滾動量」進行分發,決定是本身滾,仍是子 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)
    }
}
複製代碼

4.三、這裏再多 BB 兩句

儘量攔截事件,而後本身分發「滾動量」稍微有點麻煩,可是處理邏輯更加明確。以前還作過體驗不是很好的其餘嘗試: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 事件和 Fling 處理

6.一、Up 和 Cancel 時的復位操做

Up 和 Cancel 時,若是狀態是 BOTTOM_SHEET_STATE_SCROLLING,此時須要經過動畫滾動到 BOTTOM_SHEET_STATE_EXTENDEDBOTTOM_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
    }
}
複製代碼

6.二、Fling

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,就先無論了。若是真要解決,就須要更改攔截事件的方案,或者利用嵌套滾動來處理。

github.com/funnywolfda…

相關文章
相關標籤/搜索