laitimes

The small universe square list implements SquareLayoutManager

author:Flash Gene

introduction

In the current era of aesthetic fatigue, one-way scrolling list UIs on the client side have become commonplace. In a request for a small universe app, the designer came up with a bold and eye-catching list design solution. The original design drawings are as follows:

The small universe square list implements SquareLayoutManager

The moment I saw the UI requirements, I was bald, and at the same time, it also inspired my challenge, so let's take everyone to write a beautiful square list UI from scratch. If you are interested in how to use it directly, you can go to [Read the original article] at the end of the article to view the README details.

The implementation ideas and functional characteristics are as follows:

  • A list UI with no fixed number, the RecyclerView implementation is preferred, and the layout uses a custom LayoutManager
  • Customize the SnapHelper for inertial motion and automatically center the item closest to the center after swiping
  • During the swiping process, there is a change in size, and the farther the card is from the center, the smaller it becomes
  • Pure Kotlin code implementation

Before explaining the code implementation, let's take a look at the implementation diagram in the Small Universe App:

The small universe square list implements SquareLayoutManager

Customize the LayoutManager

The main steps of customizing a LayoutManger are adding sub-views, measuring, layout, handling sliding, and recycling, and the focus in this process is the layout, which involves the following methods:

  • onLayoutChildren():初始化或 Adapter 更新时上层自动调用
  • layoutDecorated(view, left, top, right, bottom):测量好各参数后,调用该方法进行布局绘制
  • layoutDecoratedWithMargins():与上面方法作用相同,但会计算 Margin 值

Let's take you to implement a custom LayoutManager from scratch, first write a class that inherits from 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()
}
           
  • The spanCount in the constructor is the number of elements in a row, with the default value of 20
  • generateDefaultLayoutParams() is an abstract method that must be implemented to carry custom properties in the layout parameters, and WRAP_CONTENT is generally used here without special requirements
  • The overridden canScrollHorizontally() and canScrollVertically() methods indicate whether you can slide horizontally or vertically, and the requirement here is that you can slide in both directions, and two Lock properties are written to avoid conflicts

The next step is to initialize the layout implementation, which needs to override the onLayoutChildren() method, which will be called when the initialization or Adapter data is updated, as follows:

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)
}
           

Before layout, call the detachAndScrapAttachedViews() method to detach all views from the RecyclerView and then mark them as scrap, indicating that the views are in a reusable state. The last called onLayout(recycler, 0, 0) method is the key method of this layout, which is defined as follows:

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

The dx and dy in the method parameters are the pixel values offset by a finger slide, which are used to process the calculation of the slide, and the two methods in the LayoutManager to deal with the finger sliding in the horizontal and vertical directions are: scrollHorizontallyBy() and scrollVerticallyBy(), which need to be handled by the subclass itself, and the final value returned by the subclass is the real sliding distance, which is implemented as follows:

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() 方法代码,与上方横向逻辑一致
           

Next, it's time for the most important method of layout, onLayout(), which does the following:

  • Calculate the actual sliding distance
  • Calculate the visible item coordinates
  • Layout measurement and drawing
  • Item scale size calculation

In fact, the essence of the calculation of this method is to solve the unknown through the known number, where the known number is the screen width and height, the width and height of the item, the total sliding distance, and the sliding distance, and the unknown is the coordinates of the first starting item in the upper left corner, the coordinates of all visible items in the current screen, and the scale size of the item.

Before we look at the specific implementation of the calculation, let's take a look at the auxiliary description of the following figure:

The small universe square list implements SquareLayoutManager

The firstChildCompleteScrollLengthForHor and onceCompleteScrollLengthForHor shown in the figure above are both directly calculated values that can be used to assist in calculating the coordinate position of visible items.

// 计算横坐标上第一个 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
}
           

After the calculation, the auxiliary parameters used to calculate the visible item coordinates on the abscissa are obtained, and the calculation logic on the ordinate is the same, so I will not elaborate too much. After obtaining the position value of the first visible item on the horizontal ordinate, the position value of the first visible item on the 2D matrix and the left and top coordinate values of the upper left corner of the item can be calculated, and the calculation code is as follows:

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

Finally, it loops through each visible item and calculates and plots the ItemView, as follows:

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 循环,感兴趣可去源码查看
}
           

Before the ItemView is drawn, it is scaled, which is the first omission logic in the code above, which is as follows:

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)
           

At this point, we have preliminarily implemented a list of 2D matrices with a zoom effect, let's take a look at the effect:

The small universe square list implements SquareLayoutManager

As you can see, the basic drawing of the list is no longer a problem, but it lacks a bit of silkiness, and you need to continue to slide with the inertial motion for a while after the slide is released, and the item closest to the center after the slide is automatically selected to stop in the middle. Fortunately, there is a similar sliding assistance class in the official source code: SnapHelper.

自定义 SnapHelper

The SnapHelper class is an official helper class that is used to align an item to a fixed position by inertia at the end of the RecyclerView scroll. The official source code provides two implementations: LinearSnapHelper and PagerSnapHelper, but neither of them works with the above two-dimensional matrix list, so you need to imitate the source code to customize a SnapHelper.

First, let's take a look at the three abstract method definitions provided by the official SnapHelper:

  • findTargetSnapPosition(LayoutManager layoutManager, int velocityX, int velocityY)
    • Based on the rate at which the fling operation is triggered (parameters velocityX and velocityY), the final position to which the RecyclerView needs to scroll is calculated
  • findSnapView(LayoutManager layoutManager)
    • Locate the ItemView closest to the alignment position on the current LayoutManager
  • calculateDistanceToFinalSnap(LayoutManager layoutManager, View targetView)
    • Based on the current coordinates of the ItemView corresponding to the second parameter, the distance between that coordinate and the coordinates that need to be aligned is calculated, mainly for sliding

参照官方 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
    }
}
           

Among them, the onFling() method will be called at the moment when the finger is raised during the sliding process, which is used to process the subsequent inertial calculation and sliding, and the snapFromFling() method finally called by the method implements the inertial calculation and sliding processing, which is as follows:

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
}
           

As you can see, the target position after inertia sliding is calculated using the findTargetSnapPosition() method. This brings us to the implementation of the core method of the custom SnapHelper, findTargetSnapPosition(), which is used to calculate the final stop position after sliding, as follows:

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()
}
           

After calculating the final dwell position, you need to do the sliding process, going back to the last of the snapFromFling() method above, the sliding process is implemented by the smoothScrollToPosition() method of the custom SquareLayoutManager just now, let's take a look at the core implementation code of the animation:

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()
}
           

As you can see, the animation sliding logic in SquareLayoutManager is quite simple, first calculate the motion duration according to the distance, and then use the deceleration interpolator to animate the properties.

The above is completed with an inertial sliding helper class SquareSnapHelper, and then used in the custom SquareLayoutManager, mainly to rewrite the onAttachedToWindow() method of the LayoutManager, the code is as follows:

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

At this point, a custom LayoutManager with inertial swiping is complete, and see what it looks like:

The small universe square list implements SquareLayoutManager

Author: Sinyu

Source-WeChat public account: Instant technical team

Source: https://mp.weixin.qq.com/s/fCELffvpF09R9QHYJxeNwA

Read on