From a2d9f241ddd680c4fd23abfe3ac099a413c4315c Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Mon, 23 Mar 2026 18:11:55 +0100 Subject: [PATCH] Fix long messages not being clickable (#6356) * Fix long messages not being clickable As @bmarty found out, `clip = true` causes the click event to be ignored in some cases. Since we have the shape we want to draw and we're using a custom `onDraw` modifier anyway to cut-out part of the path, we can just draw everything using the modifier and avoid using `clip = true`. This seems to fix the issue. * Fix clipping of images or other items that cover the bubble * Fix borders being displayed for contents * Extract the layer drawing logic into `drawInLayer` to simplify the inlined code. Remove redundant code, those changes are now in the `drawInLayer` block * Workaround for lint issue: it seems like detekt can't properly detect usages in content receivers * Update screenshots --------- Co-authored-by: ElementBot --- .../timeline/components/MessageEventBubble.kt | 58 +++++++++++-------- .../ui/utils/graphics/DrawInLayer.kt | 35 +++++++++++ 2 files changed, 70 insertions(+), 23 deletions(-) create mode 100644 libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/graphics/DrawInLayer.kt 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 8923b0c963..aa5aaa2075 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 @@ -22,14 +22,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.drawWithCache 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.graphicsLayer -import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.graphics.layer.CompositingStrategy import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp @@ -49,6 +47,7 @@ import io.element.android.libraries.designsystem.theme.messageFromMeBackground import io.element.android.libraries.designsystem.theme.messageFromOtherBackground import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.ui.utils.graphics.drawInLayer import io.element.android.libraries.ui.utils.time.isTalkbackActive private val BUBBLE_RADIUS = 12.dp @@ -78,32 +77,45 @@ fun MessageEventBubble( .onKeyboardContextMenuAction(onLongClick) } + val cutTopStart = state.cutTopStart // Ignore state.isHighlighted for now, we need a design decision on it. val backgroundBubbleColor = MessageEventBubbleDefaults.backgroundBubbleColor(state.isMine) val bubbleShape = remember(state) { MessageEventBubbleDefaults.shape(state.cutTopStart, state.groupPosition, state.isMine) } val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx() val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx() - val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl BoxWithConstraints( modifier = modifier - .graphicsLayer { - shape = bubbleShape - clip = true - compositingStrategy = CompositingStrategy.Offscreen - } - .drawWithContent { - drawRect(backgroundBubbleColor) - drawContent() - if (state.cutTopStart) { - drawCircle( - color = Color.Black, - center = Offset( - x = if (isRtl) size.width else 0f, - y = yOffsetPx, - ), - radius = radiusPx, - blendMode = BlendMode.Clear, - ) + .drawWithCache { + // Calculate the outline of the background and cache it + val outline = bubbleShape.createOutline(size, layoutDirection, this) + + onDrawWithContent { + // Draw the contents in a layer to be able to clip them with the same outline + // For some reason, doing this clipping outside a layer messes up with the touch events + drawInLayer( + composingStrategy = CompositingStrategy.Offscreen, + outline = outline, + clip = true, + ) { + // Draw the background first, so that it's behind the content + drawRect(backgroundBubbleColor) + + // Then draw the content on top of it + drawContent() + + // And then clip the top start corner if needed to make room for the avatar + if (cutTopStart) { + drawCircle( + color = Color.Black, + center = Offset( + x = if (layoutDirection == LayoutDirection.Rtl) size.width else 0f, + y = yOffsetPx, + ), + radius = radiusPx, + blendMode = BlendMode.Clear, + ) + } + } } }, // Need to set the contentAlignment again (it's already set in TimelineItemEventRow), for the case diff --git a/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/graphics/DrawInLayer.kt b/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/graphics/DrawInLayer.kt new file mode 100644 index 0000000000..996b80f17a --- /dev/null +++ b/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/graphics/DrawInLayer.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.ui.utils.graphics + +import androidx.compose.ui.draw.CacheDrawScope +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.layer.CompositingStrategy +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.graphics.layer.setOutline + +/** + * Draws the content of [recordBlock] in a separate layer, which can be customized using [composingStrategy], [outline] and [clip]. + */ +context(scope: androidx.compose.ui.graphics.drawscope.DrawScope) +fun CacheDrawScope.drawInLayer( + composingStrategy: CompositingStrategy = CompositingStrategy.Auto, + outline: Outline? = null, + clip: Boolean = false, + recordBlock: ContentDrawScope.() -> Unit, +) { + val layer = obtainGraphicsLayer().apply { + this.compositingStrategy = composingStrategy + this.clip = clip + outline?.let { this.setOutline(it) } + + record(block = recordBlock) + } + scope.drawLayer(layer) +}