From 5a5d631a8d30c04cdbc093f4fe6a871e48c54aa1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 14 Oct 2026 17:04:32 +0200 Subject: [PATCH] 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) --- .../home/impl/components/DismissibleBanner.kt | 78 ------- .../impl/components/RoomListContentView.kt | 217 ++++++++++-------- .../home/impl/roomlist/RoomListState.kt | 33 +++ .../home/impl/roomlist/RoomListViewTest.kt | 6 - 4 files changed, 153 insertions(+), 181 deletions(-) delete mode 100644 features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt deleted file mode 100644 index 1c743ec8dd..0000000000 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt +++ /dev/null @@ -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 } - } -} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt index 09dc51d68f..02288eb8b7 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt @@ -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).. + 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, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt index 6d3d85ab56..f77695399b 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt @@ -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 + } +} + diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt index e93aef71f5..eb41331202 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt @@ -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) }