diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt index 0dcf489fe3..7f4375e632 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt @@ -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) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index 7f08aa465e..d547d823a8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -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 /**