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.
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.
Below is the final effect in the app:
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): PointScrolling 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.
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()
}With the custom SquareLayoutManager and SquareSnapHelper, the list now supports smooth inertia scrolling and automatically centers the nearest item after a fling.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
