From 8923ba1e1b83b6ed13f284b1022e59e9c943811c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 13 Oct 2026 10:36:13 +0200 Subject: [PATCH] Animate the dismissal of the banners in the room list Add DismissibleBanner, which makes a banner shrink and fade out when it is dismissed, and invokes the dismiss callback when the animation is over, so that the banner is removed from the list when its height is 0. Apply it to all the dismissible banners of the room list. The banners are now rendered in a slot which is always the first item of the room list, so that the rooms, which do not have a key, keep the same index and the scroll position is preserved when a banner is removed. The visible range of rooms sent to the SDK does not include this slot anymore. FullScreenIntentPermissionBanner and BatteryOptimizationBanner now take callbacks instead of their state, like the other banners. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/BatteryOptimizationBanner.kt | 13 +- .../home/impl/components/DismissibleBanner.kt | 78 +++++++++ .../FullScreenIntentPermissionBanner.kt | 15 +- .../impl/components/RoomListContentView.kt | 159 ++++++++++++------ .../home/impl/roomlist/RoomListViewTest.kt | 31 ++-- 5 files changed, 217 insertions(+), 79 deletions(-) create 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/BatteryOptimizationBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BatteryOptimizationBanner.kt index 95826665fe..3feabca8b0 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BatteryOptimizationBanner.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BatteryOptimizationBanner.kt @@ -16,13 +16,11 @@ import io.element.android.libraries.designsystem.components.Announcement import io.element.android.libraries.designsystem.components.AnnouncementType import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.push.api.battery.BatteryOptimizationEvent -import io.element.android.libraries.push.api.battery.BatteryOptimizationState -import io.element.android.libraries.push.api.battery.aBatteryOptimizationState @Composable internal fun BatteryOptimizationBanner( - state: BatteryOptimizationState, + onActionClick: () -> Unit, + onDismissClick: () -> Unit, modifier: Modifier = Modifier, ) { Announcement( @@ -31,8 +29,8 @@ internal fun BatteryOptimizationBanner( description = stringResource(R.string.banner_battery_optimization_content_android), type = AnnouncementType.Actionable( actionText = stringResource(R.string.banner_battery_optimization_submit_android), - onActionClick = { state.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations) }, - onDismissClick = { state.eventSink(BatteryOptimizationEvent.Dismiss) }, + onActionClick = onActionClick, + onDismissClick = onDismissClick, ), ) } @@ -41,6 +39,7 @@ internal fun BatteryOptimizationBanner( @Composable internal fun BatteryOptimizationBannerPreview() = ElementPreview { BatteryOptimizationBanner( - state = aBatteryOptimizationState(), + onActionClick = {}, + onDismissClick = {}, ) } 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 new file mode 100644 index 0000000000..1c743ec8dd --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt @@ -0,0 +1,78 @@ +/* + * 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/FullScreenIntentPermissionBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/FullScreenIntentPermissionBanner.kt index f2826415d2..ae5a25a623 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/FullScreenIntentPermissionBanner.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/FullScreenIntentPermissionBanner.kt @@ -16,14 +16,12 @@ import io.element.android.libraries.designsystem.components.Announcement import io.element.android.libraries.designsystem.components.AnnouncementType import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvent -import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState -import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState import io.element.android.libraries.ui.strings.CommonStrings @Composable fun FullScreenIntentPermissionBanner( - state: FullScreenIntentPermissionsState, + onActionClick: () -> Unit, + onDismissClick: () -> Unit, modifier: Modifier = Modifier ) { Announcement( @@ -31,8 +29,8 @@ fun FullScreenIntentPermissionBanner( description = stringResource(R.string.full_screen_intent_banner_message), type = AnnouncementType.Actionable( actionText = stringResource(CommonStrings.action_continue), - onDismissClick = { state.eventSink(FullScreenIntentPermissionsEvent.Dismiss) }, - onActionClick = { state.eventSink(FullScreenIntentPermissionsEvent.OpenSettings) }, + onDismissClick = onDismissClick, + onActionClick = onActionClick, ), modifier = modifier.roomListBannerPadding(), ) @@ -42,6 +40,9 @@ fun FullScreenIntentPermissionBanner( @Composable internal fun FullScreenIntentPermissionBannerPreview() { ElementPreview { - FullScreenIntentPermissionBanner(aFullScreenIntentPermissionsState()) + FullScreenIntentPermissionBanner( + onActionClick = {}, + onDismissClick = {}, + ) } } 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 68d258431d..874f4f4784 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 @@ -52,6 +52,8 @@ import io.element.android.libraries.designsystem.theme.components.HorizontalDivi import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect +import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvent +import io.element.android.libraries.push.api.battery.BatteryOptimizationEvent import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList @@ -161,16 +163,24 @@ private fun EmptyView( Box { when (state.securityBannerState) { SecurityBannerState.SetUpRecovery -> { - SetUpRecoveryKeyBanner( - onContinueClick = onSetUpRecoveryClick, - onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, - ) + DismissibleBanner( + onDismiss = { eventSink(RoomListEvent.DismissBanner) }, + ) { onDismissClick -> + SetUpRecoveryKeyBanner( + onContinueClick = onSetUpRecoveryClick, + onDismissClick = onDismissClick, + ) + } } SecurityBannerState.RecoveryKeyConfirmation -> { - ConfirmRecoveryKeyBanner( - onContinueClick = onConfirmRecoveryKeyClick, - onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, - ) + DismissibleBanner( + onDismiss = { eventSink(RoomListEvent.DismissBanner) }, + ) { onDismissClick -> + ConfirmRecoveryKeyBanner( + onContinueClick = onConfirmRecoveryKeyClick, + onDismissClick = onDismissClick, + ) + } } SecurityBannerState.None -> Unit } @@ -228,60 +238,26 @@ private fun RoomsViewList( modifier: Modifier = Modifier, ) { OnVisibleRangeChangeEffect(lazyListState) { visibleRange -> - eventSink(RoomListEvent.UpdateVisibleRange(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 - 1) + eventSink(RoomListEvent.UpdateVisibleRange(roomsRange)) } LazyColumn( state = lazyListState, modifier = modifier, contentPadding = contentPadding, ) { - when (state.securityBannerState) { - SecurityBannerState.SetUpRecovery -> { - item { - SetUpRecoveryKeyBanner( - onContinueClick = onSetUpRecoveryClick, - onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, - ) - } - } - SecurityBannerState.RecoveryKeyConfirmation -> { - item { - ConfirmRecoveryKeyBanner( - onContinueClick = onConfirmRecoveryKeyClick, - onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, - ) - } - } - // 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 -> { - item { - FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState) - } - } - state.batteryOptimizationState.shouldDisplayBanner -> { - item { - BatteryOptimizationBanner(state = state.batteryOptimizationState) - } - } - state.showNewNotificationSoundBanner -> { - item { - NewNotificationSoundBanner( - onDismissClick = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) }, - ) - } - } - state.showSwipeAvatarBanner -> { - item { - SwipeAvatarBanner( - onDismissClick = { eventSink(RoomListEvent.DismissSwipeAvatarBanner) }, - ) - } - } - } + // The banner slot is always the first item of the list, even when there is no banner to display, + // 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, + ) } - // 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. itemsIndexed( @@ -305,6 +281,79 @@ private fun RoomsViewList( } } +@Composable +private fun RoomsViewBanner( + state: RoomListContentState.Rooms, + eventSink: (RoomListEvent) -> Unit, + onSetUpRecoveryClick: () -> Unit, + onConfirmRecoveryKeyClick: () -> 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, + ) + } + } + } + } +} + @Composable private fun EmptyViewForFilterStates( selectedFilters: ImmutableList, 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 2c158d3ef9..e93aef71f5 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 @@ -51,9 +51,10 @@ class RoomListViewTest : RobolectricTest() { ) ) + // The banner is not included in the range, only the rooms are eventsRecorder.assertList( listOf( - RoomListEvent.UpdateVisibleRange(0..5), + RoomListEvent.UpdateVisibleRange(0..4), ) ) } @@ -64,15 +65,19 @@ class RoomListViewTest : RobolectricTest() { setRoomListView( state = aRoomListState( contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation), - eventSink = eventsRecorder, + // Ignore the visible range updates, which are also emitted when the banner is shrinking + eventSink = { event -> + if (event !is RoomListEvent.UpdateVisibleRange) { + eventsRecorder(event) + } + }, ) ) - // Remove automatic initial events - eventsRecorder.clear() - 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) } @@ -82,15 +87,19 @@ class RoomListViewTest : RobolectricTest() { setRoomListView( state = aRoomListState( contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery), - eventSink = eventsRecorder, + // Ignore the visible range updates, which are also emitted when the banner is shrinking + eventSink = { event -> + if (event !is RoomListEvent.UpdateVisibleRange) { + eventsRecorder(event) + } + }, ) ) - // Remove automatic initial events - eventsRecorder.clear() - 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) } @@ -100,7 +109,7 @@ class RoomListViewTest : RobolectricTest() { setRoomListView( state = aRoomListState( contentState = aRoomsContentState(showSwipeAvatarBanner = true), - // Ignore the visible range updates + // Ignore the visible range updates, which are also emitted when the banner is shrinking eventSink = { event -> if (event !is RoomListEvent.UpdateVisibleRange) { eventsRecorder(event) @@ -112,6 +121,8 @@ 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) }