Старший разработчик ПО
Путь к надежному редактированию текста в Compose завершился полной переработкой BasicTextField. Переход на архитектуру с буфером позволил раз и навсегда устранить состояния гонки в IME. На этом занятии мы разберем, почему устаревшая синхронизация ввода должна была уйти и как TextFieldState синхронизирует обновления клавиатуры. Затем мы подробно рассмотрим форматированное редактирование, чтобы изучить механику TrackedRange, затраты на производительность блоков редактирования и реальные проблемы, связанные с границами стилей, а также заглянем в рендеринг Markdown.
TextFieldState заменяет перегрузки на основе обратного вызова в Compose. Он обновляется синхронно с вводом пользователя, устраняя проблемы со временем при использовании виртуальных клавиатур и ненужные рекомпозиции.TextFieldBuffer. Вы можете перехватывать изменения перед их фиксацией или форматировать отображаемый текст без ручного вычисления сопоставлений смещений.TextFieldState зависит от потребностей вашей архитектуры. Вы можете хранить его локально в composable-функции или непосредственно в ViewModel для доступа к бизнес-логике.TextFieldBuffer. Вы можете применять стили с настраиваемыми параметрами ExpandPolicy, а существующие диапазоны стилей будут обновляться автоматически по мере редактирования пользователями.Расшифровка
Здравствуйте!
Спасибо, что присоединились к нам.
Меня зовут Анастасия, а это мой коллега Хаоюй.
Сегодня мы покажем вам, как создать надежный интерфейс редактирования текста.
Создаете ли вы простой экран входа, панель поиска по мере ввода или сложный мессенджер, текстовые поля лежат в основе того, как пользователи взаимодействуют с вашими приложениями для Android.
Большинство из вас, вероятно, уже используют текстовые поля Material или текстовые поля с контуром.
TextField(state = rememberTextFieldState())OutlinedTextField(state = rememberTextFieldState())Также есть BasicTextField, если вы создаете собственную систему проектирования или вам нужно простое поле ввода.
BasicTextField(state = rememberTextFieldState())Также есть SecureTextField, который специально разработан для ввода паролей.
SecureTextField(state = rememberTextFieldState())А BasicTextField – это компонент без стиля, который фактически управляет всеми остальными текстовыми полями в Compose.
Возможно, вы заметили, что в последние несколько лет у нас параллельно существовали две формы API: одна принимала значение и обратный вызов изменения значения, а другая – объект TextFieldState.
@Composable
public fun BasicTextField(
state: TextFieldState,
)Возможно, вы задаетесь вопросом, какой из них мы рекомендуем.
Мы настоятельно рекомендуем использовать перегрузку TextFieldState.
В этом докладе мы обсудим, почему это так, и рассмотрим преимущества API, функций и производительности.
Она стабильна начиная с Compose 1.7.
Что касается перегрузок обратных вызовов, мы постепенно отказываемся от них в Compose 1.13 для BasicTextField и оберток Material.
@Composable
public fun BasicTextField(
value: String,
onValueChange: (String) -> Unit
)
@Composable
public fun BasicTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> UnitЭто значит, что мы не будем добавлять новые функции или исправлять ошибки, за исключением критических проблем с безопасностью или специальных возможностей.
Перегрузка value on value change без сохранения состояния отлично работала для синхронных входных данных.
Но как только вы объединили его с асинхронными потоками, например StateFlow, это создало проблемы с синхронизацией с IME или, как это называется технически, с виртуальной клавиатурой.
Это приводило к пропуску и дублированию символов, а также к скачкам курсора, как показано на этой записи.
Чтобы решить эту проблему, мы ввели TextFieldState.
Оно обновляется синхронно, когда пользователь вводит текст, не дожидаясь асинхронных циклов, что и требуется для IME.
TextFieldState, помимо текста, доступного только для чтения, также имеет другие свойства, которые создают активный сеанс редактирования.
Это выделение, состояние отмены и повтора, а также стили span и стили абзацев для редактирования форматированного текста, подробнее об этом позже в этом докладе.
Внутренние свойства TextFieldState поддерживаются системой Compose Snapshot.
Это означает, что когда вы читаете текст или выделение из этого объекта TextFieldState где-то в Compose, конкретная область, из которой вы его читаете, подписывается на обновления.
Это позволяет BasicTextField считывать состояние только на этапах макета или отрисовки, а вместе с некоторыми другими функциями устраняет ненужную рекомпозицию, когда пользователь вводит текст.
Вместе с другими изменениями это позволило значительно повысить производительность перегрузки TextFieldState.
Если текст TextFieldState доступен только для чтения, как программно изменить поле ввода?
И здесь на помощь приходит TextFieldBuffer.
Это изменяемый объект, который предоставляет API для внесения изменений.
Поэтому, когда вы хотите внести программное изменение, вы вызываете функцию редактирования TextFieldState.
А внутри этой лямбды вы получаете TextFieldBuffer.
А TextFieldBuffer предоставляет несколько функций, которые позволяют вставлять, заменять и удалять символы по мере необходимости.
Небольшой совет: не вызывайте их несколько раз подряд.
// Often generated by AI agents:
state.edit { insert(5, ",") }
state.edit { delete(12, 13) }
state.edit { replace(0, 5, "goodbye") }
state.edit { selection = TextRange(9, 16) }Вместо этого объедините все операции в один вызов.
// Batch all operations in a single atomic edit:
state.edit {
insert(5, ",")
delete(12, 13)
replace(0, 5, "goodbye")
selection = TextRange(9, 16)
}Таким образом, Compose создаст только один экземпляр TextFieldBuffer, изменит его на месте, а затем один раз синхронизирует его с IME и системой моментальных снимков.
Ещё один совет: для большинства распространенных пакетных операций, которые вам понадобятся, у нас есть функции расширения в TextFieldState.
fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}Поэтому вам не нужно думать о пакетной обработке этих операций.
Вы просто звоните по одному из них.
Примеры: clearText и setTextAndPlaceCursorAtEnd.
fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}А как насчет фильтрации пользовательского ввода?
Например, ограничение на количество символов или разрешение только цифр.
В старом API фильтрация в onValueChange выполнялась после того, как текст уже был зафиксирован.
onValueChange { newString ->
// full diffing new String vs old String
}Поэтому отклонение или изменение этого ввода как бы боролось с клавиатурой.
Кроме того, обратный вызов onValueChange возвращал только итоговую строку.
Чтобы понять, что изменилось, нужно было сравнить всю строку.
Вместо этого в новой перегрузке у нас есть преобразование входных данных.
@Stable
public fun interface InputTransformation {
public fun TextFieldBuffer.transformInput()
}Он перехватывает пользовательский ввод до того, как он достигнет состояния текстового поля, и предоставляет вам те же API TextFieldBuffer.
Вот как можно реализовать фильтрацию только по цифрам с помощью преобразования ввода. Вы просто берете изменения.
// Intercept and filter input during typing
inputTransformation = InputTransformation {
val text = asCharSequence()
if (!text.all { it.isDigit() }) {
// Reject non-digit characters
revertAllChanges()
}
}Вы проверяете, есть ли нецифровые символы, и если они есть, вы отклоняете ввод, вызывая revertAllChanges для этого TextFieldBuffer.
И ключевая возможность этого преобразования входных данных – ChangeList.
Вместо того чтобы сравнивать полные строки, вы можете проверить точные диапазоны редактирования, а затем изменить их на месте или полностью отклонить входные данные, если это необходимо.
Compose автоматически синхронизирует клавиатуру и курсор.
Наконец, что если вы хотите визуально отформатировать вводимые данные?
Например, вы хотите добавить скобки или тире в номер кредитной карты или номер телефона.
А, да.
И вы не хотите хранить их в своей базе данных, верно?
Ранее вы использовали API визуального преобразования.
В этом случае вам нужно было реализовать двунаправленное сопоставление от необработанных входных данных к визуальным выходным данным, а затем обратно.
И было очень сложно сделать это правильно, что часто приводило к исключениям во время выполнения.
Теперь у нас есть преобразование вывода, и затем, опять же, под капотом, оно работает на том же TextFieldBuffer, так что вы получаете практически тот же API.
@Stable
public fun interface OutputTransformation {
public fun TextFieldBuffer.transformOutput()
}Так что в этом примере с номером телефона, чтобы вставить эти тире в определенный индекс, вы просто вызываете функцию вставки TextFieldBuffer, и система рассчитает для вас двунаправленное сопоставление смещения.
// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
if (length > 6) {
insert(6, "-")
}
if (length > 3) {
insert(3, "-")
}
}Это позволяет избежать ручных вычислений и значительно снижает количество сбоев.
Теперь перейдем к самому важному: как выполнить перенос и где поднять TextFieldState?
На практике почти каждый вариант использования попадает в одну из этих категорий, о которых я собираюсь поговорить.
Если это не так, то, скорее всего, у вас нестандартная архитектура, но эти примеры все равно помогут вам выполнить перенос.
Итак, самый простой случай – сохранить TextFieldState прямо внутри composable-функции, где находится текстовое поле.
@Composable
fun PromoCodeInput(onApply: (String) -> Unit) {
// State hoisted locally inside the composable
val state = rememberTextFieldState()
OutlinedTextField(
state = state,
inputTransformation = InputTransformation.maxLength(6).allCaps(Locale.current),
trailingIcon = { Button(onClick = { onApply(state.text.toString()) }) { }
)
}Это когда текстовое поле является автономным полем ввода, например, полем ввода промокода, и вам нужно получить доступ к этому тексту только тогда, когда пользователь, например, нажимает кнопку отправки, верно?
Поэтому вашей модели представления не нужно перехватывать пользовательский ввод при каждом изменении.
Вы просто вызываете rememberTextFieldState локально, и rememberTextFieldState также помогает вам восстановить состояние после завершения процесса.
Затем вы используете преобразование ввода для фильтрации и получаете текст из этого состояния, объекта TextFieldState, и когда пользователь отправляет, вы передаете его через лямбду.
Хорошо, а что, если дочерним элементам пользовательского интерфейса, таким как счетчик символов или кнопка отправки, также необходимо прочитать текст или изменить его, внести в него изменения?
Это приводит нас к случаю, когда вы поднимаете объект TextFieldState до общей composable-функции, общей composable-функции предка.
@Composable
fun PostCommentBar(onSubmit: (String) -> Unit) {
// to share between UI components
val commentState = rememberTextFieldState()
Column {
CommentTextField(commentState)
Row {
Text("${commentState.text.length} / 280")
SubmitButton(commentState)
}
}А поскольку TextFieldState поддерживается системой моментальных снимков, передача его через предка не запускает рекомпозицию родителей.
Обновятся только те места, где вы действительно прочитали текст состояния.
Окей.
Поэтому в сложных случаях, когда вам нужно, чтобы TextFieldState, преобразование ввода и вывода координировали состояние внутри, вместо того чтобы поднимать их по отдельности и пытаться связать их вместе в composable-функции, используйте объект хранилища состояния.
@Composable
fun PostEditor() {
// Hoisting and wiring related pieces separately in the composable
val textFieldState = rememberTextFieldState()
val tagRanges = remember { mutableStateListOf<TextRange>() }
val inputTransform = remember { TagInputTransformation(tagRanges) }
val outputTransform = remember { TagOutputTransformation(tagRanges) }
BasicTextField(
state = textFieldState,
inputTransformation = inputTransform,
outputTransformation = outputTransform,
)
}Представьте, что вы хотите обработать хештег в строке.
TextFieldState содержит необработанный текст и позицию курсора.
Преобразование ввода отвечает за обнаружение хештега, а также за атомарное удаление тега.
Преобразование выходных данных добавит дополнительное визуальное оформление и поможет удалить хештег.
И все они упакованы в один объект хранилища состояния, который я назову TagEditorState.
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {
private val tagRanges = mutableStateListOf<TextRange>()
// Transformations coordinate state internally
val inputTransformation = TagInputTransformation(tagRanges)
val outputTransformation = TagOutputTransformation(tagRanges)
fun addTag(tag: String) { ... }
}Затем они могут повторно использовать промежуточные вычисления, такие как tagRanges в этом случае.
Затем вы просто передаете этот объект хранилища состояния в composable-функцию.
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}
@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
BasicTextField(
state = state.textFieldState,
inputTransformation = state.inputTransformation,
outputTransformation = state.outputTransformation,
)
}Хорошо, другой вариант использования – когда вашей бизнес-логике действительно нужен доступ к этому тексту для поиска по мере ввода или какой-то сложной проверки, поднимите это текстовое поле в свой ViewModel.
Важно не помещать изменяемый объект текстового поля в неизменяемый класс данных состояния пользовательского интерфейса, поскольку он копируется с помощью метода копирования, а затем передается в StateFlow при каждом изменении.
data class FormUiState(val username: TextFieldState)TextFieldState – это долгоживущий изменяемый объект, поддерживаемый системой Snapshot System, и его ссылочный объект никогда не меняется.
Поэтому ваш StateFlow даже не узнает, что пользователь сейчас что-то печатает, поскольку ссылка остается прежней.
И что ещё хуже, если вы создадите новый экземпляр состояния пользовательского интерфейса и выделите свежий TextFieldState, ваше поле ввода потеряет всё своё состояние.
Например, выделение, положение курсора или история отмены и повтора действий.
Вместо этого сделайте TextFieldState свойством непосредственно вашего ViewModel.
class FormViewModel : ViewModel() {
val username = TextFieldState()
val uiState: StateFlow<FormUiState> = ...
}И не беспокойтесь о правиле "никакого пользовательского интерфейса в ViewModel", потому что в этом случае TextFieldState не ссылается на объект Android View или Context или жизненный цикл Compose.
Это просто чистый объект-хранилище состояния на основе снимка.
В этом случае концептуально он не отличается от StateFlow.
И помните, что у ViewModel нет rememberSaveable, поэтому вам нужно подключить TextFieldState.Saver для текстового поля, чтобы оно пережило завершение процесса и восстановило свое состояние.
class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
val username: TextFieldState = savedStateHandle.saveable(
key = "username",
saver = TextFieldState.Saver,
init = { TextFieldState() }
)
val uiState: StateFlow<FormUiState> = ...
}Что если в вашей команде действуют строгие правила, согласно которым ViewModels могут находиться только в чистых модулях Kotlin, и вы не можете добавить зависимость compose-foundation?
Или, например, вы переносите сложный устаревший экран и не можете обновить или рефакторить состояние пользовательского интерфейса сегодня?
В этом случае используйте подход, соответствующий требованиям.
Поэтому вы сохраняете TextFieldState в composable-функции как единый источник достоверных данных для этого текстового поля.
Затем вы используете LaunchedEffect с snapshotFlow, чтобы вернуть измененный текст в существующий ViewModel.
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
LaunchedEffect(usernameState, viewModel) {
snapshotFlow { usernameState.text.toString() }
.drop(1)
.collect { viewModel.onUsernameChanged(it) }
}
BasicTextField(state = usernameState)
}А ваш ViewModel остается без Compose, и поскольку свойства состояния пользовательского интерфейса не возвращаются в текстовое поле при каждом изменении, вы по-прежнему избегаете этих асинхронных ошибок.
Да, и остерегайтесь распространенной ловушки.
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
LaunchedEffect(usernameState, viewModel) {
snapshotFlow { usernameState.text.toString() }
.drop(1) // 👈 CRITICAL: Avoids firing on initial composition or rotation
.collect { viewModel.onUsernameChanged(it) }
}
BasicTextField(state = usernameState)
}В большинстве случаев первое событие нужно исключить.
Это позволит избежать ненужного дублирования поисковых запросов, а также перезаписи ViewModel исходным значением.
Итак, если ваша логика должна передавать эти изменения обратно в состояние, например, когда пользователь нажимает кнопку отправки и вы хотите очистить текст, вы не можете просто установить пустую строку в ViewModel ViewModel.
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
...
// 3. Handle discrete ViewModel events (e.g. clear after login)
LaunchedEffect(viewModel) {
viewModel.clearUsernameEvent.collect {
usernameState.clearText()
}
}
BasicTextField(state = usernameState)
}Вместо этого вам нужно создать событие очистки текста, а затем composable-функция, которая содержит этот объект TextFieldState, вызовет clearText для этого объекта.
Чтобы помочь вам с переходом, мы подготовили это руководство.
Вы можете перейти к нему по этой ссылке.
Мы также работаем над дополнительным API, чтобы упростить как перенос, так и отслеживание изменений в TextFieldState. Следите за новостями.
И все, что мы рассмотрели до сих пор, TextFieldState, TextFieldBuffer, преобразование ввода и вывода, было создано не только для ввода обычного текста.
Это основа, на которой работает наш новейший набор API для редактирования форматированного текста.
Теперь я передаю слово Хаою, который расскажет об этом подробнее.
Теперь
Спасибо!
Здравствуйте!
Далее я расскажу о новых API, которые мы добавили для редактирования форматированного текста.
Прежде чем перейти к новым API, давайте посмотрим, как работает стилизация в статическом тексте.
Большинство из вас уже хорошо знакомы с AnnotatedString.
val styledText = buildAnnotatedString {
append("Jetpack Compose ")
// 1. Blue & Bold on "Jetpack" [0, 7)
addStyle(
style = SpanStyle(
color = Color.Blue,
fontWeight = FontWeight.Bold
),
start = 0,
end = 7
)
}
// Render static formatted text
Text(text = styledText)Это стандартный способ оформления статического текста.
Мы используем buildAnnotatedString и вызываем add-- call addStyle, чтобы применить SpanStyle или ParagraphStyle к определенным диапазонам.
Это хорошо работает для статического текста.
Когда мы думали о редактировании форматированного текста, то сначала хотели сделать AnnotatedString редактируемым.
Но вскоре мы поняли, что это не так просто.
AnnotatedString – это неизменяемая структура данных.
Но в мире редактирования пользователь постоянно печатает.
И с каждым нажатием клавиши нам нужно выделять новый объект AnnotatedString.
А в каждом AnnotatedString есть много AnnotatedString.Range.
Поэтому эти объекты также необходимо создать заново.
Это не очень хорошо масштабируется и вызывает значительные накладные расходы на выделение памяти.
Рассмотрим пример.
Когда мы вставляем слово в середину, диапазон каждого стиля после него сдвигается и перераспределяется.
Это явно не работает, когда у вас много стилей.
Поэтому мы добавили специальные API для стилизации в TextFieldBuffer, о котором ранее рассказывала Анастасия.
Они очень похожи на API стилей в AnnotatedString.
Давайте разберемся, как они работают.
Как и AnnotatedString.Builder, TextFieldBuffer теперь имеет addStyle для применения SpanStyle или ParagraphStyle к диапазону.
fun addStyle(spanStyle: SpanStyle, start: Int, end: Int)
fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int)
fun addStyle(spanStyle: SpanStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<SpanStyle>
fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<ParagraphStyle>Вы также можете передать параметр ExpandPolicy, который определяет, как стиль ведет себя, когда текст вводится прямо на границах стиля.
Мы рассмотрим это через минуту.
Сначала давайте посмотрим, как работает отслеживание стиля.
val state = TextFieldState("The quick fox jumps")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 13
)
insert(0, "Look! ")
insert(16, "brown ")
delete(16, 22)
delete(10, 19)
}После добавления стиля в TextFieldBuffer его диапазон автоматически обновляется по мере редактирования текста.
В этом примере мы сначала выделяем текст жирным шрифтом.
Если вы вставите текст перед ним, диапазон стиля сдвинется вперед.
Когда вы вставляете текст внутрь диапазона, он расширяется.
А когда мы удаляем текст внутри диапазона, диапазон стиля соответственно уменьшается.
Если удалить весь текст в диапазоне стиля, стиль сворачивается и автоматически удаляется из буфера.
Что произойдет, если текст будет вставлен прямо на границе диапазона стиля?
Применяется ли стиль к новому тексту?
Чтобы вы могли управлять этим, мы добавили API ExpandPolicy.
Давайте рассмотрим их.
Первый – ExpandPolicy.InsideOnly.
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.InsideOnly
)
insert(4, "quick ")
insert(length, " jumps")В этом режиме вставка текста на границе не расширяет диапазоны стилей.
Сначала мы применяем полужирный шрифт.
Если мы вставим текст в начале диапазона полужирного шрифта, диапазон стиля сдвинется, но не расширится.
Аналогично, если мы вставим текст в конце диапазона полужирного шрифта, диапазон стиля также не расширится.
Вот демонстрация того, как это работает в редакторе.
Следующий шаг – ExpandPolicy.AtStart.
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtStart
)
insert(4, "quick ")Как следует из названия, вставка текста в начале диапазона расширяет диапазон стиля.
Поэтому, когда мы вставляем текст прямо перед диапазоном стилей, он расширяется, чтобы охватить вновь вставленный текст.
Вот ещё одна демонстрация того, как это работает.
Третий – ExpandPolicy.AtEnd.
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtEnd
)
insert(length, " jumps")Поэтому мы вставляем текст в конец диапазона, чтобы расширить диапазон стиля.
Это самый распространенный вариант для редакторов.
Вот как это работает.
И наконец, ExpandPolicy.AtBoth, который расширяет стиль, когда текст вставляется в начале и в конце диапазона.
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtBoth
)
insert(4, "quick ")
insert(length, " jumps")
}Ещё одна демонстрация.
Мы рассмотрели все API для применения стилей.
Кроме того, мы добавили новые API запросов, такие как getSpanStyles и getParagraphStyles, чтобы получать стили, примененные к определенному диапазону.
// Outside edits. ReadOnly API in TextFieldState.
val textFieldState.styles: TextFieldTextStyles
fun TextFieldTextStyles.getSpanStyle(range: TextRange): List<AnnotatedString.Range<SpanStyle>>
// Inside edits, returned TrackedRange
fun TextFieldBuffer.getSpanStyles(range: TextRange): List<TrackedRange<SpanStyle>>Ниже приведены две версии API запросов.
На TextFieldState API запросов стилей доступны только для чтения.
Они возвращают список неизменяемых объектов AnnotatedString.Range.
А в TextFieldBuffer getSpanStyles возвращает список новых объектов TrackedRange, о которых я расскажу позже.
Поэтому все запросы стилей учитывают порядок.
Они возвращают диапазоны стилей в том порядке, в котором стили применяются.
Это позволяет вам применять, как и в случае с AnnotatedString, другой стиль поверх существующего, чтобы перезаписать предыдущие.
Теперь перейдем к TrackedRange API.
var TrackedRange<*>.textRange: TextRange
var TrackedRange<*>.expandPolicy: ExpandPolicy
var TrackedRange<SpanStyle>.spanStyle: SpanStyle
var TrackedRange<ParagraphStyle>.paragraphStyle: ParagraphStyle
val TrackedRange<*>.isValid: Boolean
fun removeStyle(trackedRange: TrackedRange<*>): BooleanОбъекты TrackedRange возвращаются API addStyle или getSpanStyles в TextFieldBuffer.
Это своего рода дескриптор диапазона стилей в буфере.
Вы можете использовать его, чтобы обновить диапазон текста, ExpandPolicy или сам стиль в месте.
У него также есть свойство isValid, позволяющее проверить, находится ли диапазон стилей в TextFieldBuffer.
Это связано с тем, что стиль может быть удален, если он свернут во время редактирования, или он намеренно удаляется removeStyle.
Теперь давайте посмотрим, как эти API работают вместе в реальных сценариях.
Первый вариант использования – создание редактора WYSIWYG.
В этом примере мы реализовали функцию кнопки "Полужирный".
Нажатие кнопки "Полужирный" включает или отключает полужирный стиль для выделенного текста.
Теперь давайте посмотрим на код.
Поэтому, когда пользователь нажимает кнопку "Полужирный", мы сначала вызываем getSpanStyles для выделенного фрагмента, чтобы найти все перекрывающиеся стили.
fun TextFieldBuffer.toggleBoldSelection() {
if (selection.collapsed) return
val boldSpans = getSpanStyles(selection)
if (!isFullyBold(boldSpans, selection)) {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
selection,
ExpandPolicy.AtEnd
)
} else {
unboldSelection(boldSpans.first())
}
}Затем мы проверяем, выделен ли весь выбранный фрагмент жирным шрифтом.
В этом примере выделенное слово не выделено полужирным шрифтом.
Поскольку текст не выделен полужирным шрифтом, мы addStyle к выбранному диапазону.
Мы передаем ExpandPolicy.AtEnd, чтобы пользователь мог продолжить вводить текст полужирным шрифтом сразу после этого слова.
Теперь посмотрим, что произойдет, если выбранный диапазон уже выделен полужирным шрифтом.
fun TextFieldBuffer.unboldSelection(
boldSpan: TrackedRange<SpanStyle>
) {
val hasBoldBefore = boldSpan.start < selection.min
val hasBoldAfter = boldSpan.end > selection.max
when {
hasBoldBefore ->
boldSpan.range = TextRange(range.start, selection.min)
hasBoldAfter ->
boldSpan.range = TextRange(selection.max, range.end)
hasBoldBefore && hasBoldAfter -> {
val oldEnd = boldSpan.end
boldSpan.range = TextRange(boldSpan.start, selection.min)
addStyle(
boldSpan.spanStyle,
start = selection.max,
end = oldEnd
)
}
else -> removeStyle(range)
}
}В этом случае нам нужно убрать полужирный шрифт из выбранного диапазона.
В этом случае выбранное пользователем слово находится внутри более длинной фразы, выделенной жирным шрифтом.
Поскольку полужирный шрифт начинается до выделенного фрагмента, нам нужно обновить диапазон стилей, оставив выделенное слово обычным.
Для этого можно использовать API TrackedRange.
В этом случае вместо удаления и повторного создания стиля мы можем просто изменить диапазон полужирного шрифта, чтобы он заканчивался перед выделением с индексом 10.
Конечно, это очень упрощенная реализация.
Полную демонстрацию можно найти в нашем репозитории примеров Compose.
Теперь давайте рассмотрим стилизацию на основе шаблонов.
В этой демонстрации, когда пользователь вводит звездочки Markdown вокруг слова, редактор автоматически преобразует его в полужирный шрифт по мере ввода.
Теперь посмотрим, как это реализовано с помощью InputTransformation.
object MarkdownBoldTransformation : InputTransformation {
val regex = Regex("""**(.*?)**""")
override fun TextFieldBuffer.transformInput() {
val matches = regex.findAll(asCharSequence()).toList()
for (match in matches) {
val range = match.range
val content = match.groupValues[1]
replace(range.first, range.last + 1, content)
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = range.first,
end = range.first + content.length,
policy = ExpandPolicy.atEnd
)
}
}
}В нашем преобразующем фильтре ввода мы находим все совпадающие шаблоны над кнопкой.
Для каждого совпадения мы извлекаем контент, заключенный в двойные звездочки.
В нашем примере это слово "лиса".
Затем мы удаляем звездочки и заменяем шаблон его содержимым.
И, наконец, мы вызываем addStyle для полужирного шрифта, чтобы применить полужирный стиль к тексту контента.
Помните, что InputTransformation отправит текст в том виде, в котором он будет в поле.
Это означает, что полужирный шрифт применяется к состоянию постоянно.
И наконец, давайте рассмотрим пример выделения результатов поиска.
В этом примере, когда пользователь вводит поисковый запрос, совпадающие слова в текстовых полях выделяются без изменения состояния текстового поля.
Теперь давайте посмотрим, как это реализовано в OutputTransformation.
При выделении поискового слова никогда не изменяйте состояние текста.
OutputTransformation – идеальное место для реализации этого сценария.
Давайте посмотрим, как это реализовано.
Сначала мы ищем выделенные слова и находим все совпадения.
class SearchHighlightTransformation(
val query: MutableState<String>
) : OutputTransformation {
override fun TextFieldBuffer.transformOutput() {
val search = query.value
if (search.isEmpty()) return
val text = asCharSequence().toString()
var index = text.indexOf(search)
while (index != -1) {
addStyle(
SpanStyle(
background = Color.Yellow,
color = Color.Black
),
start = index,
end = index + search.length
)
index = text.indexOf(search, index + search.length)
}
}
}Обратите внимание, что запрос является изменяемым состоянием.
Это связано с тем, что OutputTransformation учитывает снимки.
Когда это изменяемое состояние обновляется, OutputTransformation применяется повторно автоматически.
Затем мы перебираем все совпадения и вызываем addStyle, чтобы выделить их желтым цветом.
Опять же, OutputTransformation изменяет только визуальный текст, а исходные текстовые состояния остаются нетронутыми.
Это все примеры использования.
Напоминаем, что все показанные ранее API доступны начиная с Compose 1.12.
И в заключение я хочу поделиться с вами информацией о новом проекте, над которым мы сейчас работаем.
Это библиотека разметки текста для Jetpack Compose, которая поддерживает форматы RTF, например Markdown.
Одна из самых интересных возможностей, которые мы поддерживаем, – это инкрементный синтаксический анализ и инкрементная отрисовка.
Поэтому она отлично подходит для редактирования в реальном времени или потоковой передачи Markdown.
Вот запись нашего рабочего прототипа в действии, и вы можете видеть, что эти затухающие области являются обновляющимися частями пользовательского интерфейса.
Если вас интересует эта функция или вы хотите использовать ее в своем приложении, свяжитесь с нами.
Спасибо всем.
TextField(state = rememberTextFieldState())OutlinedTextField(state = rememberTextFieldState())BasicTextField(state = rememberTextFieldState())SecureTextField(state = rememberTextFieldState())@Composable
public fun BasicTextField(
state: TextFieldState,
)@Composable
public fun BasicTextField(
value: String,
onValueChange: (String) -> Unit
)
@Composable
public fun BasicTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit// Often generated by AI agents:
state.edit { insert(5, ",") }
state.edit { delete(12, 13) }
state.edit { replace(0, 5, "goodbye") }
state.edit { selection = TextRange(9, 16) }// Batch all operations in a single atomic edit:
state.edit {
insert(5, ",")
delete(12, 13)
replace(0, 5, "goodbye")
selection = TextRange(9, 16)
}fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}onValueChange { newString ->
// full diffing new String vs old String
}@Stable
public fun interface InputTransformation {
public fun TextFieldBuffer.transformInput()
}// Intercept and filter input during typing
inputTransformation = InputTransformation {
val text = asCharSequence()
if (!text.all { it.isDigit() }) {
// Reject non-digit characters
revertAllChanges()
}
}@Stable
public fun interface OutputTransformation {
public fun TextFieldBuffer.transformOutput()
}// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
if (length > 6) {
insert(6, "-")
}
if (length > 3) {
insert(3, "-")
}
}@Composable
fun PromoCodeInput(onApply: (String) -> Unit) {
// State hoisted locally inside the composable
val state = rememberTextFieldState()
OutlinedTextField(
state = state,
inputTransformation = InputTransformation.maxLength(6).allCaps(Locale.current),
trailingIcon = { Button(onClick = { onApply(state.text.toString()) }) { }
)
}@Composable
fun PostCommentBar(onSubmit: (String) -> Unit) {
// to share between UI components
val commentState = rememberTextFieldState()
Column {
CommentTextField(commentState)
Row {
Text("${commentState.text.length} / 280")
SubmitButton(commentState)
}
}@Composable
fun PostEditor() {
// Hoisting and wiring related pieces separately in the composable
val textFieldState = rememberTextFieldState()
val tagRanges = remember { mutableStateListOf<TextRange>() }
val inputTransform = remember { TagInputTransformation(tagRanges) }
val outputTransform = remember { TagOutputTransformation(tagRanges) }
BasicTextField(
state = textFieldState,
inputTransformation = inputTransform,
outputTransformation = outputTransform,
)
}@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {
private val tagRanges = mutableStateListOf<TextRange>()
// Transformations coordinate state internally
val inputTransformation = TagInputTransformation(tagRanges)
val outputTransformation = TagOutputTransformation(tagRanges)
fun addTag(tag: String) { ... }
}@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}
@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
BasicTextField(
state = state.textFieldState,
inputTransformation = state.inputTransformation,
outputTransformation = state.outputTransformation,
)
}data class FormUiState(val username: TextFieldState)class FormViewModel : ViewModel() {
val username = TextFieldState()
val uiState: StateFlow<FormUiState> = ...
}class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
val username: TextFieldState = savedStateHandle.saveable(
key = "username",
saver = TextFieldState.Saver,
init = { TextFieldState() }
)
val uiState: StateFlow<FormUiState> = ...
}@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
LaunchedEffect(usernameState, viewModel) {
snapshotFlow { usernameState.text.toString() }
.drop(1)
.collect { viewModel.onUsernameChanged(it) }
}
BasicTextField(state = usernameState)
}@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
LaunchedEffect(usernameState, viewModel) {
snapshotFlow { usernameState.text.toString() }
.drop(1) // 👈 CRITICAL: Avoids firing on initial composition or rotation
.collect { viewModel.onUsernameChanged(it) }
}
BasicTextField(state = usernameState)
}@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
...
// 3. Handle discrete ViewModel events (e.g. clear after login)
LaunchedEffect(viewModel) {
viewModel.clearUsernameEvent.collect {
usernameState.clearText()
}
}
BasicTextField(state = usernameState)
}val styledText = buildAnnotatedString {
append("Jetpack Compose ")
// 1. Blue & Bold on "Jetpack" [0, 7)
addStyle(
style = SpanStyle(
color = Color.Blue,
fontWeight = FontWeight.Bold
),
start = 0,
end = 7
)
}
// Render static formatted text
Text(text = styledText)fun addStyle(spanStyle: SpanStyle, start: Int, end: Int)
fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int)
fun addStyle(spanStyle: SpanStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<SpanStyle>
fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<ParagraphStyle>val state = TextFieldState("The quick fox jumps")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 13
)
insert(0, "Look! ")
insert(16, "brown ")
delete(16, 22)
delete(10, 19)
}val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.InsideOnly
)
insert(4, "quick ")
insert(length, " jumps")val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtStart
)
insert(4, "quick ")val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtEnd
)
insert(length, " jumps")val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtBoth
)
insert(4, "quick ")
insert(length, " jumps")
}// Outside edits. ReadOnly API in TextFieldState.
val textFieldState.styles: TextFieldTextStyles
fun TextFieldTextStyles.getSpanStyle(range: TextRange): List<AnnotatedString.Range<SpanStyle>>
// Inside edits, returned TrackedRange
fun TextFieldBuffer.getSpanStyles(range: TextRange): List<TrackedRange<SpanStyle>>var TrackedRange<*>.textRange: TextRange
var TrackedRange<*>.expandPolicy: ExpandPolicy
var TrackedRange<SpanStyle>.spanStyle: SpanStyle
var TrackedRange<ParagraphStyle>.paragraphStyle: ParagraphStyle
val TrackedRange<*>.isValid: Boolean
fun removeStyle(trackedRange: TrackedRange<*>): Booleanfun TextFieldBuffer.toggleBoldSelection() {
if (selection.collapsed) return
val boldSpans = getSpanStyles(selection)
if (!isFullyBold(boldSpans, selection)) {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
selection,
ExpandPolicy.AtEnd
)
} else {
unboldSelection(boldSpans.first())
}
}fun TextFieldBuffer.unboldSelection(
boldSpan: TrackedRange<SpanStyle>
) {
val hasBoldBefore = boldSpan.start < selection.min
val hasBoldAfter = boldSpan.end > selection.max
when {
hasBoldBefore ->
boldSpan.range = TextRange(range.start, selection.min)
hasBoldAfter ->
boldSpan.range = TextRange(selection.max, range.end)
hasBoldBefore && hasBoldAfter -> {
val oldEnd = boldSpan.end
boldSpan.range = TextRange(boldSpan.start, selection.min)
addStyle(
boldSpan.spanStyle,
start = selection.max,
end = oldEnd
)
}
else -> removeStyle(range)
}
}object MarkdownBoldTransformation : InputTransformation {
val regex = Regex("""**(.*?)**""")
override fun TextFieldBuffer.transformInput() {
val matches = regex.findAll(asCharSequence()).toList()
for (match in matches) {
val range = match.range
val content = match.groupValues[1]
replace(range.first, range.last + 1, content)
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = range.first,
end = range.first + content.length,
policy = ExpandPolicy.atEnd
)
}
}
}class SearchHighlightTransformation(
val query: MutableState<String>
) : OutputTransformation {
override fun TextFieldBuffer.transformOutput() {
val search = query.value
if (search.isEmpty()) return
val text = asCharSequence().toString()
var index = text.indexOf(search)
while (index != -1) {
addStyle(
SpanStyle(
background = Color.Yellow,
color = Color.Black
),
start = index,
end = index + search.length
)
index = text.indexOf(search, index + search.length)
}
}
}