mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 16:22:49 +00:00
Align the selection indicator with the message bubble
This commit is contained in:
+16
@@ -32,6 +32,8 @@ import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.drawOutline
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
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.unit.LayoutDirection
|
||||
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.ui.utils.a11y.isTalkbackActive
|
||||
import io.element.android.libraries.ui.utils.graphics.drawInLayer
|
||||
import kotlin.math.min
|
||||
|
||||
private val BUBBLE_RADIUS = 12.dp
|
||||
private val avatarRadius = AvatarSize.TimelineSender.dp / 2
|
||||
|
||||
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
|
||||
fun MessageEventBubble(
|
||||
state: BubbleState,
|
||||
@@ -93,6 +103,12 @@ fun MessageEventBubble(
|
||||
val updatedBorderColor by rememberUpdatedState(borderColor)
|
||||
BoxWithConstraints(
|
||||
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 {
|
||||
// Calculate the outline of the background and cache it
|
||||
val outline = bubbleShape.createOutline(size, layoutDirection, this)
|
||||
|
||||
+72
-58
@@ -8,7 +8,6 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
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.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.isTraversalGroup
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
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.layout.ContentAvoidingLayoutData
|
||||
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.TimelineItemLegacyCallInviteContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
|
||||
@@ -106,10 +106,7 @@ internal fun TimelineItemRow(
|
||||
val selectionProgress = selectionData.progress
|
||||
val isSelected = selectionData.isSelected
|
||||
val backgroundModifier = when {
|
||||
isSelected -> {
|
||||
Modifier.background(ElementTheme.colors.bgAccentSelected)
|
||||
}
|
||||
timelineItem.isEvent(focusedEventId) -> {
|
||||
timelineItem.isEvent(focusedEventId) && !isSelectionModeActive -> {
|
||||
val focusedEventOffset =
|
||||
if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) {
|
||||
14.dp
|
||||
@@ -122,7 +119,7 @@ internal fun TimelineItemRow(
|
||||
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:
|
||||
// - 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.
|
||||
@@ -130,60 +127,77 @@ internal fun TimelineItemRow(
|
||||
val shouldSlideContent = timelineItem.rendersAsBubble() && !isOutgoing
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val contentSlide = if (shouldSlideContent) SELECTION_COLUMN_WIDTH * selectionProgress else 0.dp
|
||||
Box(
|
||||
modifier = modifier.then(backgroundModifier)
|
||||
) {
|
||||
TimelineItemRowContent(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.offset(x = contentSlide),
|
||||
selectionContentOffset = contentSlide,
|
||||
timelineItem = timelineItem,
|
||||
timelineMode = timelineMode,
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
focusedEventId = focusedEventId,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
onContentClick = onContentClick,
|
||||
onGalleryItemClick = onGalleryItemClick,
|
||||
onLongClick = onLongClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onReactionClick = onReactionClick,
|
||||
onReactionLongClick = onReactionLongClick,
|
||||
onMoreReactionsClick = onMoreReactionsClick,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
eventSink = eventSink,
|
||||
eventContentView = eventContentView,
|
||||
)
|
||||
if (selectableEvent != null && selectionProgress > 0f) {
|
||||
val slideSign = if (layoutDirection == LayoutDirection.Ltr) -1f else 1f
|
||||
SelectionIndicator(
|
||||
selected = isSelected,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.graphicsLayer { translationX = slideSign * SELECTION_COLUMN_WIDTH.toPx() * (1f - selectionProgress) }
|
||||
.padding(start = 12.dp)
|
||||
.size(24.dp),
|
||||
)
|
||||
}
|
||||
if (isSelectionModeActive && selectableEvent != null) {
|
||||
// In selection mode the whole row toggles selection.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.clickable { onContentClick(selectableEvent) }
|
||||
)
|
||||
Layout(
|
||||
modifier = modifier.then(backgroundModifier),
|
||||
contents = listOf(
|
||||
{
|
||||
TimelineItemRowContent(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.offset(x = contentSlide),
|
||||
selectionContentOffset = contentSlide,
|
||||
timelineItem = timelineItem,
|
||||
timelineMode = timelineMode,
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
focusedEventId = focusedEventId,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
onContentClick = onContentClick,
|
||||
onGalleryItemClick = onGalleryItemClick,
|
||||
onLongClick = onLongClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onReactionClick = onReactionClick,
|
||||
onReactionLongClick = onReactionLongClick,
|
||||
onMoreReactionsClick = onMoreReactionsClick,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
eventSink = eventSink,
|
||||
eventContentView = eventContentView,
|
||||
)
|
||||
},
|
||||
{
|
||||
if (selectableEvent != null && selectionProgress > 0f) {
|
||||
val slideSign = if (layoutDirection == LayoutDirection.Ltr) -1f else 1f
|
||||
SelectionIndicator(
|
||||
selected = isSelected,
|
||||
modifier = Modifier
|
||||
.graphicsLayer { translationX = slideSign * SELECTION_COLUMN_WIDTH.toPx() * (1f - selectionProgress) }
|
||||
.padding(start = 16.dp)
|
||||
.size(24.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
if (isSelectionModeActive && selectableEvent != null) {
|
||||
// In selection mode the whole row toggles selection.
|
||||
Box(modifier = Modifier.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
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user