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:

,