How a Scrollbar for Compose Lazy Lists Works Under the Hood
How a Scrollbar for Compose Lazy Lists Works Under the Hood
Compose gives you smooth scrolling, but no scrollbar to go with it. A LazyColumn, LazyRow, or LazyVerticalGrid glides through its content, yet there is no thumb on the side telling you where you are, and no way to grab that thumb and fling to the middle of a long list. The old View system had this for free through the fast scroller on RecyclerView, so the absence is noticeable. The cleanest reference implementation for filling the gap lives in the Now in Android sample, and it is worth taking apart, because building a scrollbar for a lazy list turns out to be a genuinely interesting problem. A lazy list only lays out the items you can currently see. It has no idea how tall the ten thousand items below the fold are, so the usual "content height versus viewport height" math that a scrollbar relies on is simply not available.
In this article, you'll dive deep into how a lazy list scrollbar works, exploring how the thumb geometry is packed into a single value, how the thumb size and position are derived from only the visible items, how a smooth fractional index is interpolated so the thumb does not jump item by item, how the thumb is measured and placed in a custom layout, how dragging the thumb maps back into a scroll, how a press on the track scrolls the list incrementally, and how the thumb fades in and out with interaction. The code shown is the real implementation, quoted as it ships.
The fundamental problem: a scrollbar for a list that only measures what you can see
A traditional scrollbar is a ratio problem. You know the total content height and the viewport height, so the thumb size is viewport / content, and the thumb position is scrollOffset / (content - viewport). A lazy list breaks this because it never measures the whole content. At any moment it has laid out only the visible items plus a small buffer, exposed through layoutInfo.visibleItemsInfo. The items above and below are not measured, and they may not even be the same height. There is no total content height to divide by.
The way out is to stop thinking in pixels and start thinking in item indices. If a list has itemsAvailable items and the first visible item is at index firstIndex, then the thumb has traveled firstIndex / itemsAvailable of the way down, and if itemsVisible items fit on screen, the thumb should cover itemsVisible / itemsAvailable of the track. Both are fractions in the range zero to one, and both can be computed from the visible items alone. The rest of the implementation is about deriving those two fractions accurately, storing them cheaply, and turning them back into a drawn thumb and a scroll.
ScrollbarState: thumb geometry in a single Long
Everything the scrollbar needs to draw itself is two numbers: how big the thumb is as a fraction of the track, and how far it has moved as a fraction of the track. Rather than hold two Float states, the implementation packs both into one Long and exposes it through ScrollbarState:
class ScrollbarState {
private var packedValue by mutableLongStateOf(0L)
internal fun onScroll(stateValue: ScrollbarStateValue) {
packedValue = stateValue.packedValue
}
val thumbSizePercent
get() = unpackFloat1(packedValue)
val thumbMovedPercent
get() = unpackFloat2(packedValue)
val thumbTrackSizePercent
get() = 1f - thumbSizePercent
}
The two fractions are read back with unpackFloat1 and unpackFloat2, and a third value, thumbTrackSizePercent, is derived as 1f - thumbSizePercent: the fraction of the track the thumb is free to move through. The packed value is produced by a small factory that writes the two floats into one Long:
fun scrollbarStateValue(
thumbSizePercent: Float,
thumbMovedPercent: Float,
) = ScrollbarStateValue(
packFloats(
val1 = thumbSizePercent,
val2 = thumbMovedPercent,
),
)
Packing two floats into a Long is not decoration. mutableLongStateOf holds a primitive with no boxing, and updating one Long is a single atomic write, so the thumb size and position always move together and a reader never sees a half updated state where the size is new but the position is old. The ScrollbarStateValue and the internal ScrollbarTrack that appears later are both @JvmInline value class wrappers over a Long, and the stored ScrollbarState keeps its packed value in a mutableLongStateOf, so the thumb geometry updates without boxing the two floats.
Deriving the thumb from the visible items
The two fractions are computed in a scrollbarState extension on LazyListState. It watches the layout through snapshotFlow and pushes each new value into the ScrollbarState:
@Composable
fun LazyListState.scrollbarState(
itemsAvailable: Int,
itemIndex: (LazyListItemInfo) -> Int = LazyListItemInfo::index,
): ScrollbarState {
val state = remember { ScrollbarState() }
LaunchedEffect(this, itemsAvailable) {
snapshotFlow {
if (itemsAvailable == 0) return@snapshotFlow null
val visibleItemsInfo = layoutInfo.visibleItemsInfo
if (visibleItemsInfo.isEmpty()) return@snapshotFlow null
// ... compute firstIndex, itemsVisible, and the two percentages
}
.filterNotNull()
.distinctUntilChanged()
.collect { state.onScroll(it) }
}
return state
}
Reading layoutInfo inside snapshotFlow is what makes the scrollbar reactive. Every time the list scrolls, the layout snapshot changes, the block reruns, and a new ScrollbarStateValue flows out. distinctUntilChanged drops duplicate emissions so the thumb only updates when something actually moved, and filterNotNull skips the empty cases, an empty list or a list with no visible items, where there is nothing to draw.
Inside that block, the two fractions come together like this:
val firstIndex = min(
a = interpolateFirstItemIndex(
visibleItems = visibleItemsInfo,
itemSize = { it.size },
offset = { it.offset },
nextItemOnMainAxis = { first -> visibleItemsInfo.find { it != first } },
itemIndex = itemIndex,
),
b = itemsAvailable.toFloat(),
)
if (firstIndex.isNaN()) return@snapshotFlow null
val itemsVisible = visibleItemsInfo.floatSumOf { itemInfo ->
itemVisibilityPercentage(
itemSize = itemInfo.size,
itemStartOffset = itemInfo.offset,
viewportStartOffset = layoutInfo.viewportStartOffset,
viewportEndOffset = layoutInfo.viewportEndOffset,
)
}
val thumbTravelPercent = min(a = firstIndex / itemsAvailable, b = 1f)
val thumbSizePercent = min(a = itemsVisible / itemsAvailable, b = 1f)
scrollbarStateValue(
thumbSizePercent = thumbSizePercent,
thumbMovedPercent = when {
layoutInfo.reverseLayout -> 1f - thumbTravelPercent
else -> thumbTravelPercent
},
)
This article continues for subscribers
Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.
Become a Sponsor