天天看點

小宇宙廣場清單實作SquareLayoutManager

作者:閃念基因

引言

在審美疲勞的當下,用戶端單向滾動的清單 UI 大家已經司空見慣了。在小宇宙 App 的某次需求中,設計師給出了一個大膽且讓人眼前一亮的清單設計方案。設計原圖如下:

小宇宙廣場清單實作SquareLayoutManager

看到 UI 需求的那一刻,我是頭秃的,同時也激發了我的挑戰心,下面帶着大家一起從零開始寫一個優美的廣場清單 UI。如果你感興趣如何直接使用,可移步至文末的【閱讀原文】檢視 README 詳情。

實作思路以及功能特點如下:

  • 不固定個數的清單 UI,首選 RecyclerView 實作,布局使用自定義 LayoutManager
  • 通過自定義 SnapHelper 實作慣性運動,以及滑動後最靠近中心位置的 Item 自動居中
  • 滑動過程中,有大小變化,卡片離中心越遠越小
  • 純 Kotlin 代碼實作

在講解代碼實作前,先來看看小宇宙 App 中的實作效果圖:

小宇宙廣場清單實作SquareLayoutManager

自定義 LayoutManager

自定義 LayoutManger 的主要步驟有添加子 View、測量、布局、處理滑動以及回收,這個過程中的重點就是布局,涉及到的方法如下:

  • onLayoutChildren():初始化或 Adapter 更新時上層自動調用
  • layoutDecorated(view, left, top, right, bottom):測量好各參數後,調用該方法進行布局繪制
  • layoutDecoratedWithMargins():與上面方法作用相同,但會計算 Margin 值

下面帶着大家從零開始實作一個自定義的 LayoutManager ,首先寫一個繼承自 RecyclerView.LayoutManager 的類:

class SquareLayoutManager @JvmOverloads constructor(val spanCount: Int = 20) : RecyclerView.LayoutManager() {
  private var verScrollLock = false
  private var horScrollLock = false
  
  override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {
        return RecyclerView.LayoutParams(
            RecyclerView.LayoutParams.WRAP_CONTENT,
            RecyclerView.LayoutParams.WRAP_CONTENT
        )
  }
  
  override fun canScrollHorizontally() = horScrollLock.not()

  override fun canScrollVertically() = verScrollLock.not()
}
           
  • 構造函數中的 spanCount 為一行元素的個數,預設 20
  • generateDefaultLayoutParams() 為必須實作的抽象方法,用于布局參數中攜帶自定義的屬性,這裡沒有特殊需求一般使用 WRAP_CONTENT
  • 重寫的 canScrollHorizontally() 與 canScrollVertically() 方法表示是否可以橫向或豎向滑動,這裡的需求是兩個方向都可以滑動,為了避免沖突寫了兩個 Lock 屬性進行判斷

接下來是初始化布局的實作,需要重寫 onLayoutChildren() 方法,這個方法會在初始化或者 Adapter 資料更新時調用,實作如下:

override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
    if (state.itemCount == 0) {
        removeAndRecycleAllViews(recycler)
        return
    }
    // 下面兩個屬性表示橫縱方向上 從一個 item 滑動到下一個 item 所需要經過的距離,用于起始坐标的計算
    onceCompleteScrollLengthForVer = -1f
    onceCompleteScrollLengthForHor = -1f

    detachAndScrapAttachedViews(recycler)
    // 布局核心方法
    onLayout(recycler, 0, 0)
}
           

在進行布局前,調用 detachAndScrapAttachedViews() 方法把所有的 View 先從 RecyclerView 中 detach 掉,再标記為 Scrap 狀态,表示這些 View 處于可被重用狀态。最後調用到的 onLayout(recycler, 0, 0) 方法為本次布局的重點方法,定義如下:

fun onLayout(recycler: RecyclerView.Recycler, dx: Int, dy: Int): Point
           

方法參數中的 dx 與 dy 為一次手指滑動所偏移的像素值,用于處理滑動的計算,LayoutManager 中處理手指在橫豎方向上滑動的兩個方法為:scrollHorizontallyBy() 與 scrollVerticallyBy() ,需要子類自己實作滑動的處理,子類最終傳回的值就是真實的滑動距離,實作如下:

override fun scrollHorizontallyBy(
    dx: Int,
    recycler: RecyclerView.Recycler,
    state: RecyclerView.State
): Int {
    if (dx == 0 || childCount == 0) {
        return 0
    }
    verScrollLock = true
    // 全局記錄橫坐标的移動距離
    horizontalOffset += dx
    // 滑動過程中的布局,以及傳回最終的實際滑動距離
    return onLayout(recycler, dx, 0).x
}

// ... 此處省略豎直方向的 scrollHorizontallyBy() 方法代碼,與上方橫向邏輯一緻
           

接下來,就到了布局繪制最重要的方法 onLayout(),該方法作用如下:

  • 計算實際滑動距離
  • 計算可見 Item 坐标
  • 布局測量與繪制
  • Item 縮放大小計算

其實,該方法計算的本質就是通過已知數求解未知數,這裡的已知數為螢幕寬高、Item 寬高、總的滑動距離、本次滑動距離,未知數為左上角第一個起始 Item 的坐标、目前螢幕中的所有可見 Item 的坐标以及 item 的縮放大小。

在看計算的具體實作之前,先來看看下圖的輔助說明:

小宇宙廣場清單實作SquareLayoutManager

上圖所示的 firstChildCompleteScrollLengthForHor 與 onceCompleteScrollLengthForHor 都是可直接計算得到的值,用于輔助計算可見 item 的坐标位置,下面來看看具體的計算過程:

// 計算橫坐标上第一個 item 滑動到不可見時所需要的距離
firstChildCompleteScrollLengthForHor = width / 2f + childWidth / 2f

// 當橫向的滑動距離超過第一個 item 滑動到不可見所需的距離時
if (horizontalOffset >= firstChildCompleteScrollLengthForHor) {
    horStart = 0f
    // 一次完整的卡片切換滑動所需要的距離
    onceCompleteScrollLengthForHor = childWidth.toFloat()
    // 計算橫向上第一個可見 Item 的 position
    firstVisiblePosForHor =
        floor(abs(horizontalOffset - firstChildCompleteScrollLengthForHor) / onceCompleteScrollLengthForHor.toDouble()).toInt() + 1
    // 用于校正 horStart 值
    normalViewOffsetForHor =abs(horizontalOffset - firstChildCompleteScrollLengthForHor) % onceCompleteScrollLengthForHor
} else {
    // 當橫向的第一個 item 一直可見時,該方向上的 firstVisiblePos 為 0
    firstVisiblePosForHor = 0
   // horizontalMinOffset = if (childHeight == 0) 0f else (height - childHeight) / 2f,表示橫向上未滑動時,第一個 item 的 left 值
    horStart = horizontalMinOffset
    // 計算橫向上,一次完整的卡片切換滑動所需要的移動距離,該方向上第一個 item 可見時,該值為 height / 2f + childHeight / 2f
    onceCompleteScrollLengthForHor = firstChildCompleteScrollLengthForHor
    normalViewOffsetForHor = abs(horizontalOffset) % onceCompleteScrollLengthForHor
}
           

在經過計算後,就得到了橫坐标上用于計算可見 Item 坐标的輔助參數,縱坐标上的計算邏輯與此相同,就不過多闡述了。在分别得到了橫縱坐标上第一個可見 item 的 position 值後,就可以計算出在二維矩陣上的第一個可見 item 的 position 值以及該 item 的左上角 left 與 top 坐标值,計算代碼如下:

firstVisiblePos = firstVisiblePosForVer * spanCount + firstVisiblePosForHor
// 用于校正 verStart 與 horStart 值
verStart -= normalViewOffsetForVer
horStart -= normalViewOffsetForHor
val left = horStart.toInt()
val top = verStart.toInt()
           

最後會循環周遊每一個可見 Item,并計算繪制出該 ItemView,具體實作如下:

var index = firstVisiblePos
while (index != -1) {
    val item = if (index == tempPosition && tempView != null) {
        tempView
    } else {
        recycler.getViewForPosition(index)
    }    
    val focusPositionForVer =
        (abs(verticalOffset) / childHeight).toInt()
    val focusPositionForHor =
        (abs(horizontalOffset) / childWidth).toInt()
    // 計算最靠近中心的 item position
    val focusPosition = focusPositionForVer * spanCount + focusPositionForHor

    // 判斷 addView 時的層級,不過暫時沒有重疊 Item 的情況,這裡也可以直接 addView(item)
    if (index <= focusPosition) {
        addView(item)
    } else {
        addView(item, 0)
    }
    // 測量 ItemView
    measureChildWithMargins(item, 0, 0)

    val left = horStart.toInt()
    val top = verStart.toInt()
    val right = left + getDecoratedMeasurementHorizontal(item)
    val bottom = top + getDecoratedMeasurementVertical(item)

    // ... 計算并處理縮放大小

    // 繪制 ItemView
    layoutDecoratedWithMargins(item, left, top, right, bottom)

    // ... 此處省略的代碼主要用于計算下一個可見 item 的 index 值,以及判斷是否需要終止 while 循環,感興趣可去源碼檢視
}
           

在繪制 ItemView 之前,會進行縮放處理,也就是上方代碼中的第一處省略邏輯,代碼如下:

val minScale = 0.8f
// 當 item 處于螢幕的正中心時縮放大小為1,距離中心越遠值越小
val childCenterY = (top + bottom) / 2
val parentCenterY = height / 2
val fractionScaleY = abs(parentCenterY - childCenterY) / parentCenterY.toFloat()
val scaleX = 1.0f - (1.0f - minScale) * fractionScaleY
val childCenterX = (right + left) / 2
val parentCenterX = width / 2
val fractionScaleX = abs(parentCenterX - childCenterX) / parentCenterX.toFloat()
val scaleY = 1.0f - (1.0f - minScale) * fractionScaleX
// 取較小的縮放值,但最多縮放 0.8
item.scaleX = max(min(scaleX, scaleY), minScale)
item.scaleY = max(min(scaleX, scaleY), minScale)
           

至此,我們初步實作了一個帶縮放效果的二維矩陣清單,來看下效果:

小宇宙廣場清單實作SquareLayoutManager

可以看出,清單的基本繪制已經沒有問題了,但缺少了點絲滑,需要在滑動松手後繼續随着慣性運動滑一會兒,并且自動選擇滑動後最靠近中心的 Item 停在中間。好在官方源碼中有提供類似的滑動輔助類:SnapHelper。

自定義 SnapHelper

SnapHelper 類是由官方提供的輔助類,用于 RecyclerView 滾動結束時通過慣性将某個 Item 對齊到固定位置上。官方源碼提供了兩種實作:LinearSnapHelper、PagerSnapHelper,但都不适用于上面的二維矩陣清單,是以需要仿造源碼自定義一個 SnapHelper。

首先,來看看官方的 SnapHelper 提供的三個抽象方法定義:

  • findTargetSnapPosition(LayoutManager layoutManager, int velocityX, int velocityY)
    • 根據觸發 fling 操作的速率(參數 velocityX 和 velocityY)來計算出 RecyclerView 需要滾動到的最終位置
  • findSnapView(LayoutManager layoutManager)
    • 找到目前 LayoutManager 上最靠近對齊位置的 ItemView
  • calculateDistanceToFinalSnap(LayoutManager layoutManager, View targetView)
    • 根據第二個參數對應的 ItemView 的目前坐标,計算出該坐标與需要對齊的坐标之間的距離,主要用于滑動

參照官方 SnapHelper,寫一個繼承自 RecyclerView.OnFlingListener() 的類,同時實作抽象方法 onFling() ,并實作入口方法 attachToRecyclerView(),代碼如下:

class SquareSnapHelper : RecyclerView.OnFlingListener() {
    private var mRecyclerView: RecyclerView? = null

    override fun onFling(velocityX: Int, velocityY: Int): Boolean {
        val layoutManager = mRecyclerView?.layoutManager ?: return false
        // 仿造官方實作,擷取 fling 操作需要的最小速率,隻有超過該速率,Item 才有動力滑下去
        val minFlingVelocity = mRecyclerView?.minFlingVelocity ?: return false
        // 這裡會調用snapFromFling()這個方法,就是通過該方法實作平滑滾動并使得在滾動停止時itemView對齊到目的坐标位置
        return ((abs(velocityY) > minFlingVelocity || abs(velocityX) > minFlingVelocity) && snapFromFling(
            layoutManager,
            velocityX,
            velocityY
        ))
    }
  
    // 入口方法,綁定 RecyclerView 以及初始化
    fun attachToRecyclerView(recyclerView: RecyclerView?) {
        if (mRecyclerView === recyclerView) {
            return
        }
        if (mRecyclerView != null) {
            destroyCallbacks()
        }
        mRecyclerView = recyclerView
        recyclerView?.let {
            // 建立一個 Scroller,用于輔助計算 fling 的滑動總距離
            mGravityScroller = Scroller(
                it.context,
                DecelerateInterpolator()
            )
            setupCallbacks()
        }
    }

    private fun setupCallbacks() {
        check(mRecyclerView?.onFlingListener == null) { "An instance of OnFlingListener already set." }
        // 添加 ScrollListener 監聽,用于確定停止的位置是在正确的坐标上,主要是 fling 滑動停止後稍微校正下
        mRecyclerView?.addOnScrollListener(mScrollListener)
        mRecyclerView?.onFlingListener = this
    }
}
           

其中,onFling() 方法會在滑動過程中手指擡起的那一刻被調用,用于處理後續的慣性計算與滑動,該方法最終調用到的 snapFromFling() 方法,實作了慣性計算以及滑動處理,實作如下:

private fun snapFromFling(layoutManager: RecyclerView.LayoutManager, velocityX: Int, velocityY: Int): Boolean {
    // 判斷 LayoutManager 必須是上面的 SquareLayoutManager
    if (layoutManager !is SquareLayoutManager) {
        return false
    }
    // 通過 findTargetSnapPosition() 方法,以 layoutManager 和速率作為參數,找到targetSnapPosition
    val targetPosition: Int =
        findTargetSnapPosition(layoutManager, velocityX, velocityY)
    if (targetPosition == RecyclerView.NO_POSITION) {
        return false
    }
    // 利用 SquareLayoutManager 的 smoothScrollToPosition 方法,平滑的滾動到目标位置
    layoutManager.smoothScrollToPosition(targetPosition)
    return true
}
           

可以看出,慣性滑動後的目标位置是通過 findTargetSnapPosition() 方法計算所得。到這,也就到了自定義 SnapHelper 的核心方法 findTargetSnapPosition() 的實作了,該方法用于計算滑動後的最終停止位置,代碼如下:

private fun findTargetSnapPosition(layoutManager: RecyclerView.LayoutManager, velocityX: Int, velocityY: Int): Int {
   
    // ... 省略部分無關緊要的判斷與變量初始化代碼
  
    // 找到目前最靠近中心的 item
    val currentView = findSnapView(layoutManager) ?: return RecyclerView.NO_POSITION
    val currentPosition = layoutManager.getPosition(currentView)
  
    // 估算 fling 結束時相對于目前 snapView 位置的橫向位置偏移量(機關:個數)
    var hDeltaJump =
        if (layoutManager.canScrollHorizontally() && ((abs(velocityY) - abs(velocityX)) > 4000).not()) {
            estimateNextPositionDiffForFling(
                layoutManager,
                getHorizontalHelper(layoutManager), velocityX, 0
            )
        } else {
            0
        }
    val currentHorPos = currentPosition % spanCount + 1
    // 處理二維矩陣的邊界問題
    hDeltaJump = when {
        currentHorPos + hDeltaJump >= spanCount -> {
            abs(spanCount - currentHorPos)
        }
        currentHorPos + hDeltaJump <= 0 -> {
            -(currentHorPos - 1)
        }
        else -> {
            hDeltaJump
        }
    }
    // 校正,最多滑動三個 item
    hDeltaJump = if (hDeltaJump > 0) min(3, hDeltaJump) else max(-3, hDeltaJump)
  
    // ... 此處省略估算豎向偏移量 hDeltaJump 的計算代碼,與上方橫向邏輯一緻

    // 相對于目前最中心的 item,最終需要偏移的 item 個數
    val deltaJump = hDeltaJump + vDeltaJump * spanCount

    if (deltaJump == 0) {
        return RecyclerView.NO_POSITION
    }
    // 得到最終目标的 position
    var targetPos = currentPosition + deltaJump

    if (targetPos < 0) {
        targetPos = 0
    }
    if (targetPos >= itemCount) {
        targetPos = itemCount - 1
    }
    return targetPos
}
           

findTargetSnapPosition() 首先會找到目前最靠近螢幕中心的 SnapView,再通過 estimateNextPositionDiffForFling() 相關方法估算出慣性滑動後的位置偏移量,最後通過 SnapView 的 position 加上位置偏移量,得出最終滾動結束時的位置。其中 estimateNextPositionDiffForFling() 方法實作如下:

private fun estimateNextPositionDiffForFling(
    layoutManager: RecyclerView.LayoutManager,
    helper: OrientationHelper, velocityX: Int, velocityY: Int
): Int {
    // 計算滾動的總距離,這個距離受到觸發 fling 時的速度的影響
    // calculateScrollDistance() 方法主要是用 mGravityScroller 的 fling() 方法模拟慣性滑動
    val distances = calculateScrollDistance(velocityX, velocityY) ?: return -1
    // 計算每個 item 的高度或寬度
    val distancePerChild = computeDistancePerChild(layoutManager, helper)
    if (distancePerChild <= 0) {
        return 0
    }
    // 判斷是橫向布局還是縱向布局,來取對應布局方向上的滾動距離
    val distance =
        if (abs(distances[0]) > abs(distances[1])) distances[0] else distances[1]
    // 滾動距離 / item的長度 = 滾動 item 的個數,這裡取計算結果的整數部分
    return (distance / distancePerChild).roundToInt()
}
           

在計算出最終停留位置後,需要進行滑動處理,回到上面 snapFromFling() 方法的最後,滑動處理是通過剛剛的自定義 SquareLayoutManager 的 smoothScrollToPosition() 方法實作的,下面我們來看看該動畫的核心實作代碼:

ValueAnimator.ofFloat(0.0f, duration.toFloat()).apply {
    // 根據滑動距離計算動畫運動時長,具體計算代碼可檢視源碼
    this.duration = max(durationForVer, durationForHor)
    // 動畫使用減速插值器
    interpolator = DecelerateInterpolator()

    val startedOffsetForVer = verticalOffset.toFloat()
    val startedOffsetForHor = horizontalOffset.toFloat()
    // 運動過程中重新整理畫面
    addUpdateListener { animation ->
        val value = animation.animatedValue as Float
        verticalOffset =
            (startedOffsetForVer + value * (distanceForVer / duration.toFloat())).toLong()
        horizontalOffset =
            (startedOffsetForHor + value * (distanceForHor / duration.toFloat())).toLong()

        requestLayout()
    }
    doOnEnd {
        if (lastSelectedPosition != position) {
            // 回調 onItemSelectedListener
            onItemSelectedListener(position)
            lastSelectedPosition = position
        }
    }
    start()
}
           

可以看到, SquareLayoutManager 中的動畫滑動邏輯還是挺簡單的,首先根據距離算出運動時長,再使用減速插值器做屬性動畫。

以上就完成了一個慣性滑動的輔助類 SquareSnapHelper 了,再使用到自定義的 SquareLayoutManager 中,主要是重寫 LayoutManager 的 onAttachedToWindow() 方法,代碼如下:

override fun onAttachedToWindow(view: RecyclerView?) {
    super.onAttachedToWindow(view)
    SquareSnapHelper().attachToRecyclerView(view)
}
           

至此,一個帶慣性滑動的自定義 LayoutManager 就完成啦,看看效果:

小宇宙廣場清單實作SquareLayoutManager

作者:Sinyu

來源-微信公衆号:即刻技術團隊

出處:https://mp.weixin.qq.com/s/fCELffvpF09R9QHYJxeNwA

繼續閱讀