Improve the way we cut the bubble layout to give space for the sender Avatar.

Instead of drawing a circle with the same color of the background behind the avatar, properly clear the top start corner of the bubble using `CompositingStrategy.Offscreen` and `BlendMode.Clear`.
This commit is contained in:
Benoit Marty 2024-06-24 11:04:01 +02:00
parent 2c220d3f92
commit 3270731f2b
3 changed files with 52 additions and 40 deletions

View file

@ -32,7 +32,13 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
@ -40,8 +46,10 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemGrou
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleStateProvider import io.element.android.features.messages.impl.timeline.model.bubble.BubbleStateProvider
import io.element.android.libraries.core.extensions.to01 import io.element.android.libraries.core.extensions.to01
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.messageFromMeBackground import io.element.android.libraries.designsystem.theme.messageFromMeBackground
@ -51,6 +59,7 @@ import io.element.android.libraries.testtags.testTag
private val BUBBLE_RADIUS = 12.dp private val BUBBLE_RADIUS = 12.dp
internal val BUBBLE_INCOMING_OFFSET = 16.dp internal val BUBBLE_INCOMING_OFFSET = 16.dp
private val avatarRadius = AvatarSize.TimelineSender.dp / 2
// Design says: The maximum width of a bubble is still 3/4 of the screen width. But try with 85% now. // Design says: The maximum width of a bubble is still 3/4 of the screen width. But try with 85% now.
private const val BUBBLE_WIDTH_RATIO = 0.85f private const val BUBBLE_WIDTH_RATIO = 0.85f
@ -66,11 +75,12 @@ fun MessageEventBubble(
content: @Composable () -> Unit = {}, content: @Composable () -> Unit = {},
) { ) {
fun bubbleShape(): Shape { fun bubbleShape(): Shape {
val topLeftCorner = if (state.cutTopStart) 0.dp else BUBBLE_RADIUS
return when (state.groupPosition) { return when (state.groupPosition) {
TimelineItemGroupPosition.First -> if (state.isMine) { TimelineItemGroupPosition.First -> if (state.isMine) {
RoundedCornerShape(BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp, BUBBLE_RADIUS) RoundedCornerShape(BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp, BUBBLE_RADIUS)
} else { } else {
RoundedCornerShape(BUBBLE_RADIUS, BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp) RoundedCornerShape(topLeftCorner, BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp)
} }
TimelineItemGroupPosition.Middle -> if (state.isMine) { TimelineItemGroupPosition.Middle -> if (state.isMine) {
RoundedCornerShape(BUBBLE_RADIUS, 0.dp, 0.dp, BUBBLE_RADIUS) RoundedCornerShape(BUBBLE_RADIUS, 0.dp, 0.dp, BUBBLE_RADIUS)
@ -84,7 +94,7 @@ fun MessageEventBubble(
} }
TimelineItemGroupPosition.None -> TimelineItemGroupPosition.None ->
RoundedCornerShape( RoundedCornerShape(
BUBBLE_RADIUS, topLeftCorner,
BUBBLE_RADIUS, BUBBLE_RADIUS,
BUBBLE_RADIUS, BUBBLE_RADIUS,
BUBBLE_RADIUS BUBBLE_RADIUS
@ -106,11 +116,30 @@ fun MessageEventBubble(
else -> ElementTheme.colors.messageFromOtherBackground else -> ElementTheme.colors.messageFromOtherBackground
} }
val bubbleShape = bubbleShape() val bubbleShape = bubbleShape()
val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx()
val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx()
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth(BUBBLE_WIDTH_RATIO) .fillMaxWidth(BUBBLE_WIDTH_RATIO)
.padding(horizontal = 16.dp) .padding(start = avatarRadius, end = 16.dp)
.offsetForItem(), .offsetForItem()
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
drawContent()
if (state.cutTopStart) {
drawCircle(
color = Color.Black,
center = Offset(
x = 0f,
y = yOffsetPx,
),
radius = radiusPx,
blendMode = BlendMode.Clear,
)
}
},
// Need to set the contentAlignment again (it's already set in TimelineItemEventRow), for the case // Need to set the contentAlignment again (it's already set in TimelineItemEventRow), for the case
// when content width is low. // when content width is low.
contentAlignment = if (state.isMine) Alignment.CenterEnd else Alignment.CenterStart contentAlignment = if (state.isMine) Alignment.CenterEnd else Alignment.CenterStart

View file

@ -17,7 +17,6 @@
package io.element.android.features.messages.impl.timeline.components package io.element.android.features.messages.impl.timeline.components
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.Orientation
@ -46,8 +45,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -59,7 +56,6 @@ import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -118,6 +114,12 @@ import kotlinx.coroutines.launch
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt import kotlin.math.roundToInt
// The bubble has a negative margin to be placed a bit upper regarding the sender
// information and overlap the avatar.
val NEGATIVE_MARGIN_FOR_BUBBLE = (-8).dp
// Width of the transparent border around the sender avatar
val SENDER_AVATAR_BORDER_WIDTH = 3.dp
@Composable @Composable
fun TimelineItemEventRow( fun TimelineItemEventRow(
event: TimelineItem.Event, event: TimelineItem.Event,
@ -289,13 +291,11 @@ private fun TimelineItemEventRowContent(
) = createRefs() ) = createRefs()
// Sender // Sender
val avatarStrokeSize = 3.dp
if (event.showSenderInformation && !timelineRoomInfo.isDm) { if (event.showSenderInformation && !timelineRoomInfo.isDm) {
MessageSenderInformation( MessageSenderInformation(
event.senderId, event.senderId,
event.senderProfile, event.senderProfile,
event.senderAvatar, event.senderAvatar,
avatarStrokeSize,
Modifier Modifier
.constrainAs(sender) { .constrainAs(sender) {
top.linkTo(parent.top) top.linkTo(parent.top)
@ -321,7 +321,7 @@ private fun TimelineItemEventRowContent(
MessageEventBubble( MessageEventBubble(
modifier = Modifier modifier = Modifier
.constrainAs(message) { .constrainAs(message) {
top.linkTo(sender.bottom, margin = -avatarStrokeSize - 8.dp) top.linkTo(sender.bottom, margin = NEGATIVE_MARGIN_FOR_BUBBLE)
this.linkStartOrEnd(event) this.linkStartOrEnd(event)
}, },
state = bubbleState, state = bubbleState,
@ -373,37 +373,17 @@ private fun MessageSenderInformation(
senderId: UserId, senderId: UserId,
senderProfile: ProfileTimelineDetails, senderProfile: ProfileTimelineDetails,
senderAvatar: AvatarData, senderAvatar: AvatarData,
avatarStrokeSize: Dp,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val avatarStrokeColor = MaterialTheme.colorScheme.background
val avatarSize = senderAvatar.size.dp
val avatarColors = AvatarColorsProvider.provide(senderAvatar.id, ElementTheme.isLightTheme) val avatarColors = AvatarColorsProvider.provide(senderAvatar.id, ElementTheme.isLightTheme)
Box( Row(modifier = modifier) {
modifier = modifier Avatar(senderAvatar)
) { Spacer(modifier = Modifier.width(4.dp))
// Background of Avatar, to erase the corner of the message content SenderName(
Canvas( senderId = senderId,
modifier = Modifier senderProfile = senderProfile,
.size(size = avatarSize + avatarStrokeSize) senderNameMode = SenderNameMode.Timeline(avatarColors.foreground),
.clipToBounds() )
) {
drawCircle(
color = avatarStrokeColor,
center = Offset(x = (avatarSize / 2).toPx(), y = (avatarSize / 2).toPx()),
radius = (avatarSize / 2 + avatarStrokeSize).toPx()
)
}
// Content
Row {
Avatar(senderAvatar)
Spacer(modifier = Modifier.width(4.dp))
SenderName(
senderId = senderId,
senderProfile = senderProfile,
senderNameMode = SenderNameMode.Timeline(avatarColors.foreground),
)
}
} }
} }

View file

@ -24,4 +24,7 @@ data class BubbleState(
val isMine: Boolean, val isMine: Boolean,
val isHighlighted: Boolean, val isHighlighted: Boolean,
val timelineRoomInfo: TimelineRoomInfo, val timelineRoomInfo: TimelineRoomInfo,
) ) {
/** True to cut out the top start corner of the bubble, to give margin for the sender avatar. */
val cutTopStart: Boolean = groupPosition.isNew() && !isMine && !timelineRoomInfo.isDm
}