Как стать автором
Обновить

Jetpack Compose: Expandable Text

Время на прочтение5 мин
Количество просмотров3.4K

На протяжении нескольких последних лет мобильная разработка движется в сторону декларативного пользовательского интерфейса. Кто-то начал раньше, кто-то – позже. Большой толчок развитию этого направления сообщество 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 

Теги:
Хабы:
Всего голосов 4: ↑4 и ↓0+4
Комментарии7

Публикации

Истории

Работа

iOS разработчик
15 вакансий
Swift разработчик
15 вакансий

Ближайшие события

22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
2 – 18 декабря
Yandex DataLens Festival 2024
МоскваОнлайн
11 – 13 декабря
Международная конференция по AI/ML «AI Journey»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань