mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 08:15:02 +00:00
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:
+6
-7
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
+78
@@ -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 }
|
||||
}
|
||||
}
|
||||
+8
-7
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+104
-55
@@ -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>,
|
||||
|
||||
+21
-10
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user