Animate all the banner changes of the room list in a single slot

Replace DismissibleBanner, which used one AnimatedVisibility per banner, by RoomListBannerSlot,
which renders the banner to display with a single AnimatedContent. All the changes are now
animated: dismissing a banner, replacing a banner by the next one, and displaying a new banner.
Previously, when a banner was dismissed, the next banner was displayed without animation.
The dismiss event is now sent immediately, AnimatedContent keeps rendering the dismissed banner
while it animates out.

The banner slot of the LazyColumn now has a min height of 1px. When it was 0px high, the
LazyColumn was considering it as not visible and moved the first visible item to the first room,
so a new banner was added above the visible area of the list.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-10-14 17:04:32 +02:00
parent f187409db3
commit 5a5d631a8d
4 changed files with 153 additions and 181 deletions
@@ -1,78 +0,0 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import kotlinx.coroutines.flow.first
/**
* Wraps a banner of the room list so that it shrinks and fades out when it is dismissed.
* [onDismiss] is invoked once the animation is over, so that the banner is removed from the list
* when its height is 0, and the items below it are not moved abruptly.
*
* @param onDismiss invoked when the banner has been dismissed and the animation is over.
* @param modifier the modifier to apply to the banner container.
* @param content the banner, which must invoke the provided lambda when the user dismisses it.
*/
@Composable
internal fun DismissibleBanner(
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable (onDismissClick: () -> Unit) -> Unit,
) {
val visibleState = remember { MutableTransitionState(true) }
val latestOnDismiss by rememberUpdatedState(onDismiss)
var isDismissNotified by remember { mutableStateOf(false) }
fun notifyDismiss() {
if (!isDismissNotified) {
isDismissNotified = true
latestOnDismiss()
}
}
LaunchedEffect(visibleState) {
snapshotFlow { visibleState.isIdle && !visibleState.currentState }.first { it }
notifyDismiss()
}
DisposableEffect(visibleState) {
onDispose {
// The item can be disposed during the animation, for instance if the user scrolls the list
if (!visibleState.targetState) {
notifyDismiss()
}
}
}
AnimatedVisibility(
visibleState = visibleState,
modifier = modifier,
enter = EnterTransition.None,
exit = shrinkVertically(
animationSpec = tween(),
shrinkTowards = Alignment.CenterVertically,
) + fadeOut(
animationSpec = tween(),
),
) {
content { visibleState.targetState = false }
}
}
@@ -8,6 +8,12 @@
package io.element.android.features.home.impl.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -15,7 +21,9 @@ import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
@@ -24,6 +32,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -39,10 +48,11 @@ import io.element.android.features.home.impl.filters.aRoomListFiltersState
import io.element.android.features.home.impl.filters.selection.FilterSelectionState
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.model.RoomSummaryDisplayType
import io.element.android.features.home.impl.roomlist.RoomListBanner
import io.element.android.features.home.impl.roomlist.RoomListContentState
import io.element.android.features.home.impl.roomlist.RoomListContentStatePreviewParam
import io.element.android.features.home.impl.roomlist.RoomListEvent
import io.element.android.features.home.impl.roomlist.SecurityBannerState
import io.element.android.features.home.impl.roomlist.toRoomListBanner
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
import io.element.android.features.home.impl.spacefilters.anUnselectedSpaceFiltersState
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -160,30 +170,20 @@ private fun EmptyView(
},
modifier = Modifier.align(Alignment.Center),
)
Box {
when (state.securityBannerState) {
SecurityBannerState.SetUpRecovery -> {
DismissibleBanner(
onDismiss = { eventSink(RoomListEvent.DismissBanner) },
) { onDismissClick ->
SetUpRecoveryKeyBanner(
onContinueClick = onSetUpRecoveryClick,
onDismissClick = onDismissClick,
)
RoomListBannerContainer(
banner = state.securityBannerState.toRoomListBanner(),
) { banner ->
RoomListBannerView(
banner = banner,
onActionClick = {
when (banner) {
RoomListBanner.SetUpRecovery -> onSetUpRecoveryClick()
RoomListBanner.RecoveryKeyConfirmation -> onConfirmRecoveryKeyClick()
else -> Unit
}
}
SecurityBannerState.RecoveryKeyConfirmation -> {
DismissibleBanner(
onDismiss = { eventSink(RoomListEvent.DismissBanner) },
) { onDismissClick ->
ConfirmRecoveryKeyBanner(
onContinueClick = onConfirmRecoveryKeyClick,
onDismissClick = onDismissClick,
)
}
}
SecurityBannerState.None -> Unit
}
},
onDismissClick = { eventSink(RoomListEvent.DismissBanner) },
)
}
}
}
@@ -237,6 +237,10 @@ private fun RoomsViewList(
lazyListState: LazyListState,
modifier: Modifier = Modifier,
) {
val banner = state.toRoomListBanner()
// The banner slot must never be 0px high, else the LazyColumn considers that it is not visible and it is
// removed from the composition. A new banner would then be added above the visible area of the list.
val bannerSlotMinHeight = with(LocalDensity.current) { 1.toDp() }
OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
// The first item of the list is the banner slot, remove it to get the range of the rooms
val roomsRange = (visibleRange.first - 1).coerceAtLeast(0)..<visibleRange.last
@@ -251,12 +255,37 @@ private fun RoomsViewList(
// so that the rooms keep the same index when a banner is added or removed. Since the rooms do not
// have a key, this is required to keep the scroll position.
item {
RoomsViewBanner(
state = state,
eventSink = eventSink,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
)
RoomListBannerContainer(
banner = banner,
modifier = Modifier.heightIn(min = bannerSlotMinHeight),
) { displayedBanner ->
RoomListBannerView(
banner = displayedBanner,
onActionClick = {
when (displayedBanner) {
RoomListBanner.SetUpRecovery -> onSetUpRecoveryClick()
RoomListBanner.RecoveryKeyConfirmation -> onConfirmRecoveryKeyClick()
RoomListBanner.FullScreenIntentPermission ->
state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.OpenSettings)
RoomListBanner.BatteryOptimization ->
state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations)
RoomListBanner.NewNotificationSound,
RoomListBanner.SwipeAvatar -> Unit
}
},
onDismissClick = {
when (displayedBanner) {
RoomListBanner.SetUpRecovery,
RoomListBanner.RecoveryKeyConfirmation -> eventSink(RoomListEvent.DismissBanner)
RoomListBanner.FullScreenIntentPermission ->
state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.Dismiss)
RoomListBanner.BatteryOptimization -> state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.Dismiss)
RoomListBanner.NewNotificationSound -> eventSink(RoomListEvent.DismissNewNotificationSoundBanner)
RoomListBanner.SwipeAvatar -> eventSink(RoomListEvent.DismissSwipeAvatarBanner)
}
},
)
}
}
// Note: do not use a key for the LazyColumn, or the scroll will not behave as expected if a room
// is moved to the top of the list.
@@ -281,79 +310,73 @@ private fun RoomsViewList(
}
}
/**
* Displays the [banner] at the top of the room list, and animates every change: when the banner is
* dismissed, when it is replaced by another banner, and when a banner appears.
* The size of the slot is animated, so that the items below it are not moved abruptly.
*
* @param banner the banner to display, or null if there is no banner to display.
* @param modifier the modifier to apply to the slot.
* @param content renders the given banner.
*/
@Composable
private fun RoomsViewBanner(
state: RoomListContentState.Rooms,
eventSink: (RoomListEvent) -> Unit,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
private fun RoomListBannerContainer(
banner: RoomListBanner?,
modifier: Modifier = Modifier,
content: @Composable (RoomListBanner) -> Unit,
) {
when (state.securityBannerState) {
SecurityBannerState.SetUpRecovery -> {
DismissibleBanner(
onDismiss = { eventSink(RoomListEvent.DismissBanner) },
) { onDismissClick ->
SetUpRecoveryKeyBanner(
onContinueClick = onSetUpRecoveryClick,
onDismissClick = onDismissClick,
)
}
}
SecurityBannerState.RecoveryKeyConfirmation -> {
DismissibleBanner(
onDismiss = { eventSink(RoomListEvent.DismissBanner) },
) { onDismissClick ->
ConfirmRecoveryKeyBanner(
onContinueClick = onConfirmRecoveryKeyClick,
onDismissClick = onDismissClick,
)
}
}
// Banner precedence (top-to-bottom): full-screen-intent > battery-optimization >
// new-notification-sound > swipe-avatar > sound-unavailable. At most one renders at a time.
SecurityBannerState.None -> when {
state.fullScreenIntentPermissionsState.shouldDisplayBanner -> {
DismissibleBanner(
onDismiss = { state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.Dismiss) },
) { onDismissClick ->
FullScreenIntentPermissionBanner(
onActionClick = { state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.OpenSettings) },
onDismissClick = onDismissClick,
)
}
}
state.batteryOptimizationState.shouldDisplayBanner -> {
DismissibleBanner(
onDismiss = { state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.Dismiss) },
) { onDismissClick ->
BatteryOptimizationBanner(
onActionClick = { state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations) },
onDismissClick = onDismissClick,
)
}
}
state.showNewNotificationSoundBanner -> {
DismissibleBanner(
onDismiss = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) },
) { onDismissClick ->
NewNotificationSoundBanner(
onDismissClick = onDismissClick,
)
}
}
state.showSwipeAvatarBanner -> {
DismissibleBanner(
onDismiss = { eventSink(RoomListEvent.DismissSwipeAvatarBanner) },
) { onDismissClick ->
SwipeAvatarBanner(
onDismissClick = onDismissClick,
)
}
}
AnimatedContent(
targetState = banner,
// Fill the width so that only the height is animated
modifier = modifier.fillMaxWidth(),
transitionSpec = {
fadeIn(tween(durationMillis = HALF_DURATION_MILLIS, delayMillis = HALF_DURATION_MILLIS)) togetherWith
fadeOut(tween(durationMillis = HALF_DURATION_MILLIS)) using
SizeTransform { _, _ -> tween(durationMillis = 2 * HALF_DURATION_MILLIS) }
},
contentAlignment = Alignment.Center,
label = "RoomListBannerContainer",
) { targetBanner ->
if (targetBanner != null) {
content(targetBanner)
}
}
}
private const val HALF_DURATION_MILLIS = 150
@Composable
private fun RoomListBannerView(
banner: RoomListBanner,
onActionClick: () -> Unit,
onDismissClick: () -> Unit,
) {
when (banner) {
RoomListBanner.SetUpRecovery -> SetUpRecoveryKeyBanner(
onContinueClick = onActionClick,
onDismissClick = onDismissClick,
)
RoomListBanner.RecoveryKeyConfirmation -> ConfirmRecoveryKeyBanner(
onContinueClick = onActionClick,
onDismissClick = onDismissClick,
)
RoomListBanner.FullScreenIntentPermission -> FullScreenIntentPermissionBanner(
onActionClick = onActionClick,
onDismissClick = onDismissClick,
)
RoomListBanner.BatteryOptimization -> BatteryOptimizationBanner(
onActionClick = onActionClick,
onDismissClick = onDismissClick,
)
RoomListBanner.NewNotificationSound -> NewNotificationSoundBanner(
onDismissClick = onDismissClick,
)
RoomListBanner.SwipeAvatar -> SwipeAvatarBanner(
onDismissClick = onDismissClick,
)
}
}
@Composable
private fun EmptyViewForFilterStates(
selectedFilters: ImmutableList<RoomListFilter>,
@@ -61,6 +61,18 @@ enum class SecurityBannerState {
RecoveryKeyConfirmation,
}
/**
* The banners which can be displayed at the top of the room list. At most one banner is displayed at a time.
*/
internal enum class RoomListBanner {
SetUpRecovery,
RecoveryKeyConfirmation,
FullScreenIntentPermission,
BatteryOptimization,
NewNotificationSound,
SwipeAvatar,
}
@Immutable
sealed interface RoomListContentState {
data class Skeleton(val count: Int) : RoomListContentState
@@ -80,3 +92,24 @@ sealed interface RoomListContentState {
val showAllActivity: Boolean,
) : RoomListContentState
}
internal fun SecurityBannerState.toRoomListBanner(): RoomListBanner? = when (this) {
SecurityBannerState.SetUpRecovery -> RoomListBanner.SetUpRecovery
SecurityBannerState.RecoveryKeyConfirmation -> RoomListBanner.RecoveryKeyConfirmation
SecurityBannerState.None -> null
}
/**
* Banner precedence (top-to-bottom): security > full-screen-intent > battery-optimization >
* new-notification-sound > swipe-avatar. At most one renders at a time.
*/
internal fun RoomListContentState.Rooms.toRoomListBanner(): RoomListBanner? {
return securityBannerState.toRoomListBanner() ?: when {
fullScreenIntentPermissionsState.shouldDisplayBanner -> RoomListBanner.FullScreenIntentPermission
batteryOptimizationState.shouldDisplayBanner -> RoomListBanner.BatteryOptimization
showNewNotificationSoundBanner -> RoomListBanner.NewNotificationSound
showSwipeAvatarBanner -> RoomListBanner.SwipeAvatar
else -> null
}
}
@@ -76,8 +76,6 @@ class RoomListViewTest : RobolectricTest() {
val close = activity!!.getString(CommonStrings.action_close)
onNodeWithContentDescription(close).performClick()
// The event is emitted at the end of the animation
waitForIdle()
eventsRecorder.assertSingle(RoomListEvent.DismissBanner)
}
@@ -98,8 +96,6 @@ class RoomListViewTest : RobolectricTest() {
val close = activity!!.getString(CommonStrings.action_close)
onNodeWithContentDescription(close).performClick()
// The event is emitted at the end of the animation
waitForIdle()
eventsRecorder.assertSingle(RoomListEvent.DismissBanner)
}
@@ -121,8 +117,6 @@ class RoomListViewTest : RobolectricTest() {
eventsRecorder.assertEmpty()
val close = activity!!.getString(CommonStrings.action_close)
onNodeWithContentDescription(close).performClick()
// The event is emitted at the end of the animation
waitForIdle()
eventsRecorder.assertSingle(RoomListEvent.DismissSwipeAvatarBanner)
}