引言
在審美疲勞的當下,用戶端單向滾動的清單 UI 大家已經司空見慣了。在小宇宙 App 的某次需求中,設計師給出了一個大膽且讓人眼前一亮的清單設計方案。設計原圖如下:
看到 UI 需求的那一刻,我是頭秃的,同時也激發了我的挑戰心,下面帶着大家一起從零開始寫一個優美的廣場清單 UI。如果你感興趣如何直接使用,可移步至文末的【閱讀原文】檢視 README 詳情。
實作思路以及功能特點如下:
- 不固定個數的清單 UI,首選 RecyclerView 實作,布局使用自定義 LayoutManager
- 通過自定義 SnapHelper 實作慣性運動,以及滑動後最靠近中心位置的 Item 自動居中
- 滑動過程中,有大小變化,卡片離中心越遠越小
- 純 Kotlin 代碼實作
在講解代碼實作前,先來看看小宇宙 App 中的實作效果圖:
自定義 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 的縮放大小。
在看計算的具體實作之前,先來看看下圖的輔助說明:
上圖所示的 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)
至此,我們初步實作了一個帶縮放效果的二維矩陣清單,來看下效果:
可以看出,清單的基本繪制已經沒有問題了,但缺少了點絲滑,需要在滑動松手後繼續随着慣性運動滑一會兒,并且自動選擇滑動後最靠近中心的 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 就完成啦,看看效果:
作者:Sinyu
來源-微信公衆号:即刻技術團隊
出處:https://mp.weixin.qq.com/s/fCELffvpF09R9QHYJxeNwA