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.LayoutManagerand 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
spanCountdefines 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
dxand
dyrepresent 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.
Custom SnapHelper
The built‑in
SnapHelper(e.g.,
LinearSnapHelper,
PagerSnapHelper) does not suit a two‑dimensional matrix, so a custom
SquareSnapHelperis 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
ValueAnimatorthat 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>With the custom
SquareLayoutManagerand
SquareSnapHelper, the list now supports smooth inertia scrolling and automatically centers the nearest item after a fling.
Jike Tech Team
Article sharing by the Jike Tech Team
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.