Add floating/sticky date badge in the timeline (#6496)

* Add floating date indicator while scrolling the timeline (#6433)

* Add `FeatureFlags.FloatingDateBadge`. This enables displaying the floating date badge in the timeline as you scroll.

* Don't display the floating badge if the timeline isn't reversed. Otherwise, this will affect talkback users and break the existing navigation

* Use `TimelineItem.formattedDate()` to get the date to display. Always try finding the closest one (usually it will be just the 1st one we try).

* Align designs with iOS. Also fix shadows in fade animation by adding some paddings.

* Update screenshots

---------

Co-authored-by: Gianluca Iavicoli <gianluca.iavicoli04@gmail.com>
Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa
2026-04-01 12:45:57 +02:00
committed by GitHub
parent 977e64c295
commit b340e85f83
12 changed files with 200 additions and 1 deletions

View File

@@ -39,6 +39,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
@@ -464,6 +465,9 @@ private fun MessagesViewContent(
val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberScrollBehavior( val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberScrollBehavior(
pinnedMessagesCount = (state.pinnedMessagesBannerState as? PinnedMessagesBannerState.Visible)?.pinnedMessagesCount() ?: 0, pinnedMessagesCount = (state.pinnedMessagesBannerState as? PinnedMessagesBannerState.Visible)?.pinnedMessagesCount() ?: 0,
) )
val density = LocalDensity.current
var pinnedBannerHeightDp by remember { mutableStateOf(0.dp) }
TimelineView( TimelineView(
state = state.timelineState, state = state.timelineState,
timelineProtectionState = state.timelineProtectionState, timelineProtectionState = state.timelineProtectionState,
@@ -479,11 +483,13 @@ private fun MessagesViewContent(
forceJumpToBottomVisibility = forceJumpToBottomVisibility, forceJumpToBottomVisibility = forceJumpToBottomVisibility,
onJoinCallClick = onJoinCallClick, onJoinCallClick = onJoinCallClick,
nestedScrollConnection = scrollBehavior.nestedScrollConnection, nestedScrollConnection = scrollBehavior.nestedScrollConnection,
floatingDateTopOffset = pinnedBannerHeightDp,
) )
if (state.timelineState.timelineMode !is Timeline.Mode.Thread) { if (state.timelineState.timelineMode !is Timeline.Mode.Thread) {
AnimatedVisibility( AnimatedVisibility(
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible, visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
modifier = Modifier.onSizeChanged { pinnedBannerHeightDp = with(density) { it.height.toDp() } },
enter = expandVertically(), enter = expandVertically(),
exit = shrinkVertically(), exit = shrinkVertically(),
) { ) {

View File

@@ -149,6 +149,9 @@ class TimelinePresenter(
val displayThreadSummaries by produceState(false) { val displayThreadSummaries by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.Threads) value = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
} }
val displayFloatingDateBadge by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.FloatingDateBadge)
}
fun handleEvent(event: TimelineEvent) { fun handleEvent(event: TimelineEvent) {
when (event) { when (event) {
@@ -315,6 +318,7 @@ class TimelinePresenter(
messageShieldDialogData = messageShieldDialogData.value, messageShieldDialogData = messageShieldDialogData.value,
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries, displayThreadSummaries = displayThreadSummaries,
displayFloatingDateBadge = displayFloatingDateBadge,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }

View File

@@ -34,6 +34,7 @@ data class TimelineState(
val messageShieldDialogData: MessageShieldData?, val messageShieldDialogData: MessageShieldData?,
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState, val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
val displayThreadSummaries: Boolean, val displayThreadSummaries: Boolean,
val displayFloatingDateBadge: Boolean,
val eventSink: (TimelineEvent) -> Unit, val eventSink: (TimelineEvent) -> Unit,
) { ) {
private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event

View File

@@ -56,6 +56,7 @@ fun aTimelineState(
messageShield: MessageShield? = null, messageShield: MessageShield? = null,
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(), resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
displayThreadSummaries: Boolean = false, displayThreadSummaries: Boolean = false,
displayFloatingDateBadge: Boolean = false,
eventSink: (TimelineEvent) -> Unit = {}, eventSink: (TimelineEvent) -> Unit = {},
): TimelineState { ): TimelineState {
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
@@ -75,6 +76,7 @@ fun aTimelineState(
messageShieldDialogData = messageShield?.let { MessageShieldData(it) }, messageShieldDialogData = messageShield?.let { MessageShieldData(it) },
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries, displayThreadSummaries = displayThreadSummaries,
displayFloatingDateBadge = displayFloatingDateBadge,
eventSink = eventSink, eventSink = eventSink,
) )
} }

View File

@@ -47,10 +47,12 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureView import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureView
import io.element.android.features.messages.impl.timeline.components.FloatingDateBadgeOverlay
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
import io.element.android.features.messages.impl.timeline.components.toText import io.element.android.features.messages.impl.timeline.components.toText
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
@@ -105,6 +107,7 @@ fun TimelineView(
lazyListState: LazyListState = rememberLazyListState(), lazyListState: LazyListState = rememberLazyListState(),
forceJumpToBottomVisibility: Boolean = false, forceJumpToBottomVisibility: Boolean = false,
nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(), nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
floatingDateTopOffset: Dp = 0.dp,
) { ) {
fun clearFocusRequestState() { fun clearFocusRequestState() {
state.eventSink(TimelineEvent.ClearFocusRequestState) state.eventSink(TimelineEvent.ClearFocusRequestState)
@@ -210,6 +213,15 @@ fun TimelineView(
onJumpToLive = ::onJumpToLive, onJumpToLive = ::onJumpToLive,
onFocusEventRender = ::onFocusEventRender, onFocusEventRender = ::onFocusEventRender,
) )
if (state.displayFloatingDateBadge && useReverseLayout) {
FloatingDateBadgeOverlay(
lazyListState = lazyListState,
timelineItems = state.timelineItems,
isLive = state.isLive,
topOffset = floatingDateTopOffset,
)
}
} }
} }

View File

@@ -0,0 +1,144 @@
/*
* Copyright (c) 2025 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.messages.impl.timeline.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
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 androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.floatingDateBadgeBackground
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlin.time.Duration.Companion.milliseconds
@Composable
internal fun BoxScope.FloatingDateBadgeOverlay(
lazyListState: LazyListState,
timelineItems: ImmutableList<TimelineItem>,
isLive: Boolean,
topOffset: Dp = 0.dp,
) {
// This needs to be a state to trigger a `derivedState` recalculation
val updatedTimelineItems by rememberUpdatedState(timelineItems)
// Look for the last visible item with a timestamp, starting from the last visible item and going backwards until we find one or reach the start of the list
val lastVisibleItemWithTimestamp by remember {
derivedStateOf {
var index = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: return@derivedStateOf null
while (index >= 0) {
when (val item = updatedTimelineItems.getOrNull(index)) {
is TimelineItem.Event -> return@derivedStateOf item
is TimelineItem.Virtual -> if (item.model is TimelineItemDaySeparatorModel) return@derivedStateOf item
is TimelineItem.GroupedEvents -> return@derivedStateOf item.events.firstOrNull()
null -> Unit
}
index--
}
null
}
}
// Store the formatted date so we recompute it lazily and can keep it around even if we need to dispose the badge because the timeline items changed
var formattedDate: String? by remember { mutableStateOf(null) }
// Update the formatted date when we have a new non-null timestamp
LaunchedEffect(lastVisibleItemWithTimestamp) {
lastVisibleItemWithTimestamp?.formattedDate()?.let { formattedDate = it }
}
val isAtBottom by remember {
derivedStateOf {
lazyListState.firstVisibleItemIndex < 3 && isLive
}
}
var isBadgeVisible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
snapshotFlow { lazyListState.isScrollInProgress }
.collectLatest { isScrolling ->
if (isScrolling) {
isBadgeVisible = true
} else {
delay(2000.milliseconds)
isBadgeVisible = false
}
}
}
val showBadge = isBadgeVisible && !isAtBottom && formattedDate != null
AnimatedVisibility(
visible = showBadge,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 8.dp + topOffset),
enter = fadeIn(animationSpec = tween(150)),
exit = fadeOut(animationSpec = tween(300)),
) {
formattedDate?.let { dateText ->
FloatingDateBadge(
modifier = Modifier.padding(8.dp),
dateText = dateText,
)
}
}
}
@Composable
internal fun FloatingDateBadge(
dateText: String,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(16.dp),
color = ElementTheme.colors.floatingDateBadgeBackground,
shadowElevation = 4.dp,
) {
Text(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
text = dateText,
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
}
}
@PreviewsDayNight
@Composable
internal fun FloatingDateBadgePreview() = ElementPreview {
Box(modifier = Modifier.padding(16.dp)) {
FloatingDateBadge(dateText = "March 9, 2026")
}
}

View File

@@ -66,6 +66,11 @@ class TimelineItemEventFactory(
timestamp = currentTimelineItem.event.timestamp, timestamp = currentTimelineItem.event.timestamp,
mode = DateFormatterMode.TimeOnly, mode = DateFormatterMode.TimeOnly,
) )
val sentDate = dateFormatter.format(
timestamp = currentTimelineItem.event.timestamp,
mode = DateFormatterMode.Day,
useRelative = true,
)
val senderAvatarData = AvatarData( val senderAvatarData = AvatarData(
id = currentSender.value, id = currentSender.value,
name = senderProfile.getDisambiguatedDisplayName(currentSender), name = senderProfile.getDisambiguatedDisplayName(currentSender),
@@ -108,6 +113,7 @@ class TimelineItemEventFactory(
canBeRepliedTo = currentTimelineItem.event.canBeRepliedTo, canBeRepliedTo = currentTimelineItem.event.canBeRepliedTo,
sentTimeMillis = currentTimelineItem.event.timestamp, sentTimeMillis = currentTimelineItem.event.timestamp,
sentTime = sentTime, sentTime = sentTime,
sentDate = sentDate,
groupPosition = groupPosition, groupPosition = groupPosition,
reactionsState = currentTimelineItem.computeReactionsState(), reactionsState = currentTimelineItem.computeReactionsState(),
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers), readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers),

View File

@@ -15,6 +15,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@@ -59,6 +60,12 @@ sealed interface TimelineItem {
is GroupedEvents -> "groupedEvent" is GroupedEvents -> "groupedEvent"
} }
fun formattedDate(): String? = when (this) {
is Event -> sentDate.takeIf { it.isNotEmpty() }
is Virtual -> (model as? TimelineItemDaySeparatorModel)?.formattedDate?.takeIf { it.isNotEmpty() }
is GroupedEvents -> null
}
data class Virtual( data class Virtual(
val id: UniqueId, val id: UniqueId,
val model: TimelineItemVirtualModel val model: TimelineItemVirtualModel
@@ -75,6 +82,7 @@ sealed interface TimelineItem {
val content: TimelineItemEventContent, val content: TimelineItemEventContent,
val sentTimeMillis: Long = 0L, val sentTimeMillis: Long = 0L,
val sentTime: String = "", val sentTime: String = "",
val sentDate: String = "",
val isMine: Boolean = false, val isMine: Boolean = false,
val isEditable: Boolean, val isEditable: Boolean,
val canBeRepliedTo: Boolean, val canBeRepliedTo: Boolean,

View File

@@ -75,6 +75,9 @@ val SemanticColors.pinnedMessageBannerIndicator
val SemanticColors.pinnedMessageBannerBorder val SemanticColors.pinnedMessageBannerBorder
get() = if (isLight) LightColorTokens.colorAlphaGray400 else DarkColorTokens.colorAlphaGray400 get() = if (isLight) LightColorTokens.colorAlphaGray400 else DarkColorTokens.colorAlphaGray400
val SemanticColors.floatingDateBadgeBackground
get() = if (isLight) bgCanvasDefault else bgSubtlePrimary
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun ColorAliasesPreview() = ElementPreview { internal fun ColorAliasesPreview() = ElementPreview {

View File

@@ -156,10 +156,17 @@ enum class FeatureFlags(
), ),
ValidateNetworkWhenSchedulingNotificationFetching( ValidateNetworkWhenSchedulingNotificationFetching(
key = "feature.validate_network_when_scheduling_notification_fetching", key = "feature.validate_network_when_scheduling_notification_fetching",
title = "validate internet connectivity when scheduling notification fetching", title = "Validate internet connectivity when scheduling notification fetching",
description = "Only fetch events for push notifications when the device has internet connectivity. " + description = "Only fetch events for push notifications when the device has internet connectivity. " +
"Enabling this can be problematic in air-gapped environments.", "Enabling this can be problematic in air-gapped environments.",
defaultValue = { true }, defaultValue = { true },
isFinished = false, isFinished = false,
), ),
FloatingDateBadge(
key = "feature.floating_date_badge",
title = "Display sticky date headers in the timeline",
description = "When scrolling, a sticky date badge will be displayed so you can easily know on which date the messages you're seeing were sent.",
defaultValue = { false },
isFinished = false,
),
} }