Разработка
Выделение слов в Compose
Мне стало любопытно, как это можно сделать в композите Text, так что давайте разбираться!
Недавно я наткнулся на несколько ИИ-приложений, переводящих текст в речь, и заметил, что у них есть одна общая особенность в пользовательском интерфейсе — выделение в тексте проговариваемого слова. Мне стало любопытно, как это можно сделать в композите Text
, так что давайте разбираться!
Я не буду вдаваться в подробности того, как выделение слова синхронизируется с тем, что говорит приложение, поэтому давайте просто создадим текст и несколько кнопок для управления положением выделения.
@Composable fun TextHighlightSample(modifier: Modifier) { Column( modifier = modifier.fillMaxSize(), vecrticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { val text = """ Text is a central piece of any UI, and Jetpack Compose makes it easier to display or write text. Compose leverages composition of its building blocks, meaning you don’t need to overwrite properties and methods or extend big classes to have a specific composable design and logic working the way you want. As its base, Compose provides a BasicText and BasicTextField, which are the barebones to display text and handle user input. At a higher level, Compose provides Text and TextField, which are composables following Material Design guidelines. It’s recommended to use them as they have the right look and feel for users on Android, and includes other options to simplify their customization without having to write a lot of code. """.trimIndent() var wordIndex: Int by remember { mutableIntStateOf(0) } Text( text = text, style = MaterialTheme.typography.bodyMedium, ) Button(onClick = { wordIndex++ }) { Text(text = "Next word") } Button(onClick = { wordIndex = 0 }) { Text(text = "Reset") } } }
Чтобы получить информацию о том, как выделить каждое слово, мы можем использовать параметр onTextLayout
, который предоставляет нам лямбду для TextLayoutResult
. Этот класс содержит множество полезных значений и методов, но нас интересует fun getBoundingBox(offset: Int)
. Метод возвращает Rect
, ограничивающий текстовый символ с заданной позицией смещения. Но мы хотим получить границы целого слова, а не одного символа, поэтому создадим функцию расширения, которая будет возвращать границы Rect
для заданного IntRange
.
fun TextLayoutResult.getBoundingBoxForRange(range: IntRange): Rect { val start = range.first val end = range.last + 1 // Include last character val startBoundingBox = getBoundingBox(start) val endBoundingBox = getBoundingBox(end - 1) return Rect( startBoundingBox.left, startBoundingBox.top, endBoundingBox.right, endBoundingBox.bottom ).inflate(10f) // inflate the Rect to make it bigger than the word }
Теперь воспользуемся этим методом, чтобы создать Rect
для каждого слова в тексте.
fun extractWordRects(text: String, layoutResult: TextLayoutResult): List<Pair<String, Rect>> { val words = text.split("\\s+".toRegex()) // Split text into words val wordRects = mutableListOf<Pair<String, Rect>>() var startIndex = 0 for (word in words) { while (word.first() != text[startIndex]) { // A little bit hacky, but we need to shift the index if the first character don't match // the character at given index. This ensures that the newline character is skipped // and we actually calculate the word range properly. startIndex++ } val wordRange = startIndex until (startIndex + word.length) val rect = layoutResult.getBoundingBoxForRange(wordRange) wordRects.add(word to rect) startIndex += word.length + 1 // Move past the word and the following space } return wordRects }
Мы разделили текст на список слов и провели итерацию по всем словам, чтобы вычислить их смещение IntRange
, которое используется для создания ограничивающего Rect
. Затем мы можем просто вернуть список слов с их Rect
.
Давайте объединим все это с нашим композитом Text
и отрисуем выделение с помощью onDrawBehind
. Поскольку для отрисовки выделения нам нужно верхний левый Offset
и Size
прямоугольника, мы можем использовать API анимации Compose, чтобы сделать его еще лучше.
var wordRects by remember { mutableStateOf(emptyList<Pair<String, Rect>>()) } val currentRectTopLeft by animateOffsetAsState( targetValue = if (wordRects.isEmpty()) { Offset.Zero } else { wordRects[wordIndex].second.topLeft }, label = "currentRectTopLeft" ) val currentRectSize by animateSizeAsState( targetValue = if (wordRects.isEmpty()) { Size.Zero } else { wordRects[wordIndex].second.size }, label = "currentRectSize" ) Text( text = text, style = MaterialTheme.typography.titleMedium , modifier = Modifier .padding(24.dp) .drawBehind { drawRoundRect( Color(0xFFffd31c), currentRectTopLeft, currentRectSize, CornerRadius(20f, 20f) ) }, onTextLayout = { textLayoutResult -> wordRects = extractWordRects(text, textLayoutResult) } )
Вот и все. Вот конечный результат:
Полный код можно найти здесь.