Mobile Development 16 min read

Build a Custom RecyclerView LayoutManager with Inertia and Center Snap in Kotlin

This tutorial walks through creating a fully custom RecyclerView LayoutManager and SnapHelper in Kotlin, covering layout measurement, item scaling, inertia scrolling, and automatic centering of the nearest item, complete with code snippets and visual examples for a smooth, dynamic UI list.

Jike Tech Team
Jike Tech Team
Jike Tech Team
Build a Custom RecyclerView LayoutManager with Inertia and Center Snap in Kotlin

Introduction

In the era of UI fatigue, a bold two‑dimensional scrolling list design was proposed for the XiaoYuzhou app, and this article demonstrates how to implement that design from scratch using Kotlin, RecyclerView, a custom LayoutManager, and a custom SnapHelper.

Design mockup
Design mockup

Below is the final effect in the app:

App effect
App effect

Custom LayoutManager

The custom LayoutManager is built by extending RecyclerView.LayoutManager and overriding key methods for adding child views, measuring, laying out, handling scroll, and recycling.

Override onLayoutChildren() to initialize or update layout when the adapter changes.

Use layoutDecorated(view, left, top, right, bottom) for measuring and drawing each child.

Use layoutDecoratedWithMargins() when margin calculations are needed.

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
    override fun canScrollVertically() = !verScrollLock
}

The constructor’s spanCount defines the number of items per row (default 20). generateDefaultLayoutParams() returns wrap‑content parameters. The scrolling flags control whether horizontal or vertical scrolling is allowed.

Implementation of onLayoutChildren():

override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
    if (state.itemCount == 0) {
        removeAndRecycleAllViews(recycler)
        return
    }
    onceCompleteScrollLengthForVer = -1f
    onceCompleteScrollLengthForHor = -1f
    detachAndScrapAttachedViews(recycler)
    onLayout(recycler, 0, 0)
}

Before laying out, detachAndScrapAttachedViews() detaches all child views and marks them as scrap for reuse. The core layout work is done in onLayout():

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

Scrolling offsets dx and dy represent the pixel distance of a finger swipe. The LayoutManager must implement scrollHorizontallyBy() and scrollVerticallyBy() to return the actual distance scrolled.

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
}

The onLayout() method performs four main tasks:

Calculate actual scroll distance.

Determine visible item coordinates.

Measure and draw each item.

Compute item scaling based on distance from the center.

Scaling logic (simplified):

val minScale = 0.8f
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
item.scaleX = max(min(scaleX, scaleY), minScale)
item.scaleY = max(min(scaleX, scaleY), minScale)

The resulting UI shows a matrix list where items shrink the farther they are from the center.

Scaling effect
Scaling effect

Custom SnapHelper

The built‑in SnapHelper (e.g., LinearSnapHelper, PagerSnapHelper) does not suit a two‑dimensional matrix, so a custom SquareSnapHelper is created by extending RecyclerView.OnFlingListener.

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

    override fun onFling(velocityX: Int, velocityY: Int): Boolean {
        val layoutManager = mRecyclerView?.layoutManager ?: return false
        val minFlingVelocity = mRecyclerView?.minFlingVelocity ?: return false
        return (abs(velocityY) > minFlingVelocity || abs(velocityX) > minFlingVelocity) &&
                snapFromFling(layoutManager, velocityX, velocityY)
    }

    fun attachToRecyclerView(recyclerView: RecyclerView?) {
        if (mRecyclerView === recyclerView) return
        if (mRecyclerView != null) destroyCallbacks()
        mRecyclerView = recyclerView
        recyclerView?.let {
            mGravityScroller = Scroller(it.context, DecelerateInterpolator())
            setupCallbacks()
        }
    }

    private fun setupCallbacks() {
        check(mRecyclerView?.onFlingListener == null) { "OnFlingListener already set." }
        mRecyclerView?.addOnScrollListener(mScrollListener)
        mRecyclerView?.onFlingListener = this
    }

    private fun snapFromFling(layoutManager: RecyclerView.LayoutManager, velocityX: Int, velocityY: Int): Boolean {
        if (layoutManager !is SquareLayoutManager) return false
        val targetPosition = findTargetSnapPosition(layoutManager, velocityX, velocityY)
        if (targetPosition == RecyclerView.NO_POSITION) return false
        layoutManager.smoothScrollToPosition(targetPosition)
        return true
    }

    private fun findTargetSnapPosition(layoutManager: RecyclerView.LayoutManager, velocityX: Int, velocityY: Int): Int {
        val currentView = findSnapView(layoutManager) ?: return RecyclerView.NO_POSITION
        val currentPosition = layoutManager.getPosition(currentView)
        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
        }
        hDeltaJump = if (hDeltaJump > 0) min(3, hDeltaJump) else max(-3, hDeltaJump)
        // vertical delta calculation omitted for brevity
        val deltaJump = hDeltaJump // + vDeltaJump * spanCount (omitted)
        if (deltaJump == 0) return RecyclerView.NO_POSITION
        var targetPos = currentPosition + deltaJump
        if (targetPos < 0) targetPos = 0
        if (targetPos >= itemCount) targetPos = itemCount - 1
        return targetPos
    }

    private fun estimateNextPositionDiffForFling(layoutManager: RecyclerView.LayoutManager, helper: OrientationHelper, velocityX: Int, velocityY: Int): Int {
        val distances = calculateScrollDistance(velocityX, velocityY) ?: return -1
        val distancePerChild = computeDistancePerChild(layoutManager, helper)
        if (distancePerChild <= 0) return 0
        val distance = if (abs(distances[0]) > abs(distances[1])) distances[0] else distances[1]
        return (distance / distancePerChild).roundToInt()
    }
}

The onFling() method triggers when the user lifts the finger, checks the fling velocity, and calls snapFromFling(). snapFromFling() verifies the LayoutManager type, finds the target snap position, and initiates a smooth scroll.

Target calculation uses the current center view, estimates how many items the fling will cross, clamps the result to a maximum of three items, and adjusts for list boundaries.

Finally, the smooth scroll animation is driven by a ValueAnimator that interpolates the offset over a duration proportional to the distance:

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(position)
            lastSelectedPosition = position
        }
    }
    start()
}
SnapHelper effect
SnapHelper effect

With the custom SquareLayoutManager and SquareSnapHelper, the list now supports smooth inertia scrolling and automatically centers the nearest item after a fling.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

AndroidKotlinRecyclerViewCustom UILayoutManagerSnapHelper
Jike Tech Team
Written by

Jike Tech Team

Article sharing by the Jike Tech Team

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.