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) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-10-13 10:36:13 +02:00
parent 14710599a7
commit 8923ba1e1b
5 changed files with 217 additions and 79 deletions
@@ -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 = {},
)
}
@@ -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 }
}
}
@@ -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 = {},
)
}
}
@@ -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<RoomListFilter>,
@@ -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)
}