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 <android@element.io>
This commit is contained in:
Jorge Martin Espinosa
2026-03-23 18:11:55 +01:00
committed by GitHub
parent 13bbd24df1
commit a2d9f241dd
2 changed files with 70 additions and 23 deletions

View File

@@ -22,14 +22,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember 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.drawWithContent import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color 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.graphics.layer.CompositingStrategy
import androidx.compose.ui.platform.LocalLayoutDirection
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
@@ -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.designsystem.theme.messageFromOtherBackground
import io.element.android.libraries.testtags.TestTags 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.graphics.drawInLayer
import io.element.android.libraries.ui.utils.time.isTalkbackActive import io.element.android.libraries.ui.utils.time.isTalkbackActive
private val BUBBLE_RADIUS = 12.dp private val BUBBLE_RADIUS = 12.dp
@@ -78,33 +77,46 @@ fun MessageEventBubble(
.onKeyboardContextMenuAction(onLongClick) .onKeyboardContextMenuAction(onLongClick)
} }
val cutTopStart = state.cutTopStart
// Ignore state.isHighlighted for now, we need a design decision on it. // Ignore state.isHighlighted for now, we need a design decision on it.
val backgroundBubbleColor = MessageEventBubbleDefaults.backgroundBubbleColor(state.isMine) val backgroundBubbleColor = MessageEventBubbleDefaults.backgroundBubbleColor(state.isMine)
val bubbleShape = remember(state) { MessageEventBubbleDefaults.shape(state.cutTopStart, state.groupPosition, state.isMine) } val bubbleShape = remember(state) { MessageEventBubbleDefaults.shape(state.cutTopStart, state.groupPosition, state.isMine) }
val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx() val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx()
val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx() val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx()
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
BoxWithConstraints( BoxWithConstraints(
modifier = modifier modifier = modifier
.graphicsLayer { .drawWithCache {
shape = bubbleShape // Calculate the outline of the background and cache it
clip = true val outline = bubbleShape.createOutline(size, layoutDirection, this)
compositingStrategy = CompositingStrategy.Offscreen
} onDrawWithContent {
.drawWithContent { // 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) drawRect(backgroundBubbleColor)
// Then draw the content on top of it
drawContent() drawContent()
if (state.cutTopStart) {
// And then clip the top start corner if needed to make room for the avatar
if (cutTopStart) {
drawCircle( drawCircle(
color = Color.Black, color = Color.Black,
center = Offset( center = Offset(
x = if (isRtl) size.width else 0f, x = if (layoutDirection == LayoutDirection.Rtl) size.width else 0f,
y = yOffsetPx, y = yOffsetPx,
), ),
radius = radiusPx, radius = radiusPx,
blendMode = BlendMode.Clear, 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.

View File

@@ -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)
}