Playing with derivedstateof and sticky headers with LazyVerticalGrid
How to create sticky headers in a LazyVerticalGrid or LazyStaggeredVertical Grid using derivedStateOf

TJ Dahunsi
Dec 01 2022 · 6 mins
Updated December 2024
The approach detailed below has been wrapped into a library you can add to your project here:
.Updated August 2023
The following is not official guidance. It is a demonstration of a plausible use case
derivedStateOf
API.
State production
State production in Jetpack Compose is a very important concept. The efficiency of your state production pipeline has a direct effect on the performance of your Jetpack Compose app as each emission of state may trigger recomposition.
One way of ensuring performant state production when working with Compose state and UI logic in the UI layer is with the derivedStateOf
API. Ben Trengrove has an amazing post that goes into details about how it works and when to use it. In this post, I'm going to show a neat application of it in state production involving UI logic with regards to sticky headers for a LazyVerticalGrid.
The LazyGridState
in a LazyVerticalGrid
provides access to layout information of the grid at the last layout pass via the LazyGridState.layoutInfo
property. Within that, is information about the items currently visible, and their respective positions. Interactions like scrolling cause this state to be updated allowing for the tracking of each item on the screen, including metadata like positions and offsets. This allows us to overlay items on top (like a sticky header) and move it in response to the content being scrolled.
Smoke and mirrors
Let's start with a basic list. The list has 3 types of items that can be rendered:
Headers
Archive items
Loading indicators
sealed class ArchiveItem { abstract val query: ArchiveQuery data class Header( val text: String, override val query: ArchiveQuery, ) : ArchiveItem() data class Result( val archive: Archive, override val query: ArchiveQuery, ) : ArchiveItem() data class Loading( val isCircular: Boolean, override val query: ArchiveQuery, ) : ArchiveItem() }
Each of these can be easily rendered with the LazyVerticalGrid
:
LazyVerticalGrid( state = gridState, columns = GridCells.Adaptive(cardWidth), content = { items( items = state.items, key = { it.key }, span = { item -> when (item) { is ArchiveItem.Result -> GridItemSpan(1) is ArchiveItem.Header, is ArchiveItem.Loading -> GridItemSpan(maxLineSpan) } }, itemContent = { item -> // Compose appropriate UI for each ArchiveItem GridCell(item) } ) } )
@Composable private fun GridCell( item: ArchiveItem, onAction: (Action) -> Unit, navigate: (String) -> Unit ) { when (item) { is ArchiveItem.Header -> StickyHeader( item = item ) is ArchiveItem.Loading -> ProgressBar( isCircular = item.isCircular ) is ArchiveItem.Result -> ArchiveCard( archiveItem = item, onAction = onAction, onArchiveSelected = { archive -> navigate("archives/${archive.kind.type}/${archive.id.value}") } ) } }
This lays out each item as you'd expect; no sticky headers. However, we already know how to draw the sticky header, we have a Composable defined for it in:
@Composable fun StickyHeader( item: ArchiveItem.Header )
To make it "stick", we just have to render the sticky header Composable on top of the existing list and move it in response to scroll events of the list.
Using derivedStateOf
In the snippet below, a slot Composable layout is defined. It has slots for:
A generic content Composable
A sticky header Composable rendered over it
It also accepts an argument for a LazyGridState
. Using derivedStateOf
it:
Tracks the first completely visible item in the grid
Checks if the item is a header
Applies an offset to shift the overlaid header depending on the type of the first visible item
@Composable fun StickyHeaderGrid( modifier: Modifier = Modifier, lazyState: LazyGridState, headerMatcher: (LazyGridItemInfo) -> Boolean, stickyHeader: @Composable () -> Unit, content: @Composable () -> Unit ) { val headerOffset by remember(lazyState.layoutInfo) { derivedStateOf { val layoutInfo = lazyState.layoutInfo val startOffset = layoutInfo.viewportStartOffset val firstCompletelyVisibleItem = layoutInfo.visibleItemsInfo.firstOrNull { it.offset.y >= startOffset } ?: return@derivedStateOf 0 when (headerMatcher(firstCompletelyVisibleItem)) { false -> 0 true -> firstCompletelyVisibleItem.size .height .minus(firstCompletelyVisibleItem.offset.y) .let { difference -> if (difference < 0) 0 else -difference } } } } Box(modifier = modifier) { content() Box( modifier = Modifier.offset { IntOffset(x = 0, y = headerOffset) } ) { stickyHeader() } } }
Note how the offset is defined using derivedStateOf
. Even though the first visible item and its position may change multiple times during a scroll, there is no need to recompose the sticky header if the offset should still be zero. recomposition should only occur when the sticky header needs to move out of the way for a new sticky header coming in.
To use this, we need to be able to figure out what the sticky header should be for each rendered item in the list:
val ArchiveItem.stickyHeader: ArchiveItem.Header? get() = when (this) { is ArchiveItem.Header -> this is ArchiveItem.Result -> ArchiveItem.Header( text = headerText, query = query ) else -> null }
That is for the first ArchiveItem
in the grid, we need to render the ArchiveItem.Header
defined above.
Since it is possible for multiple items to have the same header (the header is just the month and year) we can use derivedStateOf
again to define the ArchiveItem.Header
that should be overlaid on the grid.
@Composable private fun ArchiveScreen( stateHolder: ArchiveListStateHolder, ) { val state by stateHolder.state.collectAsStateWithLifecycle() val gridState = rememberLazyGridState() … val stickyHeaderItem by remember(state.items) { derivedStateOf { val firstIndex = gridState.layoutInfo.visibleItemsInfo.firstOrNull()?.index val item = firstIndex?.let(state.items::getOrNull) item?.stickyHeader } } … }
We now have everything we need to create our lazy grid with sticky headers:
@Composable private fun ArchiveScreen( stateHolder: ArchiveListStateHolder, ) { val state by stateHolder.state.collectAsStateWithLifecycle() val gridState = rememberLazyGridState() val cardWidth = 350.dp val cardWidthPx = with(LocalDensity.current) { cardWidth.toPx() }.toInt() val stickyHeaderItem by remember(state.items) { derivedStateOf { val firstIndex = gridState.layoutInfo.visibleItemsInfo.firstOrNull()?.index val item = firstIndex?.let(state.items::getOrNull) item?.stickyHeader } } StickyHeaderGrid( lazyState = gridState, headerMatcher = { it.key.isHeaderKey }, stickyHeader = { stickyHeaderItem?.let { StickyHeader(item = it) } } ) { LazyVerticalGrid( state = gridState, columns = GridCells.Adaptive(cardWidth), content = { items( items = state.items, key = { it.key }, span = { item -> mutator.accept(Action.GridSize(maxLineSpan)) when (item) { is ArchiveItem.Result -> GridItemSpan(1) is ArchiveItem.Header, is ArchiveItem.Loading -> GridItemSpan(maxLineSpan) } }, itemContent = { item -> GridCell(item ) } ) } ) } } }
A generic solution for lazy layouts
The algorithm above can be made generic to provide sticky headers for LazyList
, LazyVerticalGrid
and LazyVerticalStaggeredGrid
.
import androidx.compose.foundation.gestures.ScrollableState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.offset import androidx.compose.foundation.lazy.LazyListItemInfo import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.grid.LazyGridItemInfo import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemInfo import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisallowComposableCalls import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.IntOffset @Composable inline fun StickyHeaderList( state: LazyListState, modifier: Modifier, crossinline headerMatcher: @DisallowComposableCalls (LazyListItemInfo) -> Boolean, stickyHeader: @Composable () -> Unit, content: @Composable () -> Unit ) { StickyHeaderLayout( lazyState = state, modifier = modifier, viewportStart = { state.layoutInfo.viewportStartOffset }, lazyItems = { state.layoutInfo.visibleItemsInfo }, lazyItemOffset = { offset }, lazyItemHeight = { size }, headerMatcher = headerMatcher, stickyHeader = stickyHeader, content = content, ) } @Composable inline fun StickyHeaderGrid( state: LazyGridState, modifier: Modifier, crossinline headerMatcher: @DisallowComposableCalls (LazyGridItemInfo) -> Boolean, stickyHeader: @Composable () -> Unit, content: @Composable () -> Unit ) { StickyHeaderLayout( lazyState = state, modifier = modifier, viewportStart = { state.layoutInfo.viewportStartOffset }, lazyItems = { state.layoutInfo.visibleItemsInfo }, lazyItemOffset = { offset.y }, lazyItemHeight = { size.height }, headerMatcher = headerMatcher, stickyHeader = stickyHeader, content = content, ) } @Composable inline fun StickyHeaderStaggeredGrid( state: LazyStaggeredGridState, modifier: Modifier, crossinline headerMatcher: @DisallowComposableCalls (LazyStaggeredGridItemInfo) -> Boolean, stickyHeader: @Composable () -> Unit, content: @Composable () -> Unit ) { StickyHeaderLayout( lazyState = state, modifier = modifier, viewportStart = { state.layoutInfo.viewportStartOffset }, lazyItems = { state.layoutInfo.visibleItemsInfo }, lazyItemOffset = { offset.y }, lazyItemHeight = { size.height }, headerMatcher = headerMatcher, stickyHeader = stickyHeader, content = content, ) } @Composable inline fun <LazyState : ScrollableState, LazyItem> StickyHeaderLayout( lazyState: LazyState, modifier: Modifier = Modifier, crossinline viewportStart: @DisallowComposableCalls (LazyState) -> Int, crossinline lazyItems: @DisallowComposableCalls (LazyState) -> List<LazyItem>, crossinline lazyItemOffset: @DisallowComposableCalls LazyItem.() -> Int, crossinline lazyItemHeight: @DisallowComposableCalls LazyItem.() -> Int, crossinline headerMatcher: @DisallowComposableCalls LazyItem.() -> Boolean, stickyHeader: @Composable () -> Unit, content: @Composable () -> Unit ) { val headerOffset by remember(lazyState) { derivedStateOf { val startOffset = viewportStart(lazyState) val visibleItems = lazyItems(lazyState) val firstCompletelyVisibleItem = visibleItems.firstOrNull { lazyItem -> lazyItemOffset(lazyItem) >= startOffset } ?: return@derivedStateOf 0 when (headerMatcher(firstCompletelyVisibleItem)) { false -> 0 true -> lazyItemHeight(firstCompletelyVisibleItem) .minus(lazyItemOffset(firstCompletelyVisibleItem)) .let { difference -> if (difference < 0) 0 else -difference } } } } Box(modifier = modifier) { content() Box( modifier = Modifier.offset { IntOffset(x = 0, y = headerOffset) } ) { stickyHeader() } } }
The sample project used to create the above can be found here: