@@ -10,8 +10,10 @@ package io.element.android.features.messages.impl.timeline
1010
1111import android.view.HapticFeedbackConstants
1212import androidx.compose.animation.AnimatedVisibility
13+ import androidx.compose.animation.core.MutableTransitionState
1314import androidx.compose.animation.core.tween
1415import androidx.compose.animation.fadeIn
16+ import androidx.compose.animation.fadeOut
1517import androidx.compose.animation.scaleIn
1618import androidx.compose.animation.scaleOut
1719import androidx.compose.foundation.background
@@ -34,6 +36,7 @@ import androidx.compose.foundation.lazy.LazyListState
3436import androidx.compose.foundation.lazy.items
3537import androidx.compose.foundation.lazy.rememberLazyListState
3638import androidx.compose.foundation.shape.CircleShape
39+ import androidx.compose.foundation.shape.RoundedCornerShape
3740import androidx.compose.runtime.Composable
3841import androidx.compose.runtime.CompositionLocalProvider
3942import androidx.compose.runtime.LaunchedEffect
@@ -48,18 +51,26 @@ import androidx.compose.runtime.snapshotFlow
4851import androidx.compose.ui.Alignment
4952import androidx.compose.ui.Modifier
5053import androidx.compose.ui.draw.clip
54+ import androidx.compose.ui.draw.shadow
55+ import androidx.compose.ui.graphics.TransformOrigin
5156import androidx.compose.ui.graphics.vector.ImageVector
5257import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
5358import androidx.compose.ui.input.nestedscroll.nestedScroll
5459import androidx.compose.ui.platform.LocalContext
60+ import androidx.compose.ui.platform.LocalDensity
5561import androidx.compose.ui.platform.LocalView
5662import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
5763import androidx.compose.ui.res.stringResource
5864import androidx.compose.ui.tooling.preview.PreviewParameter
5965import androidx.compose.ui.unit.Dp
60- import androidx.compose.ui.unit.DpOffset
6166import androidx.compose.ui.unit.IntOffset
67+ import androidx.compose.ui.unit.IntRect
68+ import androidx.compose.ui.unit.IntSize
69+ import androidx.compose.ui.unit.LayoutDirection
6270import androidx.compose.ui.unit.dp
71+ import androidx.compose.ui.window.Popup
72+ import androidx.compose.ui.window.PopupPositionProvider
73+ import androidx.compose.ui.window.PopupProperties
6374import io.element.android.compound.theme.ElementTheme
6475import io.element.android.compound.tokens.generated.CompoundIcons
6576import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureView
@@ -80,7 +91,6 @@ import io.element.android.libraries.designsystem.components.dialogs.AlertDialog
8091import io.element.android.libraries.designsystem.preview.ElementPreview
8192import io.element.android.libraries.designsystem.preview.PreviewsDayNight
8293import io.element.android.libraries.designsystem.text.roundToPx
83- import io.element.android.libraries.designsystem.theme.components.DropdownMenu
8494import io.element.android.libraries.designsystem.theme.components.Icon
8595import io.element.android.libraries.designsystem.theme.components.Text
8696import io.element.android.libraries.designsystem.utils.animateScrollToItemCenter
@@ -448,8 +458,8 @@ private fun JumpToPositionButton(
448458 AnimatedVisibility (
449459 modifier = modifier,
450460 visible = isVisible,
451- enter = scaleIn(animationSpec = tween(100 )),
452- exit = scaleOut(animationSpec = tween(100 )),
461+ enter = scaleIn(animationSpec = tween(220 ), initialScale = 0.8f ) + fadeIn(animationSpec = tween( 220 )),
462+ exit = scaleOut(animationSpec = tween(180 ), targetScale = 0.8f ) + fadeOut(animationSpec = tween( 180 )),
453463 ) {
454464 var menuExpanded by remember { mutableStateOf(false ) }
455465 Box {
@@ -473,34 +483,60 @@ private fun JumpToPositionButton(
473483 contentDescription = contentDescription,
474484 tint = ElementTheme .colors.iconSecondary,
475485 )
476- DropdownMenu (
477- expanded = menuExpanded,
478- onDismissRequest = { menuExpanded = false },
479- minWidth = 0 .dp,
480- offset = DpOffset (x = - 44 .dp, y = 40 .dp)
481- ) {
482- // Hand-rolled instead of DropdownMenuItem: padding here is tighter
483- // than DropdownMenuItem's 12.dp default to match the Figma spec.
484- Row (
485- modifier = Modifier
486- .clickable {
487- menuExpanded = false
488- onMarkAsRead()
489- }
490- .padding(horizontal = 12 .dp, vertical = 8 .dp),
491- verticalAlignment = Alignment .CenterVertically ,
486+ val menuTransitionState = remember { MutableTransitionState (false ) }
487+ .apply { targetState = menuExpanded }
488+ if (menuTransitionState.currentState || menuTransitionState.targetState) {
489+ val gapPx = with (LocalDensity .current) { 8 .dp.roundToPx() }
490+ val positionProvider = remember(gapPx) { CenterStartOfAnchorPositionProvider (gapPx) }
491+ Popup (
492+ popupPositionProvider = positionProvider,
493+ onDismissRequest = { menuExpanded = false },
494+ properties = PopupProperties (focusable = true ),
492495 ) {
493- Icon (
494- imageVector = CompoundIcons .MarkAsRead (),
495- contentDescription = null ,
496- tint = ElementTheme .colors.iconPrimary,
497- )
498- Spacer (modifier = Modifier .width(8 .dp))
499- Text (
500- text = stringResource(id = CommonStrings .action_mark_as_read),
501- color = ElementTheme .colors.textPrimary,
502- style = ElementTheme .typography.fontBodyLgRegular,
503- )
496+ // Anchor the scale to the right-center edge so the menu visually grows
497+ // outward from the FAB it's attached to.
498+ val transformOrigin = TransformOrigin (pivotFractionX = 1f , pivotFractionY = 0.5f )
499+ AnimatedVisibility (
500+ visibleState = menuTransitionState,
501+ enter = scaleIn(
502+ animationSpec = tween(180 ),
503+ initialScale = 0.9f ,
504+ transformOrigin = transformOrigin,
505+ ) + fadeIn(animationSpec = tween(180 )),
506+ exit = scaleOut(
507+ animationSpec = tween(140 ),
508+ targetScale = 0.9f ,
509+ transformOrigin = transformOrigin,
510+ ) + fadeOut(animationSpec = tween(140 )),
511+ ) {
512+ // Hand-rolled instead of DropdownMenuItem: padding here is tighter
513+ // than DropdownMenuItem's 12.dp default to match the Figma spec.
514+ Row (
515+ modifier = Modifier
516+ .shadow(elevation = 1 .dp, shape = RoundedCornerShape (8 .dp))
517+ .clip(RoundedCornerShape (8 .dp))
518+ .background(ElementTheme .colors.bgCanvasDefaultLevel1)
519+ .border(1 .dp, ElementTheme .colors.borderDisabled, RoundedCornerShape (8 .dp))
520+ .clickable {
521+ menuExpanded = false
522+ onMarkAsRead()
523+ }
524+ .padding(horizontal = 12 .dp, vertical = 12 .dp),
525+ verticalAlignment = Alignment .CenterVertically ,
526+ ) {
527+ Icon (
528+ imageVector = CompoundIcons .MarkAsRead (),
529+ contentDescription = null ,
530+ tint = ElementTheme .colors.iconTertiary,
531+ )
532+ Spacer (modifier = Modifier .width(8 .dp))
533+ Text (
534+ text = stringResource(id = CommonStrings .action_mark_as_read),
535+ color = ElementTheme .colors.textPrimary,
536+ style = ElementTheme .typography.fontBodyLgRegular,
537+ )
538+ }
539+ }
504540 }
505541 }
506542 }
@@ -617,3 +653,26 @@ internal fun TimelineViewWithReadMarkerNoIndicatorsPreview() = ElementPreview {
617653internal fun TimelineViewWithReadMarkerBothIndicatorsPreview () = ElementPreview {
618654 TimelineViewWithReadMarker (hasUnreadAbove = true , hasUnreadBelow = true )
619655}
656+
657+ /* *
658+ * Anchors the popup so its right edge sits [gapPx] to the left of the anchor and its vertical
659+ * center matches the anchor's. Adapts to localized menu widths and FAB size; coerced to stay
660+ * on-screen.
661+ */
662+ private class CenterStartOfAnchorPositionProvider (
663+ private val gapPx : Int ,
664+ ) : PopupPositionProvider {
665+ override fun calculatePosition (
666+ anchorBounds : IntRect ,
667+ windowSize : IntSize ,
668+ layoutDirection : LayoutDirection ,
669+ popupContentSize : IntSize ,
670+ ): IntOffset {
671+ val x = anchorBounds.left - popupContentSize.width - gapPx
672+ val y = anchorBounds.top + (anchorBounds.height - popupContentSize.height) / 2
673+ return IntOffset (
674+ x = x.coerceIn(0 , (windowSize.width - popupContentSize.width).coerceAtLeast(0 )),
675+ y = y.coerceIn(0 , (windowSize.height - popupContentSize.height).coerceAtLeast(0 )),
676+ )
677+ }
678+ }
0 commit comments