Align the selection indicator with the message bubble

This commit is contained in:
ganfra
2026-09-25 18:48:58 +02:00
parent f4399a392b
commit 0a11e88f06
2 changed files with 88 additions and 58 deletions
@@ -32,6 +32,8 @@ import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawOutline import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.layer.CompositingStrategy import androidx.compose.ui.graphics.layer.CompositingStrategy
import androidx.compose.ui.layout.HorizontalAlignmentLine
import androidx.compose.ui.layout.layout
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -53,12 +55,20 @@ import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.libraries.ui.utils.graphics.drawInLayer import io.element.android.libraries.ui.utils.graphics.drawInLayer
import kotlin.math.min
private val BUBBLE_RADIUS = 12.dp private val BUBBLE_RADIUS = 12.dp
private val avatarRadius = AvatarSize.TimelineSender.dp / 2 private val avatarRadius = AvatarSize.TimelineSender.dp / 2
private val MIN_BUBBLE_WIDTH = 80.dp private val MIN_BUBBLE_WIDTH = 80.dp
/**
* Alignment line published by [MessageEventBubble] at its vertical center.
* It lets the timeline row align decorations with the bubble itself, instead of with the whole row, whose height also
* covers the sender information, the reactions, the thread summary and the read receipts.
*/
val BubbleVerticalCenter = HorizontalAlignmentLine(::min)
@Composable @Composable
fun MessageEventBubble( fun MessageEventBubble(
state: BubbleState, state: BubbleState,
@@ -93,6 +103,12 @@ fun MessageEventBubble(
val updatedBorderColor by rememberUpdatedState(borderColor) val updatedBorderColor by rememberUpdatedState(borderColor)
BoxWithConstraints( BoxWithConstraints(
modifier = modifier modifier = modifier
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height, mapOf(BubbleVerticalCenter to placeable.height / 2)) {
placeable.place(0, 0)
}
}
.drawWithCache { .drawWithCache {
// Calculate the outline of the background and cache it // Calculate the outline of the background and cache it
val outline = bubbleShape.createOutline(size, layoutDirection, this) val outline = bubbleShape.createOutline(size, layoutDirection, this)
@@ -8,7 +8,6 @@
package io.element.android.features.messages.impl.timeline.components package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -18,18 +17,20 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.AlignmentLine
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -39,7 +40,6 @@ import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.canBeSelected
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
@@ -106,10 +106,7 @@ internal fun TimelineItemRow(
val selectionProgress = selectionData.progress val selectionProgress = selectionData.progress
val isSelected = selectionData.isSelected val isSelected = selectionData.isSelected
val backgroundModifier = when { val backgroundModifier = when {
isSelected -> { timelineItem.isEvent(focusedEventId) && !isSelectionModeActive -> {
Modifier.background(ElementTheme.colors.bgAccentSelected)
}
timelineItem.isEvent(focusedEventId) -> {
val focusedEventOffset = val focusedEventOffset =
if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) { if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) {
14.dp 14.dp
@@ -122,7 +119,7 @@ internal fun TimelineItemRow(
Modifier Modifier
} }
} }
val selectableEvent = (timelineItem as? TimelineItem.Event)?.takeIf { it.canBeSelected() } val selectableEvent = (timelineItem as? TimelineItem.Event)?.takeIf { selectionData.canBeSelected }
// Only start-aligned message bubbles slide to make room for the selection indicator: // Only start-aligned message bubbles slide to make room for the selection indicator:
// - Outgoing (own) messages are end-aligned, opposite the indicator, so they already have room. // - Outgoing (own) messages are end-aligned, opposite the indicator, so they already have room.
// - Centered tiles (state events, call tiles, day dividers, grouped events) must stay put. // - Centered tiles (state events, call tiles, day dividers, grouped events) must stay put.
@@ -130,9 +127,10 @@ internal fun TimelineItemRow(
val shouldSlideContent = timelineItem.rendersAsBubble() && !isOutgoing val shouldSlideContent = timelineItem.rendersAsBubble() && !isOutgoing
val layoutDirection = LocalLayoutDirection.current val layoutDirection = LocalLayoutDirection.current
val contentSlide = if (shouldSlideContent) SELECTION_COLUMN_WIDTH * selectionProgress else 0.dp val contentSlide = if (shouldSlideContent) SELECTION_COLUMN_WIDTH * selectionProgress else 0.dp
Box( Layout(
modifier = modifier.then(backgroundModifier) modifier = modifier.then(backgroundModifier),
) { contents = listOf(
{
TimelineItemRowContent( TimelineItemRowContent(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -161,29 +159,45 @@ internal fun TimelineItemRow(
eventSink = eventSink, eventSink = eventSink,
eventContentView = eventContentView, eventContentView = eventContentView,
) )
},
{
if (selectableEvent != null && selectionProgress > 0f) { if (selectableEvent != null && selectionProgress > 0f) {
val slideSign = if (layoutDirection == LayoutDirection.Ltr) -1f else 1f val slideSign = if (layoutDirection == LayoutDirection.Ltr) -1f else 1f
SelectionIndicator( SelectionIndicator(
selected = isSelected, selected = isSelected,
modifier = Modifier modifier = Modifier
.align(Alignment.CenterStart)
.graphicsLayer { translationX = slideSign * SELECTION_COLUMN_WIDTH.toPx() * (1f - selectionProgress) } .graphicsLayer { translationX = slideSign * SELECTION_COLUMN_WIDTH.toPx() * (1f - selectionProgress) }
.padding(start = 12.dp) .padding(start = 16.dp)
.size(24.dp), .size(24.dp),
) )
} }
},
{
if (isSelectionModeActive && selectableEvent != null) { if (isSelectionModeActive && selectableEvent != null) {
// In selection mode the whole row toggles selection. // In selection mode the whole row toggles selection.
Box( Box(modifier = Modifier.clickable { onContentClick(selectableEvent) })
modifier = Modifier }
.matchParentSize() },
.clickable { onContentClick(selectableEvent) } ),
) ) { (contentMeasurables, indicatorMeasurables, overlayMeasurables), constraints ->
val contentPlaceable = contentMeasurables.first().measure(constraints)
val width = contentPlaceable.width
val height = contentPlaceable.height
val indicatorPlaceable = indicatorMeasurables.firstOrNull()?.measure(constraints.copy(minWidth = 0, minHeight = 0))
val overlayPlaceable = overlayMeasurables.firstOrNull()?.measure(Constraints.fixed(width, height))
layout(width = width, height = height) {
contentPlaceable.place(0, 0)
if (indicatorPlaceable != null) {
val bubbleCenter = contentPlaceable[BubbleVerticalCenter].takeIf { it != AlignmentLine.Unspecified } ?: (height / 2)
val x = if (layoutDirection == LayoutDirection.Ltr) 0 else width - indicatorPlaceable.width
indicatorPlaceable.place(x = x, y = bubbleCenter - indicatorPlaceable.height / 2)
}
overlayPlaceable?.place(0, 0)
} }
} }
} }
/** Width reserved for the selection indicator; the row content slides by this amount. */ /** Width reserved for the selection indicator : the row content slides by this amount. */
private val SELECTION_COLUMN_WIDTH = 36.dp private val SELECTION_COLUMN_WIDTH = 36.dp
/** /**