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


  1. Rusrst
    14.05.2023 15:21
    +2

    Да, static layout нет и его мягко говоря не хватает, но появилась возможность (не так давно насколько я знаю) измерения текста средствами самого compose (и его отрисовки собственно говоря для кастомных контроллов), в ту сторону не смотрели?


    1. TheSecond Автор
      14.05.2023 15:21
      +1

      Спасибо за наводку! Пока не смотрел. Проект использует немного устаревшую версию Compose (так как версия Compose завязана на версию Kotlin). К сожалению самые новые подходы пока не применить и приходится искать решения исходя из того что есть.

      Если найду более изящные способы - дополню статью


      1. Rusrst
        14.05.2023 15:21
        +1

        Если что имел ввиду вот это, сама возможность появилась недавно, до этого рисовать текст только через нативный canvas можно было.

        https://developer.android.com/jetpack/compose/graphics/draw/overview#common-drawing


  1. left-sinii
    14.05.2023 15:21

    Спасибо за статью! Думаю что данное решение можно улучшить с помощью SubcomposeLayout иначе мы убьемся об LINE_EXTRA_SPACE при локализации приложения.


    1. TheSecond Автор
      14.05.2023 15:21

      Спасибо за замечание! В данном случае локализация скорее всего никак не повлияет. Основная функция `LINE_EXTRA_SPACE` в данном случае – постараться предотвратить перенос строки из-за разницы в ширине символов. Поскольку шрифт может быть не моноширинный, и в случае если мы удаляем скажем символ `a` и добавляем на его место `щ` - может произойти перенос строки. но если же мы уже удалим `aб` и добавим `щ` то переноса строки уже не будет.

      Можно конечно попробовать точнее пройти в цикле и по одному удалять символы пока точно не найдем нужную ширину. Но, как указа в статье - это усложнит код, и можем повлиять на производительность