Adetunji Dahunsi

Playing with derivedStateOf and sticky headers with LazyVerticalGrid

A UI logic application of derivedStateOf

TJ Dahunsi

Dec 01 2022 · 5 min read

The following is not a reccomendation, official guidance or production level code. It is a demonstration of a plausible use case derivedStateOf API.

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() 78 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 } 1617}

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}

The sample project used to create the above can be found here:

2