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 min read
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
1sealed class ArchiveItem { 2 abstract val query: ArchiveQuery 3 4 data class Header( 5 val text: String, 6 override val query: ArchiveQuery, 7 ) : ArchiveItem() 8 9 data class Result( 10 val archive: Archive, 11 override val query: ArchiveQuery, 12 ) : ArchiveItem() 13 14 data class Loading( 15 val isCircular: Boolean, 16 override val query: ArchiveQuery, 17 ) : ArchiveItem() 18}
Each of these can be easily rendered with the LazyVerticalGrid
:
1LazyVerticalGrid( 2 state = gridState, 3 columns = GridCells.Adaptive(cardWidth), 4 content = { 5 items( 6 items = state.items, 7 key = { it.key }, 8 span = { item -> 9 when (item) { 10 is ArchiveItem.Result -> GridItemSpan(1) 11 is ArchiveItem.Header, 12 is ArchiveItem.Loading -> GridItemSpan(maxLineSpan) 13 } 14 }, 15 itemContent = { item -> 16 // Compose appropriate UI for each ArchiveItem 17 GridCell(item) 18 } 19 ) 20 } 21)
1@Composable 2private fun GridCell( 3 item: ArchiveItem, 4 onAction: (Action) -> Unit, 5 navigate: (String) -> Unit 6) { 7 when (item) { 8 is ArchiveItem.Header -> StickyHeader( 9 item = item 10 ) 11 is ArchiveItem.Loading -> ProgressBar( 12 isCircular = item.isCircular 13 ) 14 is ArchiveItem.Result -> ArchiveCard( 15 archiveItem = item, 16 onAction = onAction, 17 onArchiveSelected = { archive -> 18 navigate("archives/${archive.kind.type}/${archive.id.value}") 19 } 20 ) 21 } 22}
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:
1@Composable 2fun StickyHeader( 3 item: ArchiveItem.Header 4)
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
1@Composable 2fun StickyHeaderGrid( 3 modifier: Modifier = Modifier, 4 lazyState: LazyGridState, 5 headerMatcher: (LazyGridItemInfo) -> Boolean, 6 stickyHeader: @Composable () -> Unit, 7 content: @Composable () -> Unit 8) { 9 val headerOffset by remember(lazyState.layoutInfo) { 10 derivedStateOf { 11 val layoutInfo = lazyState.layoutInfo 12 val startOffset = layoutInfo.viewportStartOffset 13 val firstCompletelyVisibleItem = layoutInfo.visibleItemsInfo.firstOrNull { 14 it.offset.y >= startOffset 15 } ?: return@derivedStateOf 0 16 17 when (headerMatcher(firstCompletelyVisibleItem)) { 18 false -> 0 19 true -> firstCompletelyVisibleItem.size 20 .height 21 .minus(firstCompletelyVisibleItem.offset.y) 22 .let { difference -> if (difference < 0) 0 else -difference } 23 } 24 } 25 } 26 27 Box(modifier = modifier) { 28 content() 29 Box( 30 modifier = Modifier.offset { IntOffset(x = 0, y = headerOffset) } 31 ) { 32 stickyHeader() 33 } 34 } 35}
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:
1val ArchiveItem.stickyHeader: ArchiveItem.Header? 2 get() = when (this) { 3 is ArchiveItem.Header -> this 4 is ArchiveItem.Result -> ArchiveItem.Header( 5 text = headerText, 6 query = query 7 ) 8 else -> null 9 }
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.
1@Composable 2private fun ArchiveScreen( 3 stateHolder: ArchiveListStateHolder, 4) { 5 val state by stateHolder.state.collectAsStateWithLifecycle() 6 val gridState = rememberLazyGridState() 7 … 8 9 val stickyHeaderItem by remember(state.items) { 10 derivedStateOf { 11 val firstIndex = gridState.layoutInfo.visibleItemsInfo.firstOrNull()?.index 12 val item = firstIndex?.let(state.items::getOrNull) 13 item?.stickyHeader 14 } 15 } 16 … 17}
We now have everything we need to create our lazy grid with sticky headers:
1@Composable 2private fun ArchiveScreen( 3 stateHolder: ArchiveListStateHolder, 4) { 5 val state by stateHolder.state.collectAsStateWithLifecycle() 6 val gridState = rememberLazyGridState() 7 val cardWidth = 350.dp 8 val cardWidthPx = with(LocalDensity.current) { cardWidth.toPx() }.toInt() 9 val stickyHeaderItem by remember(state.items) { 10 derivedStateOf { 11 val firstIndex = gridState.layoutInfo.visibleItemsInfo.firstOrNull()?.index 12 val item = firstIndex?.let(state.items::getOrNull) 13 item?.stickyHeader 14 } 15 } 16 StickyHeaderGrid( 17 lazyState = gridState, 18 headerMatcher = { it.key.isHeaderKey }, 19 stickyHeader = { 20 stickyHeaderItem?.let { StickyHeader(item = it) } 21 } 22 ) { 23 LazyVerticalGrid( 24 state = gridState, 25 columns = GridCells.Adaptive(cardWidth), 26 content = { 27 items( 28 items = state.items, 29 key = { it.key }, 30 span = { item -> 31 mutator.accept(Action.GridSize(maxLineSpan)) 32 when (item) { 33 is ArchiveItem.Result -> GridItemSpan(1) 34 is ArchiveItem.Header, 35 is ArchiveItem.Loading -> GridItemSpan(maxLineSpan) 36 } 37 }, 38 itemContent = { item -> 39 GridCell(item ) 40 } 41 ) 42 } 43 ) 44 } 45 } 46}
A generic solution for lazy layouts
The algorithm above can be made generic to provide sticky headers for LazyList
, LazyVerticalGrid
and LazyVerticalStaggeredGrid
.
1import androidx.compose.foundation.gestures.ScrollableState 2import androidx.compose.foundation.layout.Box 3import androidx.compose.foundation.layout.offset 4import androidx.compose.foundation.lazy.LazyListItemInfo 5import androidx.compose.foundation.lazy.LazyListState 6import androidx.compose.foundation.lazy.grid.LazyGridItemInfo 7import androidx.compose.foundation.lazy.grid.LazyGridState 8import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemInfo 9import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState 10import androidx.compose.runtime.Composable 11import androidx.compose.runtime.DisallowComposableCalls 12import androidx.compose.runtime.derivedStateOf 13import androidx.compose.runtime.getValue 14import androidx.compose.runtime.remember 15import androidx.compose.ui.Modifier 16import androidx.compose.ui.unit.IntOffset 17 18@Composable 19inline fun StickyHeaderList( 20 state: LazyListState, 21 modifier: Modifier, 22 crossinline headerMatcher: @DisallowComposableCalls (LazyListItemInfo) -> Boolean, 23 stickyHeader: @Composable () -> Unit, 24 content: @Composable () -> Unit 25) { 26 StickyHeaderLayout( 27 lazyState = state, 28 modifier = modifier, 29 viewportStart = { state.layoutInfo.viewportStartOffset }, 30 lazyItems = { state.layoutInfo.visibleItemsInfo }, 31 lazyItemOffset = { offset }, 32 lazyItemHeight = { size }, 33 headerMatcher = headerMatcher, 34 stickyHeader = stickyHeader, 35 content = content, 36 ) 37} 38 39@Composable 40inline fun StickyHeaderGrid( 41 state: LazyGridState, 42 modifier: Modifier, 43 crossinline headerMatcher: @DisallowComposableCalls (LazyGridItemInfo) -> Boolean, 44 stickyHeader: @Composable () -> Unit, 45 content: @Composable () -> Unit 46) { 47 StickyHeaderLayout( 48 lazyState = state, 49 modifier = modifier, 50 viewportStart = { state.layoutInfo.viewportStartOffset }, 51 lazyItems = { state.layoutInfo.visibleItemsInfo }, 52 lazyItemOffset = { offset.y }, 53 lazyItemHeight = { size.height }, 54 headerMatcher = headerMatcher, 55 stickyHeader = stickyHeader, 56 content = content, 57 ) 58} 59 60@Composable 61inline fun StickyHeaderStaggeredGrid( 62 state: LazyStaggeredGridState, 63 modifier: Modifier, 64 crossinline headerMatcher: @DisallowComposableCalls (LazyStaggeredGridItemInfo) -> Boolean, 65 stickyHeader: @Composable () -> Unit, 66 content: @Composable () -> Unit 67) { 68 StickyHeaderLayout( 69 lazyState = state, 70 modifier = modifier, 71 viewportStart = { state.layoutInfo.viewportStartOffset }, 72 lazyItems = { state.layoutInfo.visibleItemsInfo }, 73 lazyItemOffset = { offset.y }, 74 lazyItemHeight = { size.height }, 75 headerMatcher = headerMatcher, 76 stickyHeader = stickyHeader, 77 content = content, 78 ) 79} 80 81@Composable 82inline fun <LazyState : ScrollableState, LazyItem> StickyHeaderLayout( 83 lazyState: LazyState, 84 modifier: Modifier = Modifier, 85 crossinline viewportStart: @DisallowComposableCalls (LazyState) -> Int, 86 crossinline lazyItems: @DisallowComposableCalls (LazyState) -> List<LazyItem>, 87 crossinline lazyItemOffset: @DisallowComposableCalls LazyItem.() -> Int, 88 crossinline lazyItemHeight: @DisallowComposableCalls LazyItem.() -> Int, 89 crossinline headerMatcher: @DisallowComposableCalls LazyItem.() -> Boolean, 90 stickyHeader: @Composable () -> Unit, 91 content: @Composable () -> Unit 92) { 93 val headerOffset by remember(lazyState) { 94 derivedStateOf { 95 val startOffset = viewportStart(lazyState) 96 val visibleItems = lazyItems(lazyState) 97 val firstCompletelyVisibleItem = visibleItems.firstOrNull { lazyItem -> 98 lazyItemOffset(lazyItem) >= startOffset 99 } ?: return@derivedStateOf 0 100 101 when (headerMatcher(firstCompletelyVisibleItem)) { 102 false -> 0 103 true -> lazyItemHeight(firstCompletelyVisibleItem) 104 .minus(lazyItemOffset(firstCompletelyVisibleItem)) 105 .let { difference -> if (difference < 0) 0 else -difference } 106 } 107 } 108 } 109 110 Box(modifier = modifier) { 111 content() 112 Box( 113 modifier = Modifier.offset { IntOffset(x = 0, y = headerOffset) } 114 ) { 115 stickyHeader() 116 } 117 } 118}
The sample project used to create the above can be found here:
54