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 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
@@ -24,6 +32,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter 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.filters.selection.FilterSelectionState
import io.element.android.features.home.impl.model.RoomListRoomSummary 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.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.RoomListContentState
import io.element.android.features.home.impl.roomlist.RoomListContentStatePreviewParam 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.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.SpaceFiltersState
import io.element.android.features.home.impl.spacefilters.anUnselectedSpaceFiltersState import io.element.android.features.home.impl.spacefilters.anUnselectedSpaceFiltersState
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -160,32 +170,22 @@ private fun EmptyView(
}, },
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
) )
Box { RoomListBannerContainer(
when (state.securityBannerState) { banner = state.securityBannerState.toRoomListBanner(),
SecurityBannerState.SetUpRecovery -> { ) { banner ->
DismissibleBanner( RoomListBannerView(
onDismiss = { eventSink(RoomListEvent.DismissBanner) }, banner = banner,
) { onDismissClick -> onActionClick = {
SetUpRecoveryKeyBanner( when (banner) {
onContinueClick = onSetUpRecoveryClick, RoomListBanner.SetUpRecovery -> onSetUpRecoveryClick()
onDismissClick = onDismissClick, RoomListBanner.RecoveryKeyConfirmation -> onConfirmRecoveryKeyClick()
else -> Unit
}
},
onDismissClick = { eventSink(RoomListEvent.DismissBanner) },
) )
} }
} }
SecurityBannerState.RecoveryKeyConfirmation -> {
DismissibleBanner(
onDismiss = { eventSink(RoomListEvent.DismissBanner) },
) { onDismissClick ->
ConfirmRecoveryKeyBanner(
onContinueClick = onConfirmRecoveryKeyClick,
onDismissClick = onDismissClick,
)
}
}
SecurityBannerState.None -> Unit
}
}
}
} }
@Composable @Composable
@@ -237,6 +237,10 @@ private fun RoomsViewList(
lazyListState: LazyListState, lazyListState: LazyListState,
modifier: Modifier = Modifier, 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 -> OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
// The first item of the list is the banner slot, remove it to get the range of the rooms // 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 val roomsRange = (visibleRange.first - 1).coerceAtLeast(0)..<visibleRange.last
@@ -251,13 +255,38 @@ private fun RoomsViewList(
// so that the rooms keep the same index when a banner is added or removed. Since the rooms do not // 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. // have a key, this is required to keep the scroll position.
item { item {
RoomsViewBanner( RoomListBannerContainer(
state = state, banner = banner,
eventSink = eventSink, modifier = Modifier.heightIn(min = bannerSlotMinHeight),
onSetUpRecoveryClick = onSetUpRecoveryClick, ) { displayedBanner ->
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, 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 // 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. // is moved to the top of the list.
itemsIndexed( itemsIndexed(
@@ -281,77 +310,71 @@ 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 @Composable
private fun RoomsViewBanner( private fun RoomListBannerContainer(
state: RoomListContentState.Rooms, banner: RoomListBanner?,
eventSink: (RoomListEvent) -> Unit, modifier: Modifier = Modifier,
onSetUpRecoveryClick: () -> Unit, content: @Composable (RoomListBanner) -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
) { ) {
when (state.securityBannerState) { AnimatedContent(
SecurityBannerState.SetUpRecovery -> { targetState = banner,
DismissibleBanner( // Fill the width so that only the height is animated
onDismiss = { eventSink(RoomListEvent.DismissBanner) }, modifier = modifier.fillMaxWidth(),
) { onDismissClick -> transitionSpec = {
SetUpRecoveryKeyBanner( fadeIn(tween(durationMillis = HALF_DURATION_MILLIS, delayMillis = HALF_DURATION_MILLIS)) togetherWith
onContinueClick = onSetUpRecoveryClick, 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, onDismissClick = onDismissClick,
) )
} RoomListBanner.RecoveryKeyConfirmation -> ConfirmRecoveryKeyBanner(
} onContinueClick = onActionClick,
SecurityBannerState.RecoveryKeyConfirmation -> {
DismissibleBanner(
onDismiss = { eventSink(RoomListEvent.DismissBanner) },
) { onDismissClick ->
ConfirmRecoveryKeyBanner(
onContinueClick = onConfirmRecoveryKeyClick,
onDismissClick = onDismissClick, onDismissClick = onDismissClick,
) )
} RoomListBanner.FullScreenIntentPermission -> FullScreenIntentPermissionBanner(
} onActionClick = onActionClick,
// 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, onDismissClick = onDismissClick,
) )
} RoomListBanner.BatteryOptimization -> BatteryOptimizationBanner(
} onActionClick = onActionClick,
state.batteryOptimizationState.shouldDisplayBanner -> {
DismissibleBanner(
onDismiss = { state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.Dismiss) },
) { onDismissClick ->
BatteryOptimizationBanner(
onActionClick = { state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations) },
onDismissClick = onDismissClick, onDismissClick = onDismissClick,
) )
} RoomListBanner.NewNotificationSound -> NewNotificationSoundBanner(
}
state.showNewNotificationSoundBanner -> {
DismissibleBanner(
onDismiss = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) },
) { onDismissClick ->
NewNotificationSoundBanner(
onDismissClick = onDismissClick, onDismissClick = onDismissClick,
) )
} RoomListBanner.SwipeAvatar -> SwipeAvatarBanner(
}
state.showSwipeAvatarBanner -> {
DismissibleBanner(
onDismiss = { eventSink(RoomListEvent.DismissSwipeAvatarBanner) },
) { onDismissClick ->
SwipeAvatarBanner(
onDismissClick = onDismissClick, onDismissClick = onDismissClick,
) )
} }
}
}
}
} }
@Composable @Composable
@@ -61,6 +61,18 @@ enum class SecurityBannerState {
RecoveryKeyConfirmation, 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 @Immutable
sealed interface RoomListContentState { sealed interface RoomListContentState {
data class Skeleton(val count: Int) : RoomListContentState data class Skeleton(val count: Int) : RoomListContentState
@@ -80,3 +92,24 @@ sealed interface RoomListContentState {
val showAllActivity: Boolean, val showAllActivity: Boolean,
) : RoomListContentState ) : 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) val close = activity!!.getString(CommonStrings.action_close)
onNodeWithContentDescription(close).performClick() onNodeWithContentDescription(close).performClick()
// The event is emitted at the end of the animation
waitForIdle()
eventsRecorder.assertSingle(RoomListEvent.DismissBanner) eventsRecorder.assertSingle(RoomListEvent.DismissBanner)
} }
@@ -98,8 +96,6 @@ class RoomListViewTest : RobolectricTest() {
val close = activity!!.getString(CommonStrings.action_close) val close = activity!!.getString(CommonStrings.action_close)
onNodeWithContentDescription(close).performClick() onNodeWithContentDescription(close).performClick()
// The event is emitted at the end of the animation
waitForIdle()
eventsRecorder.assertSingle(RoomListEvent.DismissBanner) eventsRecorder.assertSingle(RoomListEvent.DismissBanner)
} }
@@ -121,8 +117,6 @@ class RoomListViewTest : RobolectricTest() {
eventsRecorder.assertEmpty() eventsRecorder.assertEmpty()
val close = activity!!.getString(CommonStrings.action_close) val close = activity!!.getString(CommonStrings.action_close)
onNodeWithContentDescription(close).performClick() onNodeWithContentDescription(close).performClick()
// The event is emitted at the end of the animation
waitForIdle()
eventsRecorder.assertSingle(RoomListEvent.DismissSwipeAvatarBanner) eventsRecorder.assertSingle(RoomListEvent.DismissSwipeAvatarBanner)
} }