Apps foundation

Cómo crear experiencias de edición de texto confiables en Compose

Bocinas

  • Anastasia Soboleva

    Ingeniero de software

  • Haoyu Zhang

    Ingeniero de software sénior

Acerca de

Acerca de la charla

El recorrido hacia la edición de texto confiable en Compose culminó con la reescritura completa de BasicTextField. Este cambio a una arquitectura basada en búferes eliminó las condiciones de carrera del IME de una vez por todas. En esta sesión, se explica por qué se tuvo que eliminar la sincronización de entrada heredada y cómo TextFieldState mantiene sincronizadas las actualizaciones del teclado. Luego, profundiza en la edición con formato para explorar la mecánica de TrackedRange, los costos de rendimiento de los bloques de edición y las trampas del mundo real detrás de los límites de estilo, antes de echar un vistazo a la renderización de Markdown.

Resumen

  • La sobrecarga de la API de TextFieldState reemplaza las sobrecargas basadas en devoluciones de llamada en Compose. Se actualiza de forma síncrona con la escritura del usuario, lo que elimina los problemas de sincronización con los teclados en pantalla y las recomposiciones innecesarias.
  • Las transformaciones de entrada y salida te permiten manipular texto con TextFieldBuffer. Puedes interceptar las ediciones antes de confirmarlas o dar formato al texto que se muestra sin calcular manualmente las asignaciones de compensación.
  • La elevación de TextFieldState depende de tus necesidades de arquitectura. Puedes mantenerlo de forma local en una función de componibilidad o almacenarlo directamente en un ViewModel para acceder a la lógica empresarial.
  • Compose 1.12 agrega APIs de edición de texto con formato a TextFieldBuffer. Puedes aplicar estilos con opciones de ExpandPolicy personalizables, y los rangos de estilo existentes se actualizan automáticamente a medida que los usuarios editan.
  • Actualmente, se está desarrollando una nueva biblioteca de texto de lenguaje de marcado para Jetpack Compose. Admite formatos como Markdown con análisis y renderización incrementales para la edición y transmisión en vivo.

Recursos

Gemini Notebook

Temas

  • jetpack-compose
  • textfieldstate
  • text-editing
  • basictextfield
  • formatted-text
  • input-transformation

Transcripción

Descargar transcripción

Introducción a la edición de texto en Compose

  • Hi, everyone!

  • Gracias por participar en nuestra charla.

  • Me llamo Anastasia y me acompaña mi colega Haoyu.

  • Hoy te mostraremos cómo crear una experiencia de edición de texto confiable.

  • Ya sea que estés creando una pantalla de acceso simple, una barra de búsqueda mientras escribes o una app de mensajería compleja, los campos de texto son el núcleo de la interacción de los usuarios con tus apps para Android.

  • Por lo tanto, es probable que la mayoría de ustedes ya use campos de texto de Material o campos de texto con contorno.

    TextField(state = rememberTextFieldState())
    OutlinedTextField(state = rememberTextFieldState())
  • También hay un BasicTextField para cuando creas tu propio sistema de diseño o necesitas un cuadro de edición simple.

    BasicTextField(state = rememberTextFieldState())
  • También existe SecureTextField, que está diseñado específicamente para ingresar contraseñas.

    SecureTextField(state = rememberTextFieldState())
  • BasicTextField es un componente sin diseño y, de hecho, potencia todos los demás campos de texto de Compose.

  • Por lo tanto, es posible que hayas notado que, en los últimos años, tenemos dos formas de API en paralelo: una que toma el valor y la devolución de llamada de cambio de valor, y otra que toma el objeto TextFieldState.

    @Composable
    public fun BasicTextField(
        state: TextFieldState,
    )
  • Por lo tanto, es posible que te preguntes cuál recomendamos.

  • Te recomendamos que uses la sobrecarga de TextFieldState.

  • En esta charla, analizaremos por qué es beneficioso en términos de API, funciones y rendimiento.

  • Es estable desde Compose 1.7.

  • En cuanto a las sobrecargas de devolución de llamada, las daremos de baja de forma parcial en Compose 1.13 en BasicTextField y los wrappers de Material.

    @Composable
    public fun BasicTextField(
        value: String,
        onValueChange: (String) -> Unit
    )
    
    @Composable
    public fun BasicTextField(
        value: TextFieldValue,
        onValueChange: (TextFieldValue) -> Unit
  • Esto significa que no agregaremos funciones nuevas ni corregiremos errores, a menos que se trate de problemas críticos de seguridad o accesibilidad.

  • La sobrecarga de value change sin estado funcionó muy bien para las entradas síncronas.

  • Sin embargo, en cuanto lo vinculaste con flujos asíncronos, por ejemplo, StateFlow, se crearon problemas de sincronización con el IME o, como término técnico, para el teclado en pantalla.

  • Por lo tanto, eso causó que se omitieran caracteres, se duplicaran caracteres y se saltara el cursor, como se puede ver en esta grabación.

Presentamos TextFieldState

  • Para solucionar este problema de forma definitiva, presentamos TextFieldState.

  • Se actualiza de forma síncrona cuando el usuario escribe sin esperar esos viajes de ida y vuelta asíncronos, que es exactamente lo que necesitaba el IME.

  • TextFieldState, además del texto de solo lectura, también tiene otras propiedades que crean una sesión de edición activa.

  • Es decir, la selección, el estado de deshacer y rehacer, y también los estilos de span y de párrafo para la edición de texto con formato, de lo que hablaremos más adelante en esta charla.

  • Las propiedades internas de TextFieldState están respaldadas por el sistema de instantáneas de Compose.

  • Por lo tanto, cuando lees ese texto o selección de ese objeto TextFieldState en algún lugar de Compose, el alcance específico desde el que lo lees se suscribe a las actualizaciones.

  • Permite que BasicTextField solo lea el estado en las fases de diseño o dibujo y, junto con algunas, elimina la recomposición innecesaria cuando el usuario escribe.

  • Y, junto con algunos otros cambios, generó una mejora significativa del rendimiento para la sobrecarga de TextFieldState.

  • Ahora bien, si el texto de TextFieldState es una propiedad de solo lectura, ¿cómo se realizan cambios de manera programática en el campo de entrada?

  • Aquí es donde TextFieldBuffer resulta útil.

  • Es un objeto mutable que te brinda APIs para realizar cambios.

  • Por lo tanto, cuando quieras realizar el cambio de manera programática, llama a la función de edición de TextFieldState.

  • Y, dentro de esa lambda, obtienes un TextFieldBuffer.

  • Y TextFieldBuffer expone varias funciones que te permiten insertar, reemplazar caracteres y borrarlos según sea necesario.

  • Un consejo rápido con la edición es que evites llamarlos varias veces seguidas.

    // 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) }
  • En cambio, agrupa todas tus operaciones en una sola llamada de edición.

    // Batch all operations in a single atomic edit:
    state.edit {
    insert(5, ",")
    delete(12, 13)
    replace(0, 5, "goodbye")
    selection = TextRange(9, 16)
    }
  • De esa manera, Compose solo creará una instancia de TextFieldBuffer, la mutará in situ y, luego, la sincronizará con el IME y el sistema de instantáneas una vez.

  • Otro consejo es que, para la mayoría de las operaciones por lotes comunes que necesitarás, tenemos funciones de extensión en TextFieldState.

    fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
    edit {
    replace(0, length, text)
    placeCursorAtEnd()
    }
    }
  • Por lo tanto, no necesitas pensar en agrupar esas operaciones por lotes.

  • Solo tienes que llamar a uno de esos.

  • Por ejemplo, clearText y setTextAndPlaceCursorAtEnd.

    fun TextFieldState.clearText() {
    edit {
    delete(0, length)
    placeCursorAtEnd()
    }
    }

Filtrado y formato de entradas

  • Ahora, ¿qué hay de filtrar la entrada del usuario?

  • Por ejemplo, aplicar un límite de caracteres o querer aceptar solo dígitos.

  • En la API anterior, el filtrado en onValueChange se ejecutaba después de que ya se había confirmado el texto.

    onValueChange { newString ->
      // full diffing new String vs old String
    }
  • Por lo tanto, rechazar o alterar esa entrada generaba un conflicto con el teclado.

  • Además, la devolución de llamada onValueChange solo te proporcionó la cadena final.

  • Por lo tanto, para comprender qué cambió, debías realizar la comparación de cadenas completa.

  • En cambio, en la nueva sobrecarga, tenemos la transformación de entrada.

    @Stable
    public fun interface InputTransformation {
        public fun TextFieldBuffer.transformInput()
    }
  • Por lo tanto, intercepta la entrada del usuario antes de que llegue al estado del campo de texto y te proporciona las mismas APIs de TextFieldBuffer.

  • Así es como implementas el filtrado de solo dígitos con la transformación de entrada y, básicamente, solo tomas los cambios.

    // Intercept and filter input during typing
    inputTransformation = InputTransformation {
        val text = asCharSequence()
        if (!text.all { it.isDigit() }) {
            // Reject non-digit characters
            revertAllChanges()
        }
    }
  • Verificas si hay caracteres que no sean dígitos y, si los hay, rechazas la entrada llamando a revertAllChanges en ese TextFieldBuffer.

  • La capacidad clave de esta transformación de entrada es ChangeList.

  • Por lo tanto, en lugar de comparar las cadenas completas, te permite inspeccionar los rangos de edición exactos y, luego, mutarlos in situ o rechazar la entrada por completo si es necesario.

  • Compose mantendrá el teclado y el cursor sincronizados automáticamente.

  • Por último, ¿qué sucede si quieres aplicar un formato visual a tu entrada?

  • Por ejemplo, si quieres agregar paréntesis o guiones a un número de tarjeta de crédito o de teléfono.

  • - Uf. - Sí.

  • Y no quieres almacenarlos en tu base de datos, ¿verdad?

  • Anteriormente, usaste la API de transformación visual.

  • En ese caso, debías implementar una asignación bidireccional de la entrada sin procesar a la salida visual y, luego, de vuelta.

  • Y era muy difícil hacerlo bien, lo que a menudo generaba excepciones de tiempo de ejecución.

  • Ahora tenemos una transformación de salida y, de nuevo, en segundo plano, funciona con el mismo TextFieldBuffer, por lo que obtienes prácticamente la misma API.

    @Stable
    public fun interface OutputTransformation {
        public fun TextFieldBuffer.transformOutput()
    }
  • En este ejemplo de número de teléfono, para insertar esos guiones en un índice específico, simplemente llamas a la función de inserción de TextFieldBuffer, y el sistema calculará la asignación de desplazamiento bidireccional por ti.

    // Raw data untouched; formatting is purely visual
    outputTransformation = OutputTransformation {
        if (length > 6) {
            insert(6, "-")
        }
        if (length > 3) {
            insert(3, "-")
        }
    }
  • Esto elimina los cálculos manuales y disminuye significativamente la cantidad de fallas.

Elevación y migración de TextFieldState

  • Ahora, pasemos a la parte más importante: ¿cómo se migra realmente y dónde se aloja ese TextFieldState?

  • En la práctica, casi todos los casos de uso se incluyen en una de esas categorías de las que voy a hablar.

  • Y cuando no lo es, bueno, por lo general, significa que tienes una arquitectura poco convencional, pero estos ejemplos te guiarán durante la migración.

  • Bien, el caso más simple es mantener el TextFieldState dentro del elemento componible en el que se encuentra ese campo de texto.

    @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()) }) { }
        )
    }
  • Esto ocurre cuando el campo de texto es un campo de entrada independiente, por ejemplo, un campo de entrada de código promocional, y solo necesitas acceder a ese texto cuando el usuario, por ejemplo, presiona el botón de envío, ¿verdad?

  • Por lo tanto, tu modelo de vista no necesita interceptar la entrada del usuario en cada cambio.

  • Por lo tanto, solo debes llamar a rememberTextFieldState de forma local, y rememberTextFieldState también te ayudará con el restablecimiento del estado después del cierre del proceso.

  • Luego, usas la transformación de entrada para filtrar y obtienes el texto de ese estado, el objeto TextFieldState, y cuando el usuario lo envía, lo pasas por la lambda.

  • Bien, ¿qué sucede si los elementos hermanos de la IU, como un contador de caracteres o un botón de envío, también necesitan leer el texto o modificarlo?

  • Esto nos lleva a un caso en el que elevas el objeto TextFieldState a una función de componibilidad común, una función de componibilidad de ancestro común.

    @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)
            }
        }
  • Y, como TextFieldState está respaldado por el sistema de instantáneas, pasarlo por el ancestro no activa la recomposición de los elementos superiores.

  • Solo se actualizarán los lugares en los que realmente leas el texto del estado.

  • Bien.

  • Por lo tanto, para los casos de uso de IU complejos en los que necesitas que tu TextFieldState y la transformación de entrada y salida coordinen el estado de forma interna, en lugar de elevarlos por separado y tratar de unirlos en la función de componibilidad, usa un objeto contenedor de estado.

    @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,
        )
    }
  • Imagina que quieres controlar un hashtag intercalado.

  • TextFieldState contiene un texto sin procesar y la posición del cursor.

  • La transformación de entrada se encarga de detectar un hashtag y, además, puedes realizar la eliminación de etiquetas atómicas a través de la transformación de entrada.

  • La transformación de salida agregaría un estilo visual adicional y también podría ayudarte a borrar el hashtag.

  • Y todos se empaquetan juntos en un solo objeto contenedor de estado, ¿cómo lo llamo?, 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) { ... }
    
    }
  • Y, luego, todos pueden reutilizar los cálculos intermedios, como tagRanges en este caso.

  • Luego, simplemente pasas este objeto contenedor de estado a tu función de componibilidad.

    @Stable
    class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}
    
    @Composable
    fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
        BasicTextField(
            state = state.textFieldState,
            inputTransformation = state.inputTransformation,
            outputTransformation = state.outputTransformation,
        )
    }
  • Bien, otro caso de uso es cuando tu lógica empresarial necesita acceso a ese texto para la búsqueda a medida que escribes o alguna validación compleja, eleva ese campo de texto a tu ViewModel.

  • Es importante que no coloques un objeto de campo de texto mutable en el estado de la IU de la clase de datos inmutable, ya que se copia con un método de copia y, luego, se emite en StateFlow en cada cambio.

    data class FormUiState(val username: TextFieldState)
  • TextFieldState es un objeto mutable de larga duración respaldado por el sistema de instantáneas, y su objeto de referencia nunca cambia.

  • Por lo tanto, tu StateFlow ni siquiera sabrá que el usuario está escribiendo en ese momento porque la referencia sigue siendo la misma.

  • Y lo que es peor, si creas una instancia nueva del estado de la IU y asignas un TextFieldState nuevo, tu campo de entrada perderá todo su estado.

  • Por lo tanto, será como la selección, la posición del cursor o el historial de deshacer/rehacer.

  • En su lugar, convierte TextFieldState en una propiedad directamente en tu ViewModel.

    class FormViewModel : ViewModel() {
       val username = TextFieldState()
       val uiState: StateFlow<FormUiState> = ...
    }
  • No te preocupes por la regla de no IU en ViewModel, ya que, en ese caso, TextFieldState no hace referencia al objeto View o Context de Android ni al ciclo de vida de Compose.

  • Es solo un objeto contenedor de estado puro respaldado por instantáneas.

  • Por lo tanto, en ese caso, conceptualmente no es diferente de StateFlow.

  • Recuerda que ViewModel no tiene rememberSaveable, por lo que debes conectar TextFieldState.Saver para que el campo de texto sobreviva al cierre del proceso y restablezca su estado.

    class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
        val username: TextFieldState = savedStateHandle.saveable(
            key = "username",
            saver = TextFieldState.Saver,
            init = { TextFieldState() }
        )
       val uiState: StateFlow<FormUiState> = ...
    }
  • Ahora bien, ¿qué sucede si tu equipo tiene reglas de módulos estrictas en las que los ViewModels solo se encuentran en módulos de Kotlin puros y no puedes incorporar esa dependencia de compose-foundation?

  • O, por ejemplo, estás migrando una pantalla heredada compleja y no puedes actualizar ni refactorizar el estado de la IU hoy.

  • En ese caso, usa el enfoque de conformidad.

  • Por lo tanto, mantienes el TextFieldState en el elemento componible como una fuente única de información para ese campo de texto.

  • Sin embargo, luego usas LaunchedEffect con un snapshotFlow para enviar esos cambios de texto de vuelta a tu ViewModel existente.

    @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)
    }
  • Además, tu ViewModel se mantiene sin Compose y, como las propiedades de estado de la IU no se vuelven a ingresar en el campo de texto con cada cambio, sigues evitando esos errores de ida y vuelta asíncronos.

  • Sí, y ten cuidado con una trampa común aquí.

    @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)
    }
  • En la mayoría de los casos, querrás omitir el primer evento.

  • Esto evitará que se dupliquen innecesariamente las búsquedas o que se reemplace tu ViewModel con el valor inicial.

  • Bien, si tu lógica necesita retroalimentar esos cambios al estado, por ejemplo, cuando el usuario presiona el botón de enviar y quieres borrar el texto, no puedes simplemente establecer una cadena vacía en el ViewModel del 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)
    }
  • En su lugar, debes emitir un evento de texto claro y, luego, la función de componibilidad que contiene ese objeto TextFieldState llamará a clearText en ese objeto.

  • Para ayudarte aún más con la transición, escribimos esta guía de migración.

  • Puedes acceder a ella a través de este vínculo.

  • También estamos trabajando en algunas APIs adicionales para facilitar la migración y la observación de los cambios generales de TextFieldState, así que mantente al tanto.

Edición de texto enriquecido y con formato

  • Y todo lo que vimos hasta ahora, TextFieldState, TextFieldBuffer, la transformación de entrada y salida, no se creó solo para la entrada de texto sin formato.

  • Esta es la base que impulsa nuestro conjunto más reciente de APIs para la edición de texto con formato.

  • Ahora, Haoyu les contará más al respecto.

  • Bien.

  • Gracias.

  • Hola a todos.

  • A continuación, hablaré sobre las nuevas APIs que presentamos para la edición de texto con formato.

  • Antes de profundizar en las nuevas APIs, retrocedamos un poco y veamos cómo funciona el diseño en el texto estático.

  • Por lo tanto, la mayoría de ustedes ya conoce muy bien 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)
  • Es la forma estándar de aplicar diseño a un texto estático.

  • Usamos buildAnnotatedString y llamamos a addStyle para aplicar SpanStyle o ParagraphStyle en ciertos rangos determinados.

  • Funciona bien para el texto estático.

  • Cuando analizamos la edición de texto con formato, lo primero que pensamos fue si podíamos hacer que AnnotatedString fuera editable.

  • Pero luego nos dimos cuenta de que no es tan sencillo.

  • AnnotatedString es una estructura de datos inmutable.

  • Sin embargo, en el mundo de la edición, el usuario escribe constantemente.

  • Y con cada tecla, cada pulsación de tecla, tenemos que asignar un nuevo objeto AnnotatedString.

  • Y en cada AnnotatedString, hay muchos AnnotatedString.Range.

  • Por lo tanto, esos objetos también se deben volver a crear.

  • No se escala muy bien y causa una sobrecarga de asignación significativa.

  • Mira este ejemplo.

  • Cuando insertamos la palabra en el medio, todos los estilos posteriores cambian su rango y se reasignan.

  • Esto claramente no funciona cuando tienes muchos estilos.

  • Por lo tanto, introdujimos APIs de diseño dedicadas dentro de TextFieldBuffer que Anastasia presentó antes.

  • Son muy similares a las APIs de diseño en AnnotatedString.

  • Analicemos cómo funcionan.

  • Al igual que AnnotatedString.Builder, TextFieldBuffer ahora tiene addStyle para aplicar SpanStyle o ParagraphStyle a un rango.

    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>
  • También puedes pasar un parámetro ExpandPolicy, que controla cómo se comporta el estilo cuando se escribe texto justo en los límites del estilo.

  • Analizaremos ese tema en un momento.

  • Primero, veamos cómo funciona el seguimiento de estilo.

    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)
    }
  • Una vez que agregas un estilo al TextFieldBuffer, su rango se actualiza automáticamente a medida que se edita el texto.

  • En este ejemplo, empezamos por poner en negrita un texto.

  • Cuando insertas texto antes de él, el rango de estilo se desplaza hacia adelante.

  • Cuando insertas texto dentro de él, el rango de texto se expande.

  • Y, cuando borramos texto dentro del rango, el rango de estilo se reduce según corresponda.

  • Si se borra todo el texto dentro del rango de estilo, este se contrae y se quita automáticamente del búfer.

  • ¿Qué sucede cuando se inserta el texto justo en los límites de un rango de estilo?

  • ¿El texto recién insertado está cubierto por el estilo o no?

  • Por lo tanto, para que puedas controlar esto, presentamos las APIs de ExpandPolicy.

  • Veamos, analicémoslas.

  • La primera es 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")
  • En este modo, insertar texto en el límite no expandirá los rangos de estilo.

  • Aquí, primero aplicamos un rango en negrita.

  • Si insertamos texto al principio del rango en negrita, el rango de estilo se desplaza, pero no se expande.

  • Del mismo modo, si insertamos texto al final del rango en negrita, el rango de estilo tampoco se expande.

  • Aquí tienes una demostración de cómo funciona en un editor.

  • A continuación, se encuentra ExpandPolicy.AtStart.

    val state = TextFieldState("The fox")
    
    state.edit {
        addStyle(
            SpanStyle(fontWeight = FontWeight.Bold),
            start = 4,
            end = 7,
            policy = ExpandPolicy.AtStart
        )
        insert(4, "quick ")
  • Como sugiere el nombre, insertar texto al comienzo del rango expande el rango de estilo.

  • Por lo tanto, cuando insertamos texto justo antes, el rango de estilo se expande para abarcar el texto recién insertado.

  • Aquí tienes otra demostración de cómo funciona.

  • En tercer lugar, se encuentra ExpandPolicy.AtEnd.

    val state = TextFieldState("The fox")
    
    state.edit {
        addStyle(
            SpanStyle(fontWeight = FontWeight.Bold),
            start = 4,
            end = 7,
            policy = ExpandPolicy.AtEnd
        )
        insert(length, " jumps")
  • Por lo tanto, insertamos texto al final del rango para expandir el rango de estilo.

  • Por lo tanto, este también es el parámetro de configuración más común para los editores.

  • Aquí tienes una demostración de cómo funciona.

  • Por último, está ExpandPolicy.AtBoth, que expande el estilo cuando se inserta el texto al principio y al final del rango.

    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")
    }
  • Así que, otra demostración para esto.

  • Hasta ahora, vimos todas las APIs para aplicar estilos.

  • Además, presentamos nuevas APIs de consulta, como getSpanStyles y getParagraphStyles, para recuperar los estilos que se aplican actualmente en un rango determinado.

    // 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>>
  • Aquí tienes dos versiones de las APIs de consulta.

  • En TextFieldState, las APIs de consulta de diseño son de solo lectura.

  • Devuelven una lista de objetos AnnotatedString.Range inmutables.

  • Sin embargo, en TextFieldBuffer, getSpanStyles devolvió una lista de objetos TrackedRange recién introducidos, que presentaré más adelante.

  • Por lo tanto, todas las consultas de estilo tienen en cuenta el orden.

  • Devuelven los rangos de estilo exactamente en el orden en que se aplica el estilo.

  • Esto te permite aplicar, al igual que AnnotatedString, otro estilo sobre uno existente para reemplazar los anteriores.

  • A continuación, se encuentra la API de TrackedRange.

    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
  • Las APIs de addStyle o getSpanStyles devuelven objetos TrackedRange en TextFieldBuffer.

  • Piensa en él como un controlador en vivo del rango de estilo en el búfer.

  • Puedes usarlo para actualizar el rango de texto, ExpandPolicy o el estilo en sí en el lugar.

  • También tiene una propiedad isValid para verificar si el rango de estilo aún está en TextFieldBuffer.

  • Esto se debe a que el estilo se puede quitar si se contrae durante la edición o si el removeStyle lo quita de forma deliberada.

  • A continuación, veamos cómo se combinan esas APIs en casos de uso reales.

Cómo compilar un editor WYSIWYG

  • El primer caso de uso es compilar un editor WYSIWYG (lo que ves es lo que obtienes).

  • En este ejemplo, implementamos la función del botón de negrita.

  • Si haces clic en el botón de negrita, se activa o desactiva el estilo de negrita en el texto seleccionado.

  • A continuación, veamos el código.

  • Por lo tanto, cuando el usuario presiona el botón de negrita, primero llamamos a getSpanStyles en una selección para encontrar todos los estilos superpuestos.

    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())
        }
    }
  • Luego, verificamos si toda la selección ya está en negrita o no.

  • Aquí, la palabra seleccionada no está en negrita.

  • Como no está en negrita, addStyle al rango seleccionado.

  • Pasamos ExpandPolicy.AtEnd para que el usuario pueda seguir escribiendo en negrita justo después de esta palabra.

  • Ahora, veamos qué sucede si el rango seleccionado ya está en negrita.

    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)
        }
    }
  • En ese caso, debemos borrar el estilo de negrita del rango seleccionado.

  • Aquí, la palabra seleccionada por el usuario está dentro de la frase más larga en negrita.

  • Como el estilo en negrita se extiende antes de la selección, debemos actualizar el rango de estilo y dejar la palabra seleccionada sin negrita.

  • Para ello, podemos usar una API de TrackedRange.

  • En este caso específico, en lugar de borrar este estilo y volver a crearlo, podemos ajustar el rango del intervalo de negrita en el lugar para que termine antes de la selección en el índice 10.

  • Por supuesto, esta es una implementación muy simplificada.

  • Para obtener una demostración completa, puedes consultar nuestro repositorio de muestras de Compose.

  • A continuación, veamos un diseño basado en patrones.

  • En esta demostración, cuando el usuario escribe asteriscos de Markdown alrededor de una palabra, el editor la convierte automáticamente en texto en negrita a medida que escribe.

  • Ahora, veamos cómo se implementa con 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
                )
            }
        }
    }
  • En nuestro filtro de entrada de transformación, encontramos todos los patrones que coinciden con el botón.

  • Y, para cada coincidencia, extraemos el contenido dentro de los asteriscos dobles.

  • En este ejemplo, es la palabra "fox".

  • Luego, quitamos los asteriscos y reemplazamos el patrón coincidente por su contenido.

  • Por último, llamamos a addStyle en negrita para aplicar el estilo de negrita al texto del contenido.

  • Solo recuerda que InputTransformation se enviará al estado final del campo de texto.

  • Esto significa que el estilo en negrita se aplica al estado de forma persistente.

  • Por último, veamos el ejemplo de búsqueda con resaltado.

  • Por lo tanto, en este ejemplo, cuando el usuario escribe una búsqueda, las palabras que coinciden en los campos de texto se destacan sin modificar el estado del campo de texto.

  • Ahora, veamos cómo se implementa en OutputTransformation.

  • Cuando destaques una palabra de búsqueda, nunca debes mutar el estado del texto.

  • Y OutputTransformation es el lugar perfecto para implementar este caso de uso.

  • Revisemos la implementación real.

  • Primero, buscamos las palabras destacadas y encontramos todas las coincidencias.

    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)
            }
        }
    }
  • Ten en cuenta que la consulta es un estado mutable.

  • Esto se debe a que OutputTransformation reconoce las instantáneas.

  • Cuando se actualiza este estado mutable, se vuelve a aplicar OutputTransformation automáticamente.

  • Luego, cuando recorremos cada coincidencia y llamamos a addStyle para dibujar los elementos destacados en amarillo sobre el rango de coincidencia.

  • Nuevamente, OutputTransformation solo cambia el texto visual, y los estados del texto original no se modifican.

  • Bien, esos son todos los casos de uso que tenemos.

  • Solo para recordarte, todas las APIs que se mostraron antes están disponibles a partir de Compose 1.12.

Próxima biblioteca de texto de lenguaje de marcado

  • Para terminar, quiero compartir un adelanto del nuevo proyecto en el que estamos trabajando.

  • Es una biblioteca de texto de lenguaje de marcado para Jetpack Compose, que admite formatos de texto enriquecido, como Markdown.

  • Una de las funciones más interesantes que admitimos es el análisis y la renderización incrementales.

  • Por lo tanto, es muy adecuado para la edición en vivo o la transmisión de Markdown.

  • Aquí hay un registro de nuestro prototipo en funcionamiento, y puedes ver que esas áreas que se desvanecen son las partes que se actualizan en la IU.

  • Si te interesa o tienes algún caso de uso en tu app, no dudes en comunicarte con nosotros.

  • Y gracias a todos.

Código

Fragmentos de código

Fragmento de Kotlin en el minuto 0:29 de "Introducción a la edición de texto en Compose"
TextField(state = rememberTextFieldState())
Fragmento de Kotlin en el minuto 00:29 de "Introducción a la edición de texto en Compose"
OutlinedTextField(state = rememberTextFieldState())
Fragmento de Kotlin en el minuto 0:34 de "Introducción a la edición de texto en Compose"
BasicTextField(state = rememberTextFieldState())
Fragmento de Kotlin en el minuto 0:44 de "Introducción a la edición de texto en Compose"
SecureTextField(state = rememberTextFieldState())
Fragmento de Kotlin en el minuto 00:58 de "Introducción a la edición de texto en Compose"
@Composable
public fun BasicTextField(
    state: TextFieldState,
)
Fragmento de Kotlin en el minuto 1:28 de "Introducción a la edición de texto en Compose"
@Composable
public fun BasicTextField(
    value: String,
    onValueChange: (String) -> Unit
)

@Composable
public fun BasicTextField(
    value: TextFieldValue,
    onValueChange: (TextFieldValue) -> Unit
Fragmento de Kotlin en el minuto 04:01 de "Presentamos TextFieldState"
// 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) }
Fragmento de Kotlin en el minuto 4:09 de "Presentamos TextFieldState"
// Batch all operations in a single atomic edit:
state.edit {
insert(5, ",")
delete(12, 13)
replace(0, 5, "goodbye")
selection = TextRange(9, 16)
}
Fragmento de Kotlin en el minuto 04:25 de "Presentamos TextFieldState"
fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}
Fragmento de Kotlin en el minuto 4:38 de "Presentamos TextFieldState"
fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}
Fragmento de Kotlin en el minuto 04:55 de "Filtrado y formato de entradas"
onValueChange { newString ->
  // full diffing new String vs old String
}
Fragmento de Kotlin en el minuto 05:17 de "Filtrado y formato de entradas"
@Stable
public fun interface InputTransformation {
    public fun TextFieldBuffer.transformInput()
}
Fragmento de Kotlin en el minuto 05:33 de "Input filtering and formatting"
// Intercept and filter input during typing
inputTransformation = InputTransformation {
    val text = asCharSequence()
    if (!text.all { it.isDigit() }) {
        // Reject non-digit characters
        revertAllChanges()
    }
}
Fragmento de Kotlin en el minuto 6:48 de "Input filtering and formatting"
@Stable
public fun interface OutputTransformation {
    public fun TextFieldBuffer.transformOutput()
}
Fragmento de Kotlin en el minuto 06:58 de "Input filtering and formatting"
// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
    if (length > 6) {
        insert(6, "-")
    }
    if (length > 3) {
        insert(3, "-")
    }
}
Fragmento de Kotlin en el minuto 07:51 de "Elevación y migración de 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()) }) { }
    )
}
Fragmento de Kotlin en el minuto 8:58 de "Elevación y migración de 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)
        }
    }
Fragmento de Kotlin en el minuto 09:27 de "Cómo elevar y migrar 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,
    )
}
Fragmento de Kotlin en el minuto 10:12 de "Elevación y migración de 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) { ... }

}
Fragmento de Kotlin en el minuto 10:30 de "Cómo elevar y migrar TextFieldState"
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}

@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
    BasicTextField(
        state = state.textFieldState,
        inputTransformation = state.inputTransformation,
        outputTransformation = state.outputTransformation,
    )
}
Fragmento de Kotlin en el minuto 10:51 de "Hoisting and migrating TextFieldState"
data class FormUiState(val username: TextFieldState)
Fragmento de Kotlin en el minuto 11:38 de "Hoisting and migrating TextFieldState"
class FormViewModel : ViewModel() {
   val username = TextFieldState()
   val uiState: StateFlow<FormUiState> = ...
}
Fragmento de Kotlin en el minuto 12:09 de "Hoisting and migrating TextFieldState"
class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
    val username: TextFieldState = savedStateHandle.saveable(
        key = "username",
        saver = TextFieldState.Saver,
        init = { TextFieldState() }
    )
   val uiState: StateFlow<FormUiState> = ...
}
Fragmento de Kotlin en el minuto 12:55 de "Elevación y migración de 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)
}
Fragmento de Kotlin en el minuto 13:21 de "Hoisting and migrating 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)
}
Fragmento de Kotlin en el minuto 13:39 de "Cómo elevar y migrar 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)
}
Fragmento de Kotlin en el minuto 15:09 de "Edición de texto enriquecido y con formato"
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)
Fragmento de Kotlin en el minuto 16:36 de "Edición de texto enriquecido y con formato"
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>
Fragmento de Kotlin en el minuto 16:57 de "Edición de texto enriquecido y con formato"
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)
}
Fragmento de Kotlin en el minuto 17:52 de "Edición de texto enriquecido y con formato"
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")
Fragmento de Kotlin en el minuto 18:22 de "Edición de texto enriquecido y con formato"
val state = TextFieldState("The fox")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 7,
        policy = ExpandPolicy.AtStart
    )
    insert(4, "quick ")
Fragmento de Kotlin en el minuto 18:40 de "Edición de texto enriquecido y con formato"
val state = TextFieldState("The fox")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 7,
        policy = ExpandPolicy.AtEnd
    )
    insert(length, " jumps")
Fragmento de Kotlin en el minuto 18:56 de "Edición de texto enriquecido y con formato"
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")
}
Fragmento de Kotlin en el minuto 19:14 de "Edición de texto enriquecido y con formato"
// 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>>
Fragmento de Kotlin en el minuto 20:03 de "Edición de texto enriquecido y con formato"
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
Fragmento de Kotlin en el minuto 21:01 de "Cómo compilar un editor 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())
    }
}
Fragmento de Kotlin en el minuto 21:26 de "Cómo compilar un editor 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)
    }
}
Fragmento de Kotlin en el minuto 22:27 de "Cómo compilar un editor WYSIWYG"
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
            )
        }
    }
}
Fragmento de Kotlin en el minuto 23:42 de "Cómo compilar un editor WYSIWYG"
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)
        }
    }
}