Основы работы с приложениями

Создание надежных возможностей редактирования текста в Compose

Колонки

  • Анастасия Соболева

    Разработчик ПО

  • Хаоюй Чжан (Haoyu Zhang)

    Старший разработчик ПО

О приложении

О выступлении

Путь к надежному редактированию текста в Compose завершился полной переработкой BasicTextField. Переход на архитектуру с буфером позволил раз и навсегда устранить состояния гонки в IME. На этом занятии мы разберем, почему устаревшая синхронизация ввода должна была уйти и как TextFieldState синхронизирует обновления клавиатуры. Затем мы подробно рассмотрим форматированное редактирование, чтобы изучить механику TrackedRange, затраты на производительность блоков редактирования и реальные проблемы, связанные с границами стилей, а также заглянем в рендеринг Markdown.

Сводка

  • Перегрузка API TextFieldState заменяет перегрузки на основе обратного вызова в Compose. Он обновляется синхронно с вводом пользователя, устраняя проблемы со временем при использовании виртуальных клавиатур и ненужные рекомпозиции.
  • Преобразования ввода и вывода позволяют манипулировать текстом с помощью TextFieldBuffer. Вы можете перехватывать изменения перед их фиксацией или форматировать отображаемый текст без ручного вычисления сопоставлений смещений.
  • Поднятие TextFieldState зависит от потребностей вашей архитектуры. Вы можете хранить его локально в composable-функции или непосредственно в ViewModel для доступа к бизнес-логике.
  • В Compose 1.12 добавлены API для редактирования форматированного текста в TextFieldBuffer. Вы можете применять стили с настраиваемыми параметрами ExpandPolicy, а существующие диапазоны стилей будут обновляться автоматически по мере редактирования пользователями.
  • В настоящее время разрабатывается новая библиотека текста разметки для Jetpack Compose. Она поддерживает такие форматы, как Markdown, с инкрементным синтаксическим анализом и рендерингом для редактирования и потоковой передачи в реальном времени.

Ресурсы

Gemini Notebook

Темы

  • jetpack-compose
  • textfieldstate
  • редактирование текста
  • basictextfield
  • formatted-text
  • input-transformation

Расшифровка

Введение в редактирование текста в Compose

  • Здравствуйте!

  • Спасибо, что присоединились к нам.

  • Меня зовут Анастасия, а это мой коллега Хаоюй.

  • Сегодня мы покажем вам, как создать надежный интерфейс редактирования текста.

  • Создаете ли вы простой экран входа, панель поиска по мере ввода или сложный мессенджер, текстовые поля лежат в основе того, как пользователи взаимодействуют с вашими приложениями для 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

  • Чтобы решить эту проблему, мы ввели 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?

  • На практике почти каждый вариант использования попадает в одну из этих категорий, о которых я собираюсь поговорить.

  • Если это не так, то, скорее всего, у вас нестандартная архитектура, но эти примеры все равно помогут вам выполнить перенос.

  • Итак, самый простой случай – сохранить 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

  • Первый вариант использования – создание редактора 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.

  • Вот запись нашего рабочего прототипа в действии, и вы можете видеть, что эти затухающие области являются обновляющимися частями пользовательского интерфейса.

  • Если вас интересует эта функция или вы хотите использовать ее в своем приложении, свяжитесь с нами.

  • Спасибо всем.

Код

Фрагменты кода

Фрагмент кода на Kotlin в видео "Введение в редактирование текста в Compose"
TextField(state = rememberTextFieldState())
Фрагмент кода на Kotlin в видео "Введение в редактирование текста в Compose"
OutlinedTextField(state = rememberTextFieldState())
Фрагмент кода на Kotlin в видео "Введение в редактирование текста в Compose"
BasicTextField(state = rememberTextFieldState())
Фрагмент кода на Kotlin в видео "Введение в текстовый редактор в Compose"
SecureTextField(state = rememberTextFieldState())
Фрагмент кода на Kotlin в видео "Введение в редактирование текста в Compose"
@Composable
public fun BasicTextField(
    state: TextFieldState,
)
Фрагмент кода на Kotlin в видео "Введение в редактирование текста в Compose"
@Composable
public fun BasicTextField(
    value: String,
    onValueChange: (String) -> Unit
)

@Composable
public fun BasicTextField(
    value: TextFieldValue,
    onValueChange: (TextFieldValue) -> Unit
Фрагмент кода на Kotlin в видео "Introducing TextFieldState" (04:01)
// 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) }
Фрагмент кода на Kotlin в видео "Представляем TextFieldState"
// Batch all operations in a single atomic edit:
state.edit {
insert(5, ",")
delete(12, 13)
replace(0, 5, "goodbye")
selection = TextRange(9, 16)
}
Фрагмент кода на Kotlin в видео "Introducing TextFieldState"
fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}
Фрагмент кода на Kotlin в видео "Представляем TextFieldState"
fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}
Фрагмент кода на Kotlin в видео "Фильтрация и форматирование ввода"
onValueChange { newString ->
  // full diffing new String vs old String
}
Фрагмент кода Kotlin на 05:17 в видео "Фильтрация и форматирование входных данных"
@Stable
public fun interface InputTransformation {
    public fun TextFieldBuffer.transformInput()
}
Фрагмент кода на Kotlin в видео "Фильтрация и форматирование ввода" (05:33)
// Intercept and filter input during typing
inputTransformation = InputTransformation {
    val text = asCharSequence()
    if (!text.all { it.isDigit() }) {
        // Reject non-digit characters
        revertAllChanges()
    }
}
Фрагмент кода на Kotlin в разделе "Фильтрация и форматирование ввода"
@Stable
public fun interface OutputTransformation {
    public fun TextFieldBuffer.transformOutput()
}
Фрагмент кода Kotlin на 06:58 в видео "Фильтрация и форматирование ввода"
// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
    if (length > 6) {
        insert(6, "-")
    }
    if (length > 3) {
        insert(3, "-")
    }
}
Фрагмент кода Kotlin на 07:51 в видео "Подъем и перенос TextFieldState"
@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()) }) { }
    )
}
Фрагмент кода на Kotlin в видео "Подъем и перенос TextFieldState"
@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)
        }
    }
Фрагмент кода Kotlin на отметке 09:27 в видео "Подъем и перенос TextFieldState"
@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,
    )
}
Фрагмент кода на Kotlin на 10:12 в видео "Подъем и перенос TextFieldState"
@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) { ... }

}
Фрагмент кода Kotlin на 10:30 в видео "Подъем и перенос TextFieldState"
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}

@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
    BasicTextField(
        state = state.textFieldState,
        inputTransformation = state.inputTransformation,
        outputTransformation = state.outputTransformation,
    )
}
Фрагмент кода Kotlin на 10:51 в видео "Подъем и перенос TextFieldState"
data class FormUiState(val username: TextFieldState)
Фрагмент кода на Kotlin в видео "Подъем и перенос TextFieldState"
class FormViewModel : ViewModel() {
   val username = TextFieldState()
   val uiState: StateFlow<FormUiState> = ...
}
Фрагмент кода Kotlin на 12:09 в видео "Подъем и перенос TextFieldState"
class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
    val username: TextFieldState = savedStateHandle.saveable(
        key = "username",
        saver = TextFieldState.Saver,
        init = { TextFieldState() }
    )
   val uiState: StateFlow<FormUiState> = ...
}
Фрагмент кода на Kotlin в видео "Подъем и перенос TextFieldState"
@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)
}
Фрагмент кода Kotlin на 13:21 в видео "Подъем и перенос TextFieldState"
@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)
}
Фрагмент кода Kotlin на 13:39 в видео "Подъем и перенос TextFieldState"
@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)
}
Фрагмент кода на Kotlin в видео "Редактирование форматированного текста"
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)
Фрагмент кода на Kotlin в видео "Форматирование и редактирование текста"
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>
Фрагмент кода на Kotlin в видео "Форматирование и редактирование текста"
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)
}
Фрагмент кода на Kotlin в видео "Редактирование форматированного текста"
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")
Фрагмент кода на Kotlin в видео "Редактирование форматированного текста"
val state = TextFieldState("The fox")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 7,
        policy = ExpandPolicy.AtStart
    )
    insert(4, "quick ")
Фрагмент кода на Kotlin в видео "Редактирование форматированного текста"
val state = TextFieldState("The fox")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 7,
        policy = ExpandPolicy.AtEnd
    )
    insert(length, " jumps")
Фрагмент кода на Kotlin в видео "Форматирование и редактирование текста"
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")
}
Фрагмент кода на Kotlin в видео "Редактирование форматированного текста" (19:14)
// 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>>
Фрагмент кода на Kotlin в видео "Форматирование и редактирование текста"
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
Фрагмент кода на Kotlin в видео "Создание WYSIWYG-редактора"
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())
    }
}
Фрагмент кода на Kotlin в видео "Создание WYSIWYG-редактора"
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)
    }
}
Фрагмент кода на Kotlin в видео "Создание визуального редактора"
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
            )
        }
    }
}
Фрагмент кода на Kotlin в видео "Создание визуального редактора"
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)
        }
    }
}