Mark as read menu ui tweaks

This commit is contained in:
Jenna Vassar
2026-05-05 18:44:11 -07:00
parent 880a189617
commit 784f5df426
@@ -10,8 +10,10 @@ package io.element.android.features.messages.impl.timeline
import android.view.HapticFeedbackConstants import android.view.HapticFeedbackConstants
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -34,6 +36,7 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -48,18 +51,26 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView 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.DpOffset
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
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
@@ -80,7 +91,6 @@ import io.element.android.libraries.designsystem.components.dialogs.AlertDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.roundToPx import io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.libraries.designsystem.theme.components.DropdownMenu
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.animateScrollToItemCenter import io.element.android.libraries.designsystem.utils.animateScrollToItemCenter
@@ -448,8 +458,8 @@ private fun JumpToPositionButton(
AnimatedVisibility( AnimatedVisibility(
modifier = modifier, modifier = modifier,
visible = isVisible, visible = isVisible,
enter = scaleIn(animationSpec = tween(100)), enter = scaleIn(animationSpec = tween(220), initialScale = 0.8f) + fadeIn(animationSpec = tween(220)),
exit = scaleOut(animationSpec = tween(100)), exit = scaleOut(animationSpec = tween(180), targetScale = 0.8f) + fadeOut(animationSpec = tween(180)),
) { ) {
var menuExpanded by remember { mutableStateOf(false) } var menuExpanded by remember { mutableStateOf(false) }
Box { Box {
@@ -473,34 +483,60 @@ private fun JumpToPositionButton(
contentDescription = contentDescription, contentDescription = contentDescription,
tint = ElementTheme.colors.iconSecondary, tint = ElementTheme.colors.iconSecondary,
) )
DropdownMenu( val menuTransitionState = remember { MutableTransitionState(false) }
expanded = menuExpanded, .apply { targetState = menuExpanded }
onDismissRequest = { menuExpanded = false }, if (menuTransitionState.currentState || menuTransitionState.targetState) {
minWidth = 0.dp, val gapPx = with(LocalDensity.current) { 8.dp.roundToPx() }
offset = DpOffset(x = -44.dp, y = 40.dp) val positionProvider = remember(gapPx) { CenterStartOfAnchorPositionProvider(gapPx) }
) { Popup(
// Hand-rolled instead of DropdownMenuItem: padding here is tighter popupPositionProvider = positionProvider,
// than DropdownMenuItem's 12.dp default to match the Figma spec. onDismissRequest = { menuExpanded = false },
Row( properties = PopupProperties(focusable = true),
modifier = Modifier
.clickable {
menuExpanded = false
onMarkAsRead()
}
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( // Anchor the scale to the right-center edge so the menu visually grows
imageVector = CompoundIcons.MarkAsRead(), // outward from the FAB it's attached to.
contentDescription = null, val transformOrigin = TransformOrigin(pivotFractionX = 1f, pivotFractionY = 0.5f)
tint = ElementTheme.colors.iconPrimary, AnimatedVisibility(
) visibleState = menuTransitionState,
Spacer(modifier = Modifier.width(8.dp)) enter = scaleIn(
Text( animationSpec = tween(180),
text = stringResource(id = CommonStrings.action_mark_as_read), initialScale = 0.9f,
color = ElementTheme.colors.textPrimary, transformOrigin = transformOrigin,
style = ElementTheme.typography.fontBodyLgRegular, ) + fadeIn(animationSpec = tween(180)),
) exit = scaleOut(
animationSpec = tween(140),
targetScale = 0.9f,
transformOrigin = transformOrigin,
) + fadeOut(animationSpec = tween(140)),
) {
// Hand-rolled instead of DropdownMenuItem: padding here is tighter
// than DropdownMenuItem's 12.dp default to match the Figma spec.
Row(
modifier = Modifier
.shadow(elevation = 1.dp, shape = RoundedCornerShape(8.dp))
.clip(RoundedCornerShape(8.dp))
.background(ElementTheme.colors.bgCanvasDefaultLevel1)
.border(1.dp, ElementTheme.colors.borderDisabled, RoundedCornerShape(8.dp))
.clickable {
menuExpanded = false
onMarkAsRead()
}
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = CompoundIcons.MarkAsRead(),
contentDescription = null,
tint = ElementTheme.colors.iconTertiary,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(id = CommonStrings.action_mark_as_read),
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyLgRegular,
)
}
}
} }
} }
} }
@@ -617,3 +653,26 @@ internal fun TimelineViewWithReadMarkerNoIndicatorsPreview() = ElementPreview {
internal fun TimelineViewWithReadMarkerBothIndicatorsPreview() = ElementPreview { internal fun TimelineViewWithReadMarkerBothIndicatorsPreview() = ElementPreview {
TimelineViewWithReadMarker(hasUnreadAbove = true, hasUnreadBelow = true) TimelineViewWithReadMarker(hasUnreadAbove = true, hasUnreadBelow = true)
} }
/**
* Anchors the popup so its right edge sits [gapPx] to the left of the anchor and its vertical
* center matches the anchor's. Adapts to localized menu widths and FAB size; coerced to stay
* on-screen.
*/
private class CenterStartOfAnchorPositionProvider(
private val gapPx: Int,
) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
): IntOffset {
val x = anchorBounds.left - popupContentSize.width - gapPx
val y = anchorBounds.top + (anchorBounds.height - popupContentSize.height) / 2
return IntOffset(
x = x.coerceIn(0, (windowSize.width - popupContentSize.width).coerceAtLeast(0)),
y = y.coerceIn(0, (windowSize.height - popupContentSize.height).coerceAtLeast(0)),
)
}
}