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:
parent
2c220d3f92
commit
3270731f2b
3 changed files with 52 additions and 40 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue