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.

<code>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
}
</code>

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

:

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

Before laying out,

detachAndScrapAttachedViews()

detaches all child views and marks them as scrap for reuse. The core layout work is done in

onLayout()

:

<code>fun onLayout(recycler: RecyclerView.Recycler, dx: Int, dy: Int): Point
</code>

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.

<code>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
}
</code>

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

<code>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)
</code>

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

.

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

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:

<code>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()
}
</code>
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.

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

login 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.