mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-04 01:19:33 +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.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)
|
||||||
|
|||||||
+72
-58
@@ -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,60 +127,77 @@ 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(
|
{
|
||||||
modifier = Modifier
|
TimelineItemRowContent(
|
||||||
.fillMaxWidth()
|
modifier = Modifier
|
||||||
.offset(x = contentSlide),
|
.fillMaxWidth()
|
||||||
selectionContentOffset = contentSlide,
|
.offset(x = contentSlide),
|
||||||
timelineItem = timelineItem,
|
selectionContentOffset = contentSlide,
|
||||||
timelineMode = timelineMode,
|
timelineItem = timelineItem,
|
||||||
timelineRoomInfo = timelineRoomInfo,
|
timelineMode = timelineMode,
|
||||||
timelineProtectionState = timelineProtectionState,
|
timelineRoomInfo = timelineRoomInfo,
|
||||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
timelineProtectionState = timelineProtectionState,
|
||||||
focusedEventId = focusedEventId,
|
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||||
displayThreadSummaries = displayThreadSummaries,
|
focusedEventId = focusedEventId,
|
||||||
onUserDataClick = onUserDataClick,
|
displayThreadSummaries = displayThreadSummaries,
|
||||||
onLinkClick = onLinkClick,
|
onUserDataClick = onUserDataClick,
|
||||||
onLinkLongClick = onLinkLongClick,
|
onLinkClick = onLinkClick,
|
||||||
onContentClick = onContentClick,
|
onLinkLongClick = onLinkLongClick,
|
||||||
onGalleryItemClick = onGalleryItemClick,
|
onContentClick = onContentClick,
|
||||||
onLongClick = onLongClick,
|
onGalleryItemClick = onGalleryItemClick,
|
||||||
inReplyToClick = inReplyToClick,
|
onLongClick = onLongClick,
|
||||||
onReactionClick = onReactionClick,
|
inReplyToClick = inReplyToClick,
|
||||||
onReactionLongClick = onReactionLongClick,
|
onReactionClick = onReactionClick,
|
||||||
onMoreReactionsClick = onMoreReactionsClick,
|
onReactionLongClick = onReactionLongClick,
|
||||||
onReadReceiptClick = onReadReceiptClick,
|
onMoreReactionsClick = onMoreReactionsClick,
|
||||||
onJoinCallClick = onJoinCallClick,
|
onReadReceiptClick = onReadReceiptClick,
|
||||||
onSwipeToReply = onSwipeToReply,
|
onJoinCallClick = onJoinCallClick,
|
||||||
eventSink = eventSink,
|
onSwipeToReply = onSwipeToReply,
|
||||||
eventContentView = eventContentView,
|
eventSink = eventSink,
|
||||||
)
|
eventContentView = eventContentView,
|
||||||
if (selectableEvent != null && selectionProgress > 0f) {
|
)
|
||||||
val slideSign = if (layoutDirection == LayoutDirection.Ltr) -1f else 1f
|
},
|
||||||
SelectionIndicator(
|
{
|
||||||
selected = isSelected,
|
if (selectableEvent != null && selectionProgress > 0f) {
|
||||||
modifier = Modifier
|
val slideSign = if (layoutDirection == LayoutDirection.Ltr) -1f else 1f
|
||||||
.align(Alignment.CenterStart)
|
SelectionIndicator(
|
||||||
.graphicsLayer { translationX = slideSign * SELECTION_COLUMN_WIDTH.toPx() * (1f - selectionProgress) }
|
selected = isSelected,
|
||||||
.padding(start = 12.dp)
|
modifier = Modifier
|
||||||
.size(24.dp),
|
.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
|
{
|
||||||
.matchParentSize()
|
if (isSelectionModeActive && selectableEvent != null) {
|
||||||
.clickable { onContentClick(selectableEvent) }
|
// 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
|
private val SELECTION_COLUMN_WIDTH = 36.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user