ARTICLE AD BOX
I'm trying to build a notification stack UI similar to the Android system using Jetpack Compose and LazyColumn.
In my implementation, notifications from the same app are grouped. When the user taps on a group, the child items should expand with an animation, appearing from underneath the group (like they slide out). When collapsing, they should animate back into the group.
Currently, I'm struggling with animating item insertion and removal in the LazyColumn. When I add or remove items from the list (for expand/collapse), they just appear or disappear instantly without any animation.
I’ve looked into Modifier.animateItem(), but it doesn't seem to provide enough control over enter/exit animations, or I might be misunderstanding how to use it properly.
My questions:
What is the correct way to animate item insertion and removal in a LazyColumn?
How can I achieve smooth expand/collapse animations for grouped items (like a notification stack)?
Should I use AnimatedVisibility, or another approach?
Any guidance or examples would be greatly appreciated.
here is my data class
"
sealed class NotificationItem { data class Group( val id: Int, val appName: String, val notifications: List<Notification>, val isExpanded: Boolean = false ) : NotificationItem() data class Notification( val id: Int, val groupId: Int, val color: Int, val content: String = "" ) : NotificationItem() }"
GroupLayout"
@Composable fun GroupLayout( modifier: Modifier = Modifier, group: NotificationItem.Group, onExpanded: () -> Unit, onCollapse: (Int) -> Unit ) { Column( modifier = modifier ) { AnimatedVisibility( visible = group.isExpanded, enter = slideInVertically() + fadeIn(initialAlpha = 0.2f, animationSpec = tween(200)), exit = shrinkVertically( shrinkTowards = Alignment.Top, animationSpec = tween(200) ) + fadeOut( targetAlpha = 0f, animationSpec = tween(200) ) ) { Row( modifier = Modifier.animateContentSize(), verticalAlignment = Alignment.CenterVertically ) { Text(text = group.appName, color = White, modifier = Modifier.weight(1f)) Button(onClick = { onCollapse(group.id) }) { Icon( imageVector = Icons.Default.KeyboardArrowUp, contentDescription = null, tint = White ) Text(text = "Show less", color = White) } Icon( imageVector = Icons.Default.Close, contentDescription = null, tint = White ) } } if (!group.isExpanded) { Box( modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp) .height(100.dp) .clickable { if (!group.isExpanded) onExpanded() } ) { group.notifications.take(3).forEachIndexed { index, item -> NotificationLayout( notification = item, modifier = Modifier .offset(y = (index * 6).dp) .scale(1f - index * 0.05f) .zIndex((10 - index).toFloat()) ) } } } } }"
NotificationLayout @Composable fun NotificationLayout( modifier: Modifier = Modifier, notification: NotificationItem.Notification ) { Box( modifier = modifier .fillMaxWidth() .padding(8.dp) .background(color = Color(notification.color)) .height(100.dp), contentAlignment = Alignment.Center ) { Text( text = notification.content, color = White, fontSize = 16.sp ) } }"
"
here is my implementation
"
@Composable fun AppContent( modifier: Modifier = Modifier, ) { var data by remember { mutableStateOf(DataProvider.getSampleData()) } Box( modifier = modifier ) { Image( painter = painterResource(R.drawable.img), contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop ) LazyColumn( modifier = Modifier.fillMaxSize() ) { itemsIndexed( items = data, key = { _, k -> when (k) { is NotificationItem.Notification -> "notif_${k.id}" is NotificationItem.Group -> "group_${k.id}" } } ) { _, it -> when (it) { is NotificationItem.Group -> { GroupLayout( modifier = Modifier, group = it, onExpanded = { val lst = data.toMutableList() val currentIdx = lst.indexOfFirst { item -> item is NotificationItem.Group && item.id == it.id } if (currentIdx == -1) return@GroupLayout val current = lst[currentIdx] as NotificationItem.Group if (current.isExpanded) return@GroupLayout lst[currentIdx] = current.copy(isExpanded = true) lst.addAll(currentIdx + 1, current.notifications) data = lst }, onCollapse = { groupId -> val lst = data.toMutableList() val filtered = lst.filterNot { item -> item is NotificationItem.Notification && item.groupId == groupId }.toMutableList() val index = filtered.indexOfFirst { item -> item is NotificationItem.Group && item.id == groupId } if (index != -1) { val group = filtered[index] as NotificationItem.Group filtered[index] = group.copy(isExpanded = false) } data = filtered } ) } is NotificationItem.Notification -> { NotificationLayout(notification = it) } } } } } }"
