На протяжении нескольких последних лет мобильная разработка движется в сторону декларативного пользовательского интерфейса. Кто-то начал раньше, кто-то – позже. Большой толчок развитию этого направления сообщество Android разработчиков получило благодаря языку программирования Kotlin, который отлично раскрывает данную концепцию. В 2019 Google представила свой фреймворк для создания декларативного UI: Jetpack Compose.
Подход имеет большое количество плюсов благодаря которым технология набирает популярность и развивается еще более динамично. Однако, как это часто бывает, новые технологии порой вынуждают нас заново изобретать решения старых проблем, об одной из которых я бы и хотел поговорить сегодня – компонент, который поддерживает отображение текста в свернутом и развернутом виде.
Как это часто бывает, можно найти решение проблемы на StackOverflow с галочкой
, однако оно имеет свои недостатки, обсудить и попробовать решить которые, я предлагаю в этой статье.
Предположим, мы работаем над приложением, посвященным шахматам, экран пользователя в котором выглядит подобным образом:
Для тех кто работал с Compose, нет никаких проблем реализовать данный экран при помощи стандартных компонентов. Однако если обратить внимание на поле Bio - можно заметить, что оно оканчивается строкой ... more
. При нажатии на которое разворачивается полный текст.
Чтобы добиться данного поведения, необходимо найти последнюю отображаемую строку, укоротить ее так, чтобы поместилось ... more
по нажатию на которую и будет открываться полный текст. При работе с Compose мы ограничены в выборе способов, которые позволяют вмешаться в процесс отрисовки. При поиске решения проблемы можно найти решение, основанное на использовании callback’а onTextLayout
(не привожу полный код такого подхода здесь) который вызывается при отрисовке компонента и содержит его размеры.
Получаем следующий результат:
Как можно заметить - получили необходимое поведение компонента. Однако, если разобрать решение внимательнее – оно базируется на onTextLayout
, т.е. прежде чем мы сможем добавить ... more
в последнюю строку – компонент должен быть отрисован и только после первой отрисовки мы можем произвести необходимые расчеты. И данный недостаток проявляет себя, когда на нашем экране появляется, скажем, загрузка (глич на месте появления `... more`):
Чтобы избавиться от данного недостатка, нам необходимо найти способ, как определить позицию, после которой в тексте нам необходимо добавить .. more
до отрисовки самого компонента. После некоторого времени за чтением документации, можно наткнуться на SubcomposeLayout,
который может позволить определить размеры компонента до его отрисовки. В нашем конкретном случае можно воспользоваться готовым компонентом, который скрываем нюансы работы с SubcomposeLayout
и дает доступ к размерам будущего компонента – BoxWithConstraints
, в комбинации с компонентом Paragraph
можем определить куда надо поместить нашу ссылку ... more
:
const val COLLAPSED_SPAN = "collapsed_span"
const val EXPANDED_SPAN = "expanded_span"
const val LINE_EXTRA_SPACE = 5
@Composable
fun ExpandableText(
text: String,
expandText: String,
modifier: Modifier = Modifier,
expandColor: Color = Color.Unspecified,
collapseText: String? = null,
collapseColor: Color = Color.Unspecified,
maxLinesCollapsed: Int = 5,
style: TextStyle = TextStyle.Default,
) {
BoxWithConstraints(modifier) {
val paragraph = Paragraph(
text = text,
style = style,
constraints = Constraints(maxWidth = constraints.maxWidth),
density = LocalDensity.current,
fontFamilyResolver = LocalFontFamilyResolver.current,
)
val trimLineRange: IntRange? = if (paragraph.lineCount > maxLinesCollapsed) {
paragraph.getLineStart(maxLinesCollapsed - 1)..paragraph.getLineEnd(maxLinesCollapsed - 1)
} else {
null
}
val expandState = SpanState(expandText, expandColor)
val collapseState = collapseText?.let { SpanState(it, collapseColor) }
val state = rememberState(text, expandState, collapseState, trimLineRange, style)
ClickableText(text = state.annotatedString, style = style, onClick = { position ->
val annotation = state.getClickableAnnotation(position)
when (annotation?.tag) {
COLLAPSED_SPAN -> state.expandState = State.ExpandState.Expanded
EXPANDED_SPAN -> state.expandState = State.ExpandState.Collapsed
else -> Unit
}
})
}
}
@Composable
private fun rememberState(
text: String,
expandSpanState: SpanState,
collapseSpanState: SpanState?,
lastLineRange: IntRange?,
style: TextStyle,
): State {
return remember(text, expandSpanState, collapseSpanState, lastLineRange, style) {
State(
text = text,
expandSpanState = expandSpanState,
collapseSpanState = collapseSpanState,
lastLineTrimRange = lastLineRange,
style = style,
)
}
}
private data class SpanState(
val text: String,
val color: Color,
)
private class State(
text: String,
expandSpanState: SpanState,
collapseSpanState: SpanState?,
lastLineTrimRange: IntRange?,
style: TextStyle,
) {
enum class ExpandState {
Collapsed, Expanded,
}
private val defaultAnnotatedText = buildAnnotatedString { append(text) }
private val collapsedAnnotatedText: AnnotatedString
private val expandedAnnotatedText: AnnotatedString
init {
collapsedAnnotatedText = lastLineTrimRange?.let {
val lastLineLen = lastLineTrimRange.last - lastLineTrimRange.first + 1
val expandTextLen = getSafeLength(expandSpanState.text)
val collapsedText =
text.take(lastLineTrimRange.last + 1).dropLast(minOf(lastLineLen, expandTextLen + LINE_EXTRA_SPACE))
val collapsedTextLen = getSafeLength(collapsedText)
val expandSpanStyle = style.merge(TextStyle(color = expandSpanState.color)).toSpanStyle()
buildAnnotatedString {
append(collapsedText)
append(expandSpanState.text)
addStyle(expandSpanStyle, start = collapsedTextLen, end = collapsedTextLen + expandTextLen)
addStringAnnotation(tag = COLLAPSED_SPAN,
annotation = "",
start = collapsedTextLen,
end = collapsedTextLen + expandTextLen)
}
} ?: defaultAnnotatedText
expandedAnnotatedText = collapseSpanState?.let { span ->
val collapseStyle = style.merge(TextStyle(color = span.color)).toSpanStyle()
val textLen = getSafeLength(text)
val collapsePostfix = "\n${span.text}"
val collapseLen = getSafeLength(collapsePostfix)
buildAnnotatedString {
append(text)
append(collapsePostfix)
addStyle(collapseStyle, start = textLen, end = textLen + collapseLen)
addStringAnnotation(tag = EXPANDED_SPAN,
annotation = "",
start = textLen,
end = textLen + collapseLen)
}
} ?: defaultAnnotatedText
}
var annotatedString: AnnotatedString by mutableStateOf(collapsedAnnotatedText)
private set
private val _expandState = mutableStateOf(ExpandState.Collapsed)
var expandState: ExpandState
set(value) {
_expandState.value = value
annotatedString = when (value) {
ExpandState.Collapsed -> collapsedAnnotatedText
ExpandState.Expanded -> expandedAnnotatedText
}
}
get() = _expandState.value
fun getClickableAnnotation(position: Int): AnnotatedString.Range<String>? {
return annotatedString.getStringAnnotations(position, position).firstOrNull {
it.tag == COLLAPSED_SPAN || it.tag == EXPANDED_SPAN
}
}
}
private fun getSafeLength(text: String): Int {
val iterator = BreakIterator.getCharacterInstance()
iterator.setText(text)
return iterator.last()
}
Как видим, решение избавлено от недостатка с первой отрисовкой компонента:
Приведенный способ также не лишен недостатков. Если присмотреться, можно найти магическое const val LINE_EXTRA_SPACE = 5
, которое нам необходимо, чтобы более гарантированно хватило места для добавления ссылки, которая будет разворачивать полный текст. Можно постараться найти более точное решение – например измерить длину текста ссылки и длину удаляемой строки. Однако задача усложняется тем, что ширина символов может быть разной, что потребует нескольких измерений. Это может чрезмерно усложнить код там, где это необязательно. Поэтому и был использован компромиссный LINE_EXTRA_SPACE
.
Полный код с примером использования можно найти на GitHub
Комментарии (5)
left-sinii
14.05.2023 15:21Спасибо за статью! Думаю что данное решение можно улучшить с помощью SubcomposeLayout иначе мы убьемся об LINE_EXTRA_SPACE при локализации приложения.
TheSecond Автор
14.05.2023 15:21Спасибо за замечание! В данном случае локализация скорее всего никак не повлияет. Основная функция `LINE_EXTRA_SPACE` в данном случае – постараться предотвратить перенос строки из-за разницы в ширине символов. Поскольку шрифт может быть не моноширинный, и в случае если мы удаляем скажем символ `a` и добавляем на его место `щ` - может произойти перенос строки. но если же мы уже удалим `aб` и добавим `щ` то переноса строки уже не будет.
Можно конечно попробовать точнее пройти в цикле и по одному удалять символы пока точно не найдем нужную ширину. Но, как указа в статье - это усложнит код, и можем повлиять на производительность
Rusrst
Да, static layout нет и его мягко говоря не хватает, но появилась возможность (не так давно насколько я знаю) измерения текста средствами самого compose (и его отрисовки собственно говоря для кастомных контроллов), в ту сторону не смотрели?
TheSecond Автор
Спасибо за наводку! Пока не смотрел. Проект использует немного устаревшую версию Compose (так как версия Compose завязана на версию Kotlin). К сожалению самые новые подходы пока не применить и приходится искать решения исходя из того что есть.
Если найду более изящные способы - дополню статью
Rusrst
Если что имел ввиду вот это, сама возможность появилась недавно, до этого рисовать текст только через нативный canvas можно было.
https://developer.android.com/jetpack/compose/graphics/draw/overview#common-drawing