仿寫豆瓣詳情頁(四)彈性佈局

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

一、前言

查看動圖git

首先聲明一下,這裏說的「彈性佈局」並非指的 FlexLayout,而是上圖所示的這種視圖。在某個方向滾動到底,再進行滑動時,會滑出邊界外的視圖,鬆手後彈回,就像彈簧同樣。這個視圖的應用其實很普遍,開源方案也有不少,和「仿寫豆瓣詳情頁」的關係並非很大,這裏只是順便造一個輪子,學習下嵌套滾動的知識。github

二、方案選擇

2.一、視圖佈局

自定義彈性佈局繼承自 FrameLayout,命名爲 JellyLayout。這裏須要決定下如何佈局和展現彈性拖拽時,拖拽方向的視圖。數組

爲了通用性,這裏採用 topViewbottomViewleftViewrightView 等佈局外視圖,放在佈局邊界的上下左右四個方向,經過滾動整個視圖的方式來露出對應的視圖。ide

2.二、事件處理方案

仿寫豆瓣詳情頁(二)底部浮層仿寫豆瓣詳情頁(三)內容列表 的方案是儘量攔截事件,而後本身分發滾動。以前也說過這種方案的一個缺點,就是內部有嵌套滾動的視圖時,沒法準確肯定如何分發「滾動量」,由於這個時候應該由子 View 來分發事件。佈局

這裏採用嵌套滾動的方案,對嵌套滾動還不瞭解的能夠參考下 自定義View事件之進階篇(一)-NestedScrolling(嵌套滑動)機制,大致思想就是本身儘量不攔截事件,交給子 View 處理,而子 View 再滾動時會先通知父 View(需實現 NestedScrollingParent 接口),父 View 能夠在滾動先後進行處理。post

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

3.一、視圖的設置

設置上下左右視圖的方法。學習

// ...
fun setTopView(v: View?): JellyLayout {
    removeView(topView)
    topView = v if (v != null) { addView(v) }
    return this
}
// ...
複製代碼

3.二、滾動的區域和進度

爲了詳細處理「滾動量」的分發和表示當前滾動的狀態,除了 scrollXscrollY 等參數,咱們還須要知道邊界外的哪一個區域視圖顯示了出來 currRegion,顯示了多少 currProcess動畫

這裏咱們定義下滾動的區域:this

  • JELLY_REGION_NONE 表示邊界外的視圖都沒顯示出來
  • JELLY_REGION_TOP 表示頂部視圖顯示出來
  • JELLY_REGION_BOTTOM 表示底部視圖顯示出來
  • JELLY_REGION_LEFT 表示左邊視圖顯示出來
  • JELLY_REGION_RIGHT 表示右邊視圖顯示出來

同時還須要規定一次只有一個區域的視圖會顯示出來,以下圖,左邊的視圖顯示出來時,currRegionJELLY_REGION_LEFT,這個時候右邊的視圖就不會顯示出來(廢話),同時也不處理垂直方向的滾動,上下區域的視圖也不會顯示出來。

const val JELLY_REGION_NONE = 0
    const val JELLY_REGION_TOP = 1
    const val JELLY_REGION_BOTTOM = 2
    const val JELLY_REGION_LEFT = 3
    const val JELLY_REGION_RIGHT = 4

    /** * 當前滾動所在的區域,一次只支持在一個區域滾動 */
    @JellyRegion
    var currRegion = JELLY_REGION_NONE get() = when {
            scrollY < 0 -> JELLY_REGION_TOP
            scrollY > 0 -> JELLY_REGION_BOTTOM
            scrollX < 0 -> JELLY_REGION_LEFT
            scrollX > 0 -> JELLY_REGION_RIGHT
            else -> JELLY_REGION_NONE
        }
        private set
複製代碼

說了區域,進度 currProcess 就很簡單了,就是在 currRegion 的視圖顯示出來的比例(minScrollYmaxScrollYminScrollXmaxScrollX 是滾動的範圍,以後會說)。這樣經過 currRegioncurrProcess,咱們就可以精確而方便地知道彈性視圖滾動的狀態了,即哪一個區域的視圖顯示或滾動出來了多少。

/** * 當前區域的滾動進度 */
    @FloatRange(from = 0.0, to = 1.0)
    var currProcess = 0F
        get() = when {
            scrollY < 0 -> if (minScrollY != 0) { scrollY.toFloat() / minScrollY } else { 0F }
            scrollY > 0 -> if (maxScrollY != 0) { scrollY.toFloat() / maxScrollY } else { 0F }
            scrollX < 0 -> if (minScrollX != 0) { scrollX.toFloat() / minScrollX } else { 0F }
            scrollX > 0 -> if (maxScrollX != 0) { scrollX.toFloat() / maxScrollX } else { 0F }
            else -> 0F
        }
        private set
複製代碼

爲了支持一下外部自定義的動畫,這裏還支持進度的設置,即滾動到某個區域 region 的某個進度 process,以及是否平滑滾動 smoothlysmoothScrollTo 會利用 Scroller 作平滑的滾動,以後說。

fun setProcess( @JellyRegion region: Int, @FloatRange(from = 0.0, to = 1.0) process: Float = 0F,
        smoothly: Boolean = true
    ) {
        var x = 0
        var y = 0
        when (region) {
            JELLY_REGION_TOP -> y = (minScrollY * process).toInt()
            JELLY_REGION_BOTTOM -> y = (maxScrollY * process).toInt()
            JELLY_REGION_LEFT -> x = (minScrollX * process).toInt()
            JELLY_REGION_RIGHT -> x = (maxScrollX * process).toInt()
        }
        if (smoothly) {
            smoothScrollTo(x, y)
        } else {
            scrollTo(x, y)
        }
    }
複製代碼

3.三、其餘

一些更細節的配置和當前屬性,方便外部作動畫之類的。

/** * 上次 x 軸的滾動方向,主要用來判斷是否發生了滾動 */
    var lastScrollXDir: Int = 0
        private set

    /** * 上次 y 軸的滾動方向 */
    var lastScrollYDir: Int = 0
        private set
        
    /** * 發生滾動時的回調 */
    var onScrollChangedListener: ((JellyLayout)->Unit)? = null

    /** * 復位時的回調,返回是否攔截處理復位事件 */
    var onResetListener: ((JellyLayout)->Boolean)? = null

    /** * 復位時的動畫時間 */
    var resetDuration: Int = 500

    /** * 滾動的阻尼 */
    var resistence = 2F
複製代碼

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

佈局時偷下懶,對於邊界外的 View 沒采用 laypout 的方式,而是屬性動畫的 translation。將邊界外視圖移動到對應側的位置,同時根據對於 View 的寬高計算出滾動範圍 minScrollYmaxScrollYminScrollXmaxScrollX

override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
        super.onLayout(changed, left, top, right, bottom)
        topView?.also {
            // 水平方向居中
            it.x = (width - it.width) / 2F
            // topView 的底部與彈性視圖頂部對齊
            it.y = -it.height.toFloat()
        }
        bottomView?.also {
            it.x = (width - it.width) / 2F
            it.y = height.toFloat()
        }
        leftView?.also {
            it.x = -it.width.toFloat()
            it.y = (height - it.height) / 2F
        }
        rightView?.also {
            it.x = width.toFloat()
            it.y = (height - it.height) / 2F
        }
        minScrollX = -(leftView?.width ?: 0)
        maxScrollX = rightView?.width ?: 0
        minScrollY = -(topView?.height ?: 0)
        maxScrollY = bottomView?.height ?: 0
    }
複製代碼

滾動範圍的限制比較簡單,水平方向 minScrollX ~ maxScrollX,垂直方向 minScrollY ~ maxScrollY

override fun canScrollHorizontally(direction: Int): Boolean {
        return if (direction > 0) {
            scrollX < maxScrollX
        } else {
            scrollX > minScrollX
        }
    }
    override fun canScrollVertically(direction: Int): Boolean {
        return if (direction > 0) {
            scrollY < maxScrollY
        } else {
            scrollY > minScrollY
        }
    }
複製代碼

在真正滾動時要複雜一些,因爲 JELLY_REGION_NONE 和其餘區域在滾動處理上邏輯不一樣,簡單來講就是 JELLY_REGION_NONE 時不會攔截嵌套滾動的「滾動量」,而其餘區域會攔截相應方向上的「滾動量」,所以須要按照區域進行限制。

舉例來講,內容視圖是一個橫向滾動的 View,在 JELLY_REGION_LEFT -> JELLY_REGION_RIGHT 的過程當中,左滑時先回到 JELLY_REGION_NONE,而後通過內容視圖本身滾動,滾到右邊界,再往左滑才能到 JELLY_REGION_RIGHT。而若是不按照 currRegion 進行滾到限制,就有可能直接從 JELLY_REGION_LEFT 滾到 JELLY_REGION_RIGHT,這樣內容視圖是沒有機會滾到的,會有問題,以下圖。

查看動圖

/** * 具體滾動的限制取決於當前的滾動區域 [currRegion],這裏的區域判斷分得很細,可使得一次只處理一個區域的滾動, * 不然會存在在臨界位置的一次大的滾動致使滾過了的問題。 * 具體規則: * [JELLY_REGION_LEFT] -> 只能在水平 [[minScrollX], 0] 範圍內滾動 * [JELLY_REGION_RIGHT] -> 只能在水平 [0, [maxScrollX]] 範圍內滾動 * [JELLY_REGION_TOP] -> 只能在垂直 [[minScrollY], 0] 範圍內滾動 * [JELLY_REGION_BOTTOM] -> 只能在垂直 [0, [maxScrollY]] 範圍內滾動 * [JELLY_REGION_NONE] -> 水平是在 [[minScrollX], [maxScrollX]] 範圍內,垂直在 [[minScrollY], [maxScrollY]] */
    override fun scrollTo(x: Int, y: Int) {
        val region = currRegion
        val xx = when(region) {
            JELLY_REGION_LEFT -> x.constrains(minScrollX, 0)
            JELLY_REGION_RIGHT -> x.constrains(0, maxScrollX)
            else -> x.constrains(minScrollX, maxScrollX)
        }
        val yy = when(region) {
            JELLY_REGION_TOP -> y.constrains(minScrollY, 0)
            JELLY_REGION_BOTTOM -> y.constrains(0, maxScrollY)
            else -> y.constrains(minScrollY, maxScrollY)
        }
        super.scrollTo(xx, yy)
    }

    private fun Int.constrains(min: Int, max: Int): Int = when {
        this < min -> min
        this > max -> max
        else -> this
    }
複製代碼

五、Touch 事件攔截

按照 2 中說的,此次採用嵌套滾動方式,在攔截事件時就要能不攔截就不攔截。根據觸點和滑動方向,找到對應方向能夠進行嵌套滾動的視圖 target,若是右這樣的視圖,那就不攔截事件,走以後的嵌套滾動邏輯。

水平方向的查找方法即 findHorizontalNestedScrollingTarget,深度優先遍歷,查找觸點下的、實現了 NestedScrollingChild 的、能夠水平滾動的 View,垂直方向的同理。

override fun onInterceptTouchEvent(e: MotionEvent): Boolean {
    return when (e.action) {
        // ...
        // move 時須要根據是否移動,是否有可處理對應方向移動的子 view,判斷是否要本身攔截
        MotionEvent.ACTION_MOVE -> {
            val dx = (lastX - e.x).toInt()
            val dy = (lastY - e.y).toInt()
            lastX = e.x
            lastY = e.y if (dx == 0 && dy == 0) {
                false
            } else {
                val child = findChildUnder(e.rawX, e.rawY)
                val target = if (abs(dx) > abs(dy)) {
                    child?.findHorizontalNestedScrollingTarget(e.rawX, e.rawY)
                } else {
                    child?.findVerticalNestedScrollingTarget(e.rawX, e.rawY)
                }
                target == null
            }

        }
        // ...
    }
}

fun ViewGroup.findHorizontalNestedScrollingTarget(rawX: Float, rawY: Float): View? {
    for (i in 0 until childCount) {
        val v = getChildAt(i)
        if (!v.isUnder(rawX, rawY)) {
            continue
        }
        if (v is NestedScrollingChild
            && (v.canScrollHorizontally(1)
                    || v.canScrollHorizontally(-1))) {
            return v
        }
        if (v !is ViewGroup) {
            continue
        }
        val t = v.findHorizontalNestedScrollingTarget(rawX, rawY)
        if (t != null) {
            return t
        }
    }
    return null
}
複製代碼

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

雖然主要是用嵌套滾動的方式處理,可是在內容視圖不支持滾動時,仍是須要本身處理 touch 事件的。主要邏輯時計算 x 軸和 y 軸的「滾動量」,而後就行 dispatchScroll 分發,其返回是否處理。因爲 JellyLayout 會與其餘可滾動佈局嵌套使用,在處理了「滾動量」後還須要用 requestDisallowInterceptTouchEvent(true) 請求父 View 不要攔截以後的事件。

override fun onTouchEvent(e: MotionEvent): Boolean {
        return when (e.action) {
            // ...
            // move 時判斷自身是否可以處理
            MotionEvent.ACTION_MOVE -> {
                val dx = (lastX - e.x).toInt()
                val dy = (lastY - e.y).toInt()
                lastX = e.x
                lastY = e.y if (dispatchScroll(dx, dy)) {
                    // 本身能夠處理就請求父 view 不要攔截事件
                    requestDisallowInterceptTouchEvent(true)
                    true
                } else {
                    false
                }
            }
            // ...
        }
    }
複製代碼

dispatchScroll 會根據阻尼係數 resistence,計算出各方向要處理的「滾動量」,而後根據 currRegion 決定進行水平仍是垂直方向的滾動,最後進行滾動。

/** * 分發滾動量,當滾動區域已知時,只處理對應方向上的滾動,未知時先經過滾動量肯定方向,再滾動 */
    private fun dispatchScroll(dScrollX: Int, dScrollY: Int): Boolean {
        val dx = (dScrollX / resistence).toInt()
        val dy = (dScrollY / resistence).toInt()
        if (dx == 0 && dy == 0) {
            return true
        }
        val horizontal = when (currRegion) {
            JELLY_REGION_TOP, JELLY_REGION_BOTTOM -> false
            JELLY_REGION_LEFT, JELLY_REGION_RIGHT -> true
            else -> abs(dScrollX) > abs(dScrollY)
        }
        return if (horizontal) {
            if (canScrollHorizontally(dx)) {
                scrollBy(dx, 0)
                true
            } else {
                false
            }
        } else {
            if (canScrollVertically(dy)) {
                scrollBy(0, dy)
                true
            } else {
                false
            }
        }
    }
複製代碼

七、嵌套滾動的處理

這裏實現了 NestedScrollingParent2,用它主要是由於它的接口裏增長了 NestedScrollType 註解標識的滾動的類型,取值以下,主要是用來區分滾動時來自手指滑動仍是 fling。

/** * Indicates that the input type for the gesture is from a user touching the screen. */
    public static final int TYPE_TOUCH = 0;

    /** * Indicates that the input type for the gesture is caused by something which is not a user * touching a screen. This is usually from a fling which is settling. */
    public static final int TYPE_NON_TOUCH = 1;
複製代碼

在一次嵌套滾動開始時會回調 onStartNestedScroll,須要咱們返回是否處理此次嵌套滾動。這裏和 系列的第二篇 裏介紹的 BottomSheetLayout 同樣,我不但願 fling 影響容器視圖的滾動,因此嵌套滾動也就只處理 TYPE_TOUCH 的。

override fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int): Boolean {
        // 只處理 touch 相關的滾動
        return type == ViewCompat.TYPE_TOUCH
    }
複製代碼

在子 View 發生嵌套滾動時,會先回調到咱們的 onNestedScrollAccepted,這裏也沒啥特殊處理。這裏用到了一個嵌套滾動的幫助類 NestedScrollingParentHelper,不過對於 NestedScrollingParent 來講做用不大,這裏很少贅述。

override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
        parentHelper.onNestedScrollAccepted(child, target, axes, type)
    }
複製代碼

在子 View 開始滾動前,會先回調 onNestedPreScroll,咱們能夠在這裏進行攔截,將咱們消耗掉的「滾動量」賦值給 consumed 數組的對於位置。這裏根據 currRegion 進行攔截處理,當處於水平的區域 JELLY_REGION_TOPJELLY_REGION_BOTTOM 時,咱們只處理 y 軸滾動,能處理就消耗掉,垂直方向同理,最後進行分發。

/** * 根據滾動區域和新的滾動量肯定是否消耗 target 的滾動,滾動區域和處理優先級關係: * [JELLY_REGION_TOP] 或 [JELLY_REGION_BOTTOM] -> 本身優先處理 y 軸滾動 * [JELLY_REGION_LEFT] 或 [JELLY_REGION_RIGHT] -> 本身優先處理 x 軸滾動 */
    override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
        when (currRegion) {
            JELLY_REGION_TOP, JELLY_REGION_BOTTOM -> if (canScrollVertically(dy)) {
                consumed[1] = dy
            }
            JELLY_REGION_LEFT, JELLY_REGION_RIGHT -> if (canScrollHorizontally(dx)) {
                consumed[0] = dx
            }
        }
        dispatchScroll(consumed[0], consumed[1])
    }
複製代碼

View 滾動以後會回調 onNestedScroll,參數的意思也很明確,這裏會告訴咱們子 View 消耗了多少「滾動量」,以及還有多少「滾動量」沒有消耗。對於子 View 不消耗的滾動,咱們就本身分發。

override fun onNestedScroll( target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int, type: Int ) {
        dispatchScroll(dxUnconsumed, dyUnconsumed)
    }
複製代碼

一次嵌套滾動中止後會回調 onStopNestedScroll,這裏也沒啥特殊處理,交給 NestedScrollingParentHelper

override fun onStopNestedScroll(target: View, type: Int) {
        parentHelper.onStopNestedScroll(target, type)
    }
複製代碼

這樣 onNestedPreScrollonNestedScroll 結合就實現了嵌套滾動的主要處理邏輯:

  1. 一開始 currRegionJELLY_REGION_NONE,不會在 onNestedPreScroll 裏攔截「滾動量」
  2. onNestedScroll 裏子 View 有未消耗的「滾動量」時,咱們本身滾動,露出對應方向的邊界外視圖,currRegion 改變
  3. 再次進行滾動前 onNestedPreScroll 裏就會攔截掉對應方向的「滾動量」進行分發
  4. 向相反的方向滑動,當 currRegion 回到 JELLY_REGION_NONE 後,又回到 1

八、擡手處理和平滑滾動

JellyLayout 擡手默認會有一個回彈的邏輯,若是 currRegion 不是在 JELLY_REGION_NONE、以前發生了移動、且未攔截回彈地處理 onResetListener,就平滑地滾動到初始位置 smoothScrollTo(0, 0)

override fun dispatchTouchEvent(e: MotionEvent): Boolean {
        when (e.action) {
            // ...
            // up 或 cancel 時復位到原始位置,被攔截就再也不處理
            // 在這裏處理是由於自身可能並無處理任何 touch 事件,也就不能在 onToucheEvent 中處理到 up 事件
            MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
                // 發生了移動,且不處於復位狀態,且未被攔截,則執行復位操做
                if ((lastScrollXDir != 0 || lastScrollYDir != 0)
                    && currRegion != JELLY_REGION_NONE
                    && onResetListener?.invoke(this) != true) {
                    smoothScrollTo(0, 0)
                }
            }
        }
        // ...
    }
複製代碼

onResetListener 的返回值是是否攔截回彈,做用主要是方便外部自定義的一些需求。好比拿 JellyLayout 作一個下拉刷新(固然可能還須要其餘特殊處理),下拉一段後鬆手,就停留在某個位置,刷新完彈回;好比作一個像 iOS 那樣左滑顯示「刪除」等操做。

setProcess 和回彈都用到了 smoothScrollTo,仍是利用 Scroller 來作平滑滾動,固然了手指再次放下時還須要停掉 Scroller

/** * 利用 scroller 平滑滾動 */
    private fun smoothScrollTo(x: Int, y: Int) {
        if (scrollX == x && scrollY == y) {
            return
        }
        scroller.startScroll(scrollX, scrollY, x - scrollX, y - scrollY, resetDuration)
        invalidate()
    }
    /** * 計算並滾到須要滾動到的位置 */
    override fun computeScroll() {
        if (scroller.computeScrollOffset()) {
            scrollTo(scroller.currX, scroller.currY)
            invalidate()
        }
    }
    override fun dispatchTouchEvent(e: MotionEvent): Boolean {
        when (e.action) {
            // down 時停掉 scroller 的滾動,復位滾動方向
            MotionEvent.ACTION_DOWN -> {
                scroller.abortAnimation()
                lastScrollXDir = 0
                lastScrollYDir = 0
            }
            // ...
        }
        // ...
    }
複製代碼

九、仿寫豆瓣橫向圖片列表的左滑查看更多

JellyLayout 對外暴露了區域 currRegion 和進度 currProcess,同時也有發生滾動時的回調 onScrollChangedListener,經過這些信息和一個受進度控制的自定義視圖 RightDragToOpenView 就能夠作到豆瓣的效果。代碼比較簡單,就再也不贅述了。

查看動圖

結束

嵌套滾動在處理嵌套同方向的滾動是十分高效的,和 仿寫豆瓣詳情頁(二)底部浮層仿寫豆瓣詳情頁(三)內容列表 中攔截全部事件再分發滾動相比,可以更好的處理優先級的問題,不過代碼也更加複雜一點,具體實踐中怎麼選擇還要看具體場景,能用就行。

github.com/funnywolfda…

相關文章
相關標籤/搜索