Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Jetpack compose - draw background behind text

I would like to draw a background behind some AnnotatedString in Jetpack Compose (Example here). Using the view system, we could do this by writing a custom text view - https://medium.com/androiddevelopers/drawing-a-rounded-corner-background-on-text-5a610a95af5. Is there a way to do this using Jetpack Compose?

I was looking at the draw modifiers for Text but I can't seem to figure out how to get the line number or the start/end of the text I need to draw the background on.

Should I use Canvas instead of Text?

like image 366
Andrei Teslovan Avatar asked Aug 19 '21 09:08

Andrei Teslovan


1 Answers

The main source of information about Text characters layout is TextLayoutResult which can be received with onTextLayout parameter

It's API is far from perfect. getPathForRange returns Path which is exactly what you need, but as any other Path it cannot be modified, e.g. you won't be able to round corners of this path, not much difference from plain SpanStyle background

There's getBoundingBox which only returns frame of one character. I played with it, and got the list of Rects for a selected range:

fun TextLayoutResult.getBoundingBoxesForRange(start: Int, end: Int): List<Rect> {
    var prevRect: Rect? = null
    var firstLineCharRect: Rect? = null
    val boundingBoxes = mutableListOf<Rect>()
    for (i in start..end) {
        val rect = getBoundingBox(i)
        val isLastRect = i == end
        
        // single char case
        if (isLastRect && firstLineCharRect == null) {
            firstLineCharRect = rect
            prevRect = rect
        }

        // `rect.right` is zero for the last space in each line
        // looks like an issue to me, reported: https://issuetracker.google.com/issues/197146630
        if (!isLastRect && rect.right == 0f) continue

        if (firstLineCharRect == null) {
            firstLineCharRect = rect
        } else if (prevRect != null) {
            if (prevRect.bottom != rect.bottom || isLastRect) {
                boundingBoxes.add(
                    firstLineCharRect.copy(right = prevRect.right)
                )
                firstLineCharRect = rect
            }
        }
        prevRect = rect
    }
    return boundingBoxes
}

Now you can draw these rects on the Canvas:

Box(Modifier.padding(10.dp)) {
    val text =
        "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
    val selectedParts = listOf(
        "consectetur adipiscing",
        "officia deserunt",
        "dolore magna aliqua. Ut enim ad minim veniam, quis nostrud",
        "consequat.",
    )
    var selectedPartPaths by remember { mutableStateOf(listOf<Path>()) }
    Text(
        text,
        style = MaterialTheme.typography.h6,
        onTextLayout = { layoutResult ->
            selectedPartPaths = selectedParts.map { part ->
                val cornerRadius = CornerRadius(x = 20f, y = 20f)
                Path().apply {
                    val startIndex = text.indexOf(part)
                    val boundingBoxes = layoutResult
                        .getBoundingBoxesForRange(
                            start = startIndex,
                            end = startIndex + part.count()
                        )
                    for (i in boundingBoxes.indices) {
                        val boundingBox = boundingBoxes[i]
                        val leftCornerRoundRect =
                            if (i == 0) cornerRadius else CornerRadius.Zero
                        val rightCornerRoundRect =
                            if (i == boundingBoxes.indices.last) cornerRadius else CornerRadius.Zero
                        addRoundRect(
                            RoundRect(
                                boundingBox.inflate(verticalDelta = -2f, horizontalDelta = 7f),
                                topLeft = leftCornerRoundRect,
                                topRight = rightCornerRoundRect,
                                bottomRight = rightCornerRoundRect,
                                bottomLeft = leftCornerRoundRect,
                            )
                        )
                    }
                }
            }
        },
        modifier = Modifier.drawBehind {
            selectedPartPaths.forEach { path ->
                drawPath(path, style = Fill, color = Color.Blue.copy(alpha = 0.2f))
                drawPath(path, style = Stroke(width = 2f), color = Color.Blue)
            }
        }
    )
}

fun Rect.inflate(verticalDelta: Float, horizontalDelta: Float) =
    Rect(
        left = left - horizontalDelta,
        top = top - verticalDelta,
        right = right + horizontalDelta,
        bottom = bottom + verticalDelta,
    )

Result:

like image 181
Philip Dukhov Avatar answered Oct 02 '22 22:10

Philip Dukhov