Поле ввода форматированного текста позволяет пользователям форматировать вводимый текст, например выделять слова полужирным шрифтом, добавлять заголовки, выравнивать абзацы или вставлять выделенный @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.AtStart |
Текст, введенный в точке start, унаследует стиль, а текст, введенный в точке end, останется без стиля. |
Текст, введенный непосредственно перед стилизованным текстом, также должен иметь такое же форматирование. | ![]() |
ExpandPolicy.AtEnd |
Текст, введенный в точке end, наследует стиль, а текст, введенный в точке start, остается без стиля. |
Стандартный ввод в редакторе форматированного текста, когда при продолжении ввода после слова, выделенного полужирным шрифтом или курсивом, форматирование сохраняется. | ![]() |
ExpandPolicy.AtBoth |
Текст, введенный в точке start или end, расширяет диапазон и наследует стиль. |
Активные выделения или непрерывные форматированные блоки, которые должны увеличиваться с любого края. | ![]() |
Как форматировать символы с помощью 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 ) } } } )
Применить форматирование блока с помощью 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.Чего следует избегать при использовании 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 ) } } }
Извлечение или сериализация форматированного текста
Чтобы показать отформатированные входные данные в 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с панелью форматирования текста и полем ввода сообщения.



