Cómo aplicar diseño a la entrada de texto enriquecido

La entrada de texto enriquecido permite que los usuarios den formato a lo que escriben, como poner una palabra en negrita, agregar un encabezado, alinear un párrafo o insertar un @mention destacado. En Compose, compilas esto sobre los campos de texto basados en el estado (BasicTextField y TextField). Los estilos se encuentran en TextFieldState junto con el texto, por lo que sobreviven a las ediciones, los cambios de selección y las recomposiciones, y se mantienen sincronizados a medida que el usuario escribe, borra y pega.

En esta página, se muestra cómo aplicar, actualizar, consultar, extraer y quitar estilos en un TextFieldState con TextFieldBuffer, y cómo controlar una barra de herramientas de formato desde TextFieldState.textStyles.

Cómo aplicar estilos intercalados y de párrafo

Puedes aplicar formato a nivel de carácter y de párrafo dentro de cualquier alcance del receptor de TextFieldBuffer, incluidos TextFieldState.edit, InputTransformation.transformInput y OutputTransformation.transformOutput.

Controla la expansión de límites con ExpandPolicy

Cuando llamas a addStyle, puedes pasar un ExpandPolicy para definir cómo se comporta el rango con diseño cuando el usuario inserta caracteres nuevos directamente en su límite start o end:

  • Inserción dentro de (start, end): Siempre expande el rango con diseño.
  • Inserción antes de start: Siempre desplaza los desplazamientos start y end hacia la derecha.
  • Inserción directamente en start o end: Se controla con el ExpandPolicy que especifiques.

En la siguiente tabla, se describen las opciones de ExpandPolicy disponibles y sus casos de uso típicos:

Política Comportamiento de límite Casos de uso recomendados Ejemplo
ExpandPolicy.InsideOnly El texto que se escribe en start o end permanece fuera del rango con diseño. Tokens discretos, como @mentions, #hashtags o pastillas de fondo, en los que escribir junto a la etiqueta no debería heredar el color de fondo de la etiqueta. Animación que muestra el texto escrito al principio o al final de un rango de ExpandPolicy.InsideOnly sin estilo.
ExpandPolicy.AtStart El texto que se escribe en start hereda el estilo, y el texto que se escribe en end permanece sin estilo. El texto que se escriba directamente delante del texto con estilo también debe adoptar el mismo formato. Animación que muestra el texto que se escribe al principio de un rango de ExpandPolicy.AtStart y que hereda el estilo, mientras que el texto que se escribe al final permanece sin estilo.
ExpandPolicy.AtEnd El texto que se escribe en end hereda el estilo, y el texto que se escribe en start permanece sin estilo. Escritura estándar en el editor de texto enriquecido, en la que, si se sigue escribiendo después de una palabra en negrita o cursiva, se mantiene ese formato. Animación que muestra el texto que se escribe al final de un rango de ExpandPolicy.AtEnd que hereda el estilo, mientras que el texto que se escribe al principio permanece sin estilo.
ExpandPolicy.AtBoth El texto que se escribe en start o end expande el rango y hereda el estilo. Son bloques con formato continuo o destacados activos que deben crecer desde cualquiera de los bordes. Animación que muestra el texto que se escribe al principio o al final de un rango de ExpandPolicy.AtBoth que hereda el estilo.

Aplica formato de caracteres con SpanStyle

Usa addStyle con un SpanStyle para aplicar formato a nivel de carácter, como el color del texto, el resaltado del fondo, FontWeight, FontStyle o el subrayado a un TextRange específico en el búfer.

Comportamientos clave para los rangos de SpanStyle

  • Orden de las capas: Los estilos se aplican en el orden en que los agregas al búfer. Si varios rangos de SpanStyle superpuestos establecen la misma propiedad con diferentes valores, el estilo que se agrega más tarde se renderiza en la parte superior.
  • Requisito de rango no vacío: Debes pasar un rango no contraído en el que start < end. Llamar a addStyle en una selección contraída de longitud 0 no tiene efecto, y pasar índices invertidos o fuera de los límites arroja una IllegalArgumentException.

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

Un TextField que aplica de forma dinámica el formato rojo en negrita y cursiva a medida que el usuario escribe.
Figura 1: Aplicar de forma dinámica el formato rojo en negrita y cursiva a la entrada del usuario

Aplica el formato de bloque con ParagraphStyle

Usa addStyle con un ParagraphStyle para aplicar un formato estructural a nivel de bloque, como textAlign, lineHeight, textDirection o textIndent, a un rango de párrafos.

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

Un TextField que alterna la alineación de párrafos entre el inicio, el centro y el final con ParagraphStyle.
Figura 2: Alternar la alineación de los párrafos entre el inicio, el centro y el final con ParagraphStyle

Errores que se deben evitar con ParagraphStyle

Debido a que ParagraphStyle se aplica a bloques de texto completos, evita usarlo en las siguientes situaciones:

  • Expansión a párrafos adyacentes: Configura ExpandPolicy con cuidado para que los rangos de ParagraphStyle adyacentes no se superpongan después de las ediciones del usuario. Por ejemplo, si [0, 5) usa ExpandPolicy.AtEnd y [5, 10) usa ExpandPolicy.AtStart, insertar texto en el índice 5 expande ambos estilos en una superposición y arroja una IllegalArgumentException.
  • Anidamiento de rangos ParagraphStyle: Aunque los rangos completamente anidados (como [0, 10) y [2, 8)) son válidos en AnnotatedString, evita anidar rangos ParagraphStyle en un campo de texto. El diseño divide los rangos anidados en párrafos separados, y las ediciones posteriores pueden convertir, de forma involuntaria, los rangos anidados en una superposición parcial.

Actualiza un diseño in situ con TrackedRange

Si llamas a addStyle con un TextRange y un ExpandPolicy, se muestra un controlador TrackedRange, que puede ser TrackedRange<SpanStyle> o TrackedRange<ParagraphStyle>. Un TrackedRange es una referencia dinámica a un intervalo de estilo en el TextFieldBuffer que actualiza automáticamente sus desplazamientos de caracteres start y end a medida que se inserta, borra o reemplaza texto a su alrededor.

Mutabilidad local

No es necesario que llames a removeStyle ni a addStyle para actualizar un estilo existente o ajustar sus límites. Dentro de un permiso TextFieldBuffer, TrackedRange proporciona propiedades de extensión que actualizan el intervalo en su lugar y, al mismo tiempo, conservan su orden de capas original en relación con otros estilos:

  • TrackedRange<SpanStyle>.spanStyle: Lee o reemplaza la carga útil de SpanStyle en un TrackedRange<SpanStyle>.
  • TrackedRange<ParagraphStyle>.paragraphStyle: Lee o reemplaza la carga útil de ParagraphStyle en un TrackedRange<ParagraphStyle>.
  • TrackedRange<*>.textRange: Lee o actualiza los límites actuales de TextRange en su lugar. Si se establece un rango contraído, invertido o fuera de los límites, se arroja una IllegalArgumentException.
  • TrackedRange<*>.expandPolicy: Lee o actualiza el ExpandPolicy in situ.
  • TrackedRange<*>.isValid: Devuelve true si el intervalo de estilo aún está activo en el búfer o false si las ediciones hicieron que su longitud se contrajera a 0 o si se quitó.

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

Confinamiento del alcance y reglas del ciclo de vida

Ten en cuenta las siguientes reglas de alcance y ciclo de vida cuando trabajes con instancias de TrackedRange:

  • Limitadas a TextFieldBuffer: Las propiedades de extensión de TrackedRange solo existen dentro del alcance de TextFieldBuffer en el que se creó o consultó el controlador.
  • Reducción automática en el colapso: Si las ediciones del usuario borran todos los caracteres cubiertos por un TrackedRange, su longitud se reduce a 0, isValid devuelve false y el estilo se quita del búfer.

Cómo consultar y quitar estilos

Compose proporciona dos formas de inspeccionar los estilos activos, según si estás mutando el búfer o leyendo el estado de tu IU:

  • Dentro de un bloque TextFieldBuffer: Llama a getSpanStyles y getParagraphStyles directamente en el receptor TextFieldBuffer para obtener controladores TrackedRange mutables.
  • Fuera de un bloque de edición: Lee TextFieldState.textStyles, que devuelve listas AnnotatedString.Range inmutables que reflejan el estado confirmado.

Consulta estilos que se intersecan en TextFieldBuffer

TextFieldBuffer.getSpanStyles y TextFieldBuffer.getParagraphStyles devuelven todas las instancias de TrackedRange que se cruzan con el TextRange determinado, ordenadas según el momento en que se agregaron al búfer.

Por ejemplo, puedes consultar getSpanStyles(selection) dentro de state.edit para inspeccionar o actualizar los estilos que abarcan la selección actual del usuario:

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

Los intervalos de estilo usan un rango matemático semiabierto [start, end). La devolución de un estilo de contacto depende de si consultas una selección o un solo punto del cursor:

  • Para una selección no vacía [start, end):
    • Se muestra: Cualquier estilo que se superponga, contenga o se encuentre dentro del rango de selección. Por ejemplo, la consulta [5, 15) coincide con [0, 20), [8, 12) o [0, 10).
    • No se devuelven: Estilos que solo tocan los bordes exteriores sin caracteres superpuestos. Por ejemplo, la consulta [5, 15) excluye [0, 5) y [15, 25).
  • Para un cursor contraído [index, index]:
    • Devuelto: Cualquier estilo que contenga el cursor o que comience en la posición del cursor. Por ejemplo, un cursor en 10 coincide con [0, 20) y [10, 20).
    • No se devuelve (regla de finalización de contacto): Cualquier estilo que termine en la posición del cursor. Por ejemplo, un cursor en 10 excluye [0, 10).

Cómo quitar o activar o desactivar estilos con removeStyle

Pasa un controlador TrackedRange a removeStyle para borrar ese intervalo de estilo específico de TextFieldBuffer.

removeStyle devuelve true si se encontró y quitó el TrackedRange, o false si ya se quitó o pertenecía a un búfer diferente. Solo quita el controlador TrackedRange de destino y deja intactos otros estilos superpuestos.

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

Un TextField que activa y desactiva el formato de negrita para las palabras seleccionadas.
Figura 3: Alternar entre poner el texto en negrita y quitarle la negrita

Extrae o serializa texto enriquecido

Para mostrar la entrada con formato en un elemento Text de solo lectura que admite composición o serializarla en un servicio de backend, lee TextFieldState.textStyles fuera de un bloque de edición.

state.textStyles.getSpanStyles y state.textStyles.getParagraphStyles devuelven List<AnnotatedString.Range<SpanStyle>> y List<AnnotatedString.Range<ParagraphStyle>>, que puedes pasar directamente al constructor AnnotatedString:

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

Ejemplo completo en Jetchat

Para ver una implementación completa que combina el diseño de caracteres intercalados (negrita, cursiva, subrayado, tachado y color), consulta la muestra de Jetchat en GitHub:

  • RichTextState.kt: Encapsula las consultas de estilo TextFieldState, el cambio de intervalo, el recorte o la división entre los límites de selección, y mucho más.
  • UserInput.kt: Conecta RichTextState a la barra de herramientas de formato de texto enriquecido y al campo de entrada de mensajes.
La app de ejemplo Jetchat que muestra el formato de texto enriquecido en la barra de entrada de mensajes.
Figura 4. Edición de texto enriquecido en Jetchat