DEV Community

Cover image for Implementing an animated column section header using Jetpack Compose
Thomas Künneth
Thomas Künneth

Posted on

Implementing an animated column section header using Jetpack Compose

A while ago, I was asked how I would build a temporary animated column section header in Jetpack Compose. Now, you may be thinking What on earth is that? Here's a short clip that shows the result of my efforts.

This vertically scrolling list contains ordinary items (Item #1, Item #2, Item #3, …) and section headers (Header #1, Header #2, Header #2, …). When the section of the first visible item changes, a copy of the section header is made visible, and, after a few seconds, becomes invisible again. How the animation takes place (fading in / out, flying in / out, …), can be changed easily with Jetpack Compose. Styling the elements (fonts, colors, sizes, …) is no challenge at all, too. Therefore, let's focus on

  • adding section headers
  • animating a copy of the section header for the first visible item

The source code is available as a GitHub Gist. Allow me to walk you through it step by step.

class MainActivity : ComponentActivity() {

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
      MaterialTheme {
        val list = mutableListOf<ListItem>()
        (1..10).forEach { headerIndex ->
          (1..(3..10).random()).forEach { itemIndex ->
            list.add(
              ListItem(
                header = "Header #$headerIndex",
                text = "Item #$itemIndex"
              )
            )
          }
        }
        LazyColumnDemo(list)
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The list consists of ten sections. Each section consists of a random number of items, at least three, but not more than ten. Each item is represented by a ListItem.

data class ListItem(
  val header: String, 
  val text: String
)
Enter fullscreen mode Exit fullscreen mode

As you will see shortly, this class makes it easy to determine the visibility of the temporary section header. However, it repeats the header name for items belonging to the same section, which could be considered waste of memory. But if you have no plans of showing thousands of items, I don't think this is going to be a problem.

Next, let's look at the LazyColumnDemo() composable.

@Composable
fun LazyColumnDemo(items: List<ListItem>) {
  Box(
    modifier = Modifier
      .fillMaxSize()
      .background(color = MaterialTheme.colorScheme.background)
  ) {
    val listState = rememberLazyListState()
    LazyColumn(state = listState) {
      itemsIndexed(items = items) { index, item ->
        if ((index == 0) ||
            items[index - 1].header != item.header) {
          Header(text = item.header)
        }
        Item(text = item.text)
      }
    }
    val currentHeader by remember {
      derivedStateOf {
        items[listState.firstVisibleItemIndex].header
      }
    }
    var lastHeader by remember { mutableStateOf(currentHeader) }
    val headerVisible by remember(
      currentHeader, lastHeader
    ) { mutableStateOf(lastHeader != currentHeader) }
    AnimatedVisibility(visible = headerVisible) {
      Header(text = currentHeader)
      LaunchedEffect(key1 = currentHeader) {
        delay(3000)
        lastHeader = currentHeader
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, the parent composable, a Box(), has two children: LazyColumn() and AnimatedVisibility. We want to place the temporary column section header over the list, so Box() is a natural choice, because it stacks its content. You could even use the contentAligmnent parameter to position the header. AnimatedVisibility() has two children, too: Header(), which is the column section header, and LaunchedEffect(), which toggles the visibility. Let's see how this works.

When LaunchedEffect() enters the composition it will launch the provided block into the composition's CoroutineContext. The coroutine will be cancelled and re-launched when LaunchedEffect() is recomposed with a different key1 (currentHeader). What does that mean? When the value of currentHeader changes, LaunchedEffect waits three seconds and then assigns the value of currentHeader to lastHeader. How does this change the visibility of the temporary header? AnimatedVisibility() receives a visible parameter with the value of headerVisible, which in turn is a state that changes based on two other states, currentHeader and lastHeader. So if any of them changes, headerVisible will be set to the result of the computation lastHeader != currentHeader. As after three seconds lastHeader is set to currentHeader, the temporary header will become invisible.

When does currentHeader change? It is a derived state of the expression items[listState.firstVisibleItemIndex].header: when the index of the first visible item changes, currentHeader will be automatically updated. listState is a remembered list state (rememberLazyListState()).

As we now have covered all states, let's dig into the list, in my case, a LazyColumn(). List items are created using itemsIndexed. Each item consists of one or two composables.

The first one, Header(), is shown when the very first item is displayed, or when the section header of the item to display differs from the section header of the previous item: items[index - 1].header != item.header. This code explains why I decided to put the header into my ListItem class: the comparison is beautifully simple.

@Composable
fun Header(text: String) {
  Text(
    text = text,
    modifier = Modifier
      .fillMaxWidth()
      .padding(all = 16.dp),
    style = MaterialTheme.typography.headlineSmall,
    textAlign = TextAlign.Center
  )
}
Enter fullscreen mode Exit fullscreen mode

The second one, Item(), is, well, an ordinary list item.

@Composable
fun Item(text: String) {
  Text(
    text = text,
    modifier = Modifier
      .fillMaxWidth()
      .padding(all = 16.dp),
    style = MaterialTheme.typography.bodyLarge,
    textAlign = TextAlign.Center
  )
}
Enter fullscreen mode Exit fullscreen mode

Obviously, Header() and Item() look pretty similar. The code in this article was deliberately kept simple for better understandability. Your implementation will likely style both differently.

Conclusion

Jetpack Compose makes it really easy to show and hide UI elements based on the state of a list. Just remember the list state, create a derived sate from it, and pass it to one of the animation composables.

Have you created section headers for lists in Jetpack Compose? How did this work for you? Please share your thoughts in the comments.

Top comments (0)