A scroll offset changes on every drag delta. Whether a "scroll to top" button should be visible changes maybe twice in a whole fling. Read the fast value directly in composition and you pay full recomposition price on every pixel, even though the UI only cares about the coarse yes-or-no answer you derived from it.
#The old way
@Composable
fun ScrollToTopButton(listState: LazyListState) {
val showButton = listState.firstVisibleItemIndex > 0 ||
listState.firstVisibleItemScrollOffset > 0
if (showButton) {
FloatingActionButton(onClick = { }) {
Icon(Icons.Default.ArrowUpward, contentDescription = "Scroll to top")
}
}
}
firstVisibleItemIndex and firstVisibleItemScrollOffset both change on almost every scroll frame. ScrollToTopButton recomposes right along with them, whether or not the button's visibility flips.
#The new way
@Composable
fun ScrollToTopButton(listState: LazyListState) {
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 ||
listState.firstVisibleItemScrollOffset > 0
}
}
if (showButton) {
FloatingActionButton(onClick = { }) {
Icon(Icons.Default.ArrowUpward, contentDescription = "Scroll to top")
}
}
}
#Why it matters
- Recomposition is scoped to state reads;
derivedStateOfcollapses two high-frequency reads into one low-frequency boolean read. - Downstream composables (icons, animations tied to
showButton) stop re-running every frame during a fling. - Composition stays cheap even on long lists with heavy scroll velocity.
#Gotcha
derivedStateOf adds its own snapshot-observation overhead. Derive offset * 2 from offset and the derived value changes exactly as often as the input, so you've bought a layer of indirection and saved nothing. Reach for it only when the derived value genuinely changes less often than the state it reads.