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)
}
}
}
}
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
)
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
}
}
}
}
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
)
}
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
)
}
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)