Поле ввода форматированного текста

Поле ввода форматированного текста позволяет пользователям форматировать вводимый текст, например выделять слова полужирным шрифтом, добавлять заголовки, выравнивать абзацы или вставлять выделенный @mention. В Compose это реализуется на основе текстовых полей, зависящих от состояния (BasicTextField и TextField). Стили хранятся в TextFieldState вместе с текстом, поэтому они сохраняются при редактировании, изменении выделения и рекомпозиции, а также синхронизируются, когда пользователь вводит, удаляет и вставляет текст.

На этой странице показано, как применять, обновлять, запрашивать, извлекать и удалять стили в TextFieldState с помощью TextFieldBuffer, а также как управлять панелью инструментов форматирования из TextFieldState.textStyles.

Как применять стили к строкам и абзацам

Вы можете применить форматирование на уровне символов и абзацев в любой области получателя TextFieldBuffer, включая TextFieldState.edit, InputTransformation.transformInput и OutputTransformation.transformOutput.

Управление расширением границ с помощью ExpandPolicy

При вызове addStyle вы можете передать ExpandPolicy, чтобы определить, как будет вести себя диапазон со стилем, когда пользователь вставляет новые символы непосредственно на его границе start или end:

  • Вставка внутри (start, end) – всегда расширяет диапазон с заданным стилем.
  • Вставка перед start. Всегда сдвигает вправо смещения как start, так и end.
  • Вставка непосредственно в start или end. Управляется указанным вами ExpandPolicy.

В таблице ниже описаны доступные варианты ExpandPolicy и типичные сценарии их использования.

Правила Поведение на границе Рекомендуемые варианты использования Пример
ExpandPolicy.InsideOnly Текст, введенный в start или end, остается за пределами диапазона со стилем. Отдельные токены, такие как @mentions, #hashtags или фоновые элементы, где ввод текста рядом с тегом не должен наследовать цвет фона тега. Анимация, показывающая, что текст, введенный в начале или конце диапазона ExpandPolicy.InsideOnly, остается без стиля.
ExpandPolicy.AtStart Текст, введенный в точке start, унаследует стиль, а текст, введенный в точке end, останется без стиля. Текст, введенный непосредственно перед стилизованным текстом, также должен иметь такое же форматирование. Анимация, показывающая, что текст, введенный в начале диапазона ExpandPolicy.AtStart, наследует стиль, а текст, введенный в конце, остается без стиля.
ExpandPolicy.AtEnd Текст, введенный в точке end, наследует стиль, а текст, введенный в точке start, остается без стиля. Стандартный ввод в редакторе форматированного текста, когда при продолжении ввода после слова, выделенного полужирным шрифтом или курсивом, форматирование сохраняется. Анимация, показывающая, что текст, введенный в конце диапазона ExpandPolicy.AtEnd, наследует стиль, а текст, введенный в начале, остается без стиля.
ExpandPolicy.AtBoth Текст, введенный в точке start или end, расширяет диапазон и наследует стиль. Активные выделения или непрерывные форматированные блоки, которые должны увеличиваться с любого края. Анимация, показывающая, что текст, введенный в начале или в конце диапазона ExpandPolicy.AtBoth, наследует стиль.

Как форматировать символы с помощью SpanStyle

Используйте addStyle с SpanStyle, чтобы применить форматирование на уровне символов, например цвет текста, выделение фона, FontWeight, FontStyle или подчеркивание к определенному TextRange в буфере.

Основные действия для диапазонов SpanStyle

  • Порядок слоев. Стили применяются в том порядке, в котором вы добавляете их в буфер. Если несколько перекрывающихся диапазонов SpanStyle задают одно и то же свойство с разными значениями, то стиль, добавленный позже, будет отображаться поверх остальных.
  • Требование к непустому диапазону. Необходимо передать несвернутый диапазон, в котором start < end. Вызов addStyle для свернутого фрагмента длиной 0 не дает результата, а передача обратных или выходящих за пределы индексов приводит к ошибке IllegalArgumentException.

TextField(
    state = rememberTextFieldState(),
    inputTransformation = InputTransformation {
        for (i in 0 until changes.changeCount) {
            val range = changes.getRange(i)
            if (!range.collapsed) {
                addStyle(
                    spanStyle = SpanStyle(
                        fontWeight = FontWeight.ExtraBold,
                        fontStyle = FontStyle.Italic,
                        color = Color.Red,
                    ),
                    range = range,
                    expandPolicy = ExpandPolicy.AtEnd
                )
            }
        }
    }
)

Текстовое поле, в котором при вводе текста динамически применяется полужирный красный курсив.
Рисунок 1. Применение полужирного и курсивного красного форматирования к пользовательскому вводу динамически.

Применить форматирование блока с помощью ParagraphStyle

Используйте addStyle с ParagraphStyle, чтобы применить структурное форматирование на уровне блока, например textAlign, lineHeight, textDirection или textIndent, к диапазону абзацев.

fun setParagraphAlignment(state: TextFieldState, alignment: TextAlign) {
    val targetRange = if (state.selection.collapsed) {
        TextRange(0, state.text.length)
    } else {
        state.selection
    }
    if (targetRange.collapsed) return
    state.edit {
        // 1. Remove existing paragraph styles in this range
        getParagraphStyles(targetRange).forEach { removeStyle(it) }
        // 2. Apply the new paragraph alignment
        addStyle(
            paragraphStyle = ParagraphStyle(textAlign = alignment),
            range = targetRange,
            expandPolicy = ExpandPolicy.InsideOnly
        )
    }
}

Текстовое поле, в котором можно выравнивать абзацы по началу, центру и концу с помощью ParagraphStyle.
Рисунок 2. Переключение выравнивания абзаца между началом, центром и концом с помощью ParagraphStyle.

Чего следует избегать при использовании ParagraphStyle

Поскольку атрибут ParagraphStyle применяется к целым блокам текста, не используйте его в следующих случаях:

  • Расширение на соседние абзацы. Тщательно настройте ExpandPolicy, чтобы соседние диапазоны ParagraphStyle не перекрывались после изменений, внесенных пользователем. Например, если [0, 5) использует ExpandPolicy.AtEnd, а [5, 10) – ExpandPolicy.AtStart, то вставка текста в индекс 5 расширяет оба стиля до перекрытия и выдает IllegalArgumentException.
  • Вложенные диапазоны ParagraphStyle. Хотя полностью вложенные диапазоны (например, [0, 10) и [2, 8)) допустимы в AnnotatedString, избегайте вложенных диапазонов ParagraphStyle в текстовом поле. Макет разделяет вложенные диапазоны на отдельные абзацы, и последующие изменения могут случайно превратить вложенные диапазоны в частичное перекрытие.

Как изменить стиль с помощью TrackedRange

Вызов метода addStyle с параметрами TextRange и ExpandPolicy возвращает дескриптор TrackedRange, который может быть TrackedRange<SpanStyle> или TrackedRange<ParagraphStyle>. TrackedRange – это динамическая ссылка на интервал стиля в TextFieldBuffer, которая автоматически обновляет смещения символов start и end при вставке, удалении или замене текста вокруг нее.

Изменяемость на месте

Чтобы обновить существующий стиль или скорректировать его границы, не нужно вызывать removeStyle и addStyle. В области действия TextFieldBuffer TrackedRange предоставляет свойства расширения, которые обновляют интервал на месте, сохраняя его исходный порядок наложения относительно других стилей:

  • TrackedRange<SpanStyle>.spanStyle – считывает или заменяет полезную нагрузку SpanStyle в TrackedRange<SpanStyle>.
  • TrackedRange<ParagraphStyle>.paragraphStyle: считывает или заменяет полезную нагрузку ParagraphStyle на TrackedRange<ParagraphStyle>.
  • TrackedRange<*>.textRange – считывает или обновляет текущие границы TextRange на месте. Если задать свернутый, обратный или выходящий за границы диапазон, будет выдана ошибка IllegalArgumentException.
  • TrackedRange<*>.expandPolicy: считывает или обновляет ExpandPolicy на месте.
  • TrackedRange<*>.isValid: возвращает true, если интервал стиля по-прежнему активен в буфере, или false, если в результате изменений его длина сократилась до 0 или он был удален.

// Trim an existing bold span in place when the selection overlaps one edge
val range = style.textRange
if (range.start < selection.min && range.end <= selection.max) {
    // Selection covers the end of the span - shrink end boundary in place
    style.textRange = TextRange(range.start, selection.min)
} else if (range.start >= selection.min && range.end > selection.max) {
    // Selection covers the start of the span - shrink start boundary in place
    style.textRange = TextRange(selection.max, range.end)
}

Ограничение области действия и правила жизненного цикла

При работе с экземплярами TrackedRange учитывайте следующие правила, касающиеся области действия и жизненного цикла:

  • Ограничение областью видимости TextFieldBuffer. Свойства расширения TrackedRange существуют только в области видимости TextFieldBuffer, в которой был создан или запрошен дескриптор.
  • Автоматическое удаление при свертывании. Если пользователь удаляет все символы, к которым применяется TrackedRange, длина стиля сокращается до 0, isValid возвращает false, а стиль удаляется из буфера.

Как запрашивать и удалять стили

Compose предоставляет два способа проверки активных стилей в зависимости от того, изменяете ли вы буфер или считываете состояние для своего пользовательского интерфейса:

  • Внутри блока TextFieldBuffer вызовите getSpanStyles и getParagraphStyles непосредственно в получателе TextFieldBuffer, чтобы получить изменяемые дескрипторы TrackedRange.
  • Вне блока редактирования. Чтение TextFieldState.textStyles, которое возвращает неизменяемые списки AnnotatedString.Range, отражающие зафиксированное состояние.

Запрос пересекающихся стилей в TextFieldBuffer

TextFieldBuffer.getSpanStyles и TextFieldBuffer.getParagraphStyles возвращают все экземпляры TrackedRange, которые пересекаются с заданным TextRange, в порядке их добавления в буфер.

Например, вы можете запросить getSpanStyles(selection) внутри state.edit, чтобы проверить или обновить стили, охватывающие текущий выбор пользователя:

state.edit {
    if (selection.collapsed) return@edit
    // Query all bold spans that overlap with the current selection
    val boldStyles: List<TrackedRange<SpanStyle>> = getSpanStyles(selection).filter {
        it.spanStyle.fontWeight == FontWeight.Bold
    }
    // ...
}

Интервалы стиля используют полуоткрытый математический диапазон [start, end). Возвращаемый стиль зависит от того, запрашиваете ли вы выделенный фрагмент или одну точку курсора.

  • Для непустого выбора [start, end):
    • Возвращаемое значение. Любой стиль, который пересекается с диапазоном выбора, содержит его или находится внутри него. Например, запрос [5, 15) соответствует [0, 20), [8, 12) или [0, 10).
    • Не возвращаются: стили, которые затрагивают только внешние границы, не перекрывая символы. Например, запрос [5, 15) исключает [0, 5) и [15, 25).
  • Для свернутого курсора [index, index]:
    • Возвращаемое значение: любой стиль, содержащий курсор или начинающийся с позиции курсора. Например, курсор в 10 соответствует [0, 20) и [10, 20).
    • Не возвращается (правило касания конца). Любой стиль, заканчивающийся в позиции курсора. Например, если курсор находится на 10, то [0, 10) не будет включено.

Удалить или переключить стили с помощью removeStyle

Передайте дескриптор TrackedRange в removeStyle, чтобы удалить определенный интервал стиля из TextFieldBuffer.

removeStyle возвращает true, если TrackedRange найден и удален, или false, если он уже удален или принадлежит другому буферу. Удаляется только указанный маркер TrackedRange, а другие перекрывающиеся стили остаются без изменений.

fun toggleBold(state: TextFieldState) {
    state.edit {
        if (selection.collapsed) return@edit
        // Query all bold spans that overlap with the current selection
        val boldStyles: List<TrackedRange<SpanStyle>> = getSpanStyles(selection).filter {
            it.spanStyle.fontWeight == FontWeight.Bold
        }
        if (boldStyles.isNotEmpty()) {
            // Remove or trim existing bold spans covering the selection
            for (style in boldStyles) {
                val range = style.textRange
                if (range.start >= selection.min && range.end <= selection.max) {
                    removeStyle(style)
                } else if (range.start < selection.min && range.end > selection.max) {
                    val oldEnd = range.end
                    style.textRange = TextRange(range.start, selection.min)
                    addStyle(
                        spanStyle = SpanStyle(fontWeight = FontWeight.Bold),
                        range = TextRange(selection.max, oldEnd),
                        expandPolicy = ExpandPolicy.AtEnd
                    )
                } else if (range.start < selection.min) {
                    style.textRange = TextRange(range.start, selection.min)
                } else {
                    style.textRange = TextRange(selection.max, range.end)
                }
            }
        } else {
            // No bold spans intersect the selection - apply bold
            addStyle(
                spanStyle = SpanStyle(fontWeight = FontWeight.Bold),
                range = selection,
                expandPolicy = ExpandPolicy.AtEnd
            )
        }
    }
}

Текстовое поле, в котором можно включать и отключать полужирное начертание для выбранных слов.
Рисунок 3. Выделение текста полужирным шрифтом и отмена выделения.

Извлечение или сериализация форматированного текста

Чтобы показать отформатированные входные данные в composable-функции Text, доступной только для чтения, или сериализовать их в бэкенд-службу, прочитайте TextFieldState.textStyles за пределами блока редактирования.

state.textStyles.getSpanStyles и state.textStyles.getParagraphStyles возвращают List<AnnotatedString.Range<SpanStyle>> и List<AnnotatedString.Range<ParagraphStyle>>, которые можно передать непосредственно в конструктор AnnotatedString:

fun TextFieldState.toAnnotatedString(): AnnotatedString {
    val fullRange = TextRange(0, text.length)
    return AnnotatedString(
        text = text.toString(),
        spanStyles = textStyles.getSpanStyles(fullRange),
        paragraphStyles = textStyles.getParagraphStyles(fullRange)
    )
}

Полный пример в Jetchat

Чтобы увидеть полную реализацию, которая сочетает в себе встроенное оформление символов (полужирный шрифт, курсив, подчеркивание, зачеркивание и цвет), посмотрите пример Jetchat на GitHub:

  • RichTextState.kt – инкапсулирует запросы стилей TextFieldState, переключение диапазонов, обрезку или разделение по границам выделения и т. д.
  • UserInput.kt – связывает RichTextState с панелью форматирования текста и полем ввода сообщения.
Пример приложения Jetchat, демонстрирующий форматирование текста в строке ввода сообщения.
Рисунок 4. Редактирование форматированного текста в Jetchat.