View in English

  • Global Nav Open Menu Global Nav Close Menu
  • Apple Developer
Search
Cancel
  • Apple Developer
  • News
  • Discover
  • Design
  • Develop
  • Distribute
  • Support
  • Account
Only search within “”

Quick Links

5 Quick Links

Videos

Abrir menú Cerrar menú
  • Colecciones
  • Temas
  • Todos los videos
  • Información

Volver a WWDC25

  • Información
  • Resumen
  • Transcripción
  • Código
  • Codificación conjunta: Crea una experiencia de texto enriquecida en SwiftUI con AttributedString

    Aprende a crear una experiencia de texto enriquecida con la API TextEditor y AttributedString de SwiftUI. Descubre cómo habilitar la edición de texto enriquecido, crear controles personalizados que manipulen el contenido de tu editor y personalizar las opciones de formato disponibles. Explora las capacidades avanzadas de AttributedString que te ayudarán a crear las mejores experiencias de edición de texto.

    Capítulos

    • 0:00 - Introducción
    • 1:15 - TextEditor y AttributedString
    • 5:36 - Crea controles personalizados
    • 22:02 - Define el formato de tu texto
    • 34:08 - Próximos pasos

    Recursos

    • AttributedTextFormatting
    • AttributedTextSelection
    • Building rich SwiftUI text experiences
    • Character
      • Video HD
      • Video SD

    Videos relacionados

    WWDC25

    • Mejora la experiencia multilingüe de tu app

    WWDC22

    • Get it right (to left)

    WWDC21

    • What's new in Foundation
  • Buscar este video…

    Hola, soy Max, ingeniero del equipo de SwiftUI, y yo Jeremy, ingeniero del equipo de bibliotecas estándar de Swift. Nos complace compartir cómo puedes crear experiencias de edición de texto enriquecido mediante SwiftUI y AttributedString. Con la ayuda de Jeremy, cubriré los aspectos clave de las experiencias de texto enriquecido en SwiftUI. Primero, hablaré de actualizar TextEditor para que admita texto enriquecido mediante AttributedString. Luego, crearé controles personalizados, mejorando mi editor con funcionalidades únicas. Por último, crearé mi propia definición de formato de texto para que mi editor y su contenido tengan un gran aspecto.

    Aunque de día soy ingeniero, también soy... ¡un cocinero en casa con la misión de hacer el croissant perfecto! Recientemente, creé un pequeño editor de recetas para registrar mis intentos.

    Tiene una lista de recetas a la izquierda, un TextEditor para editar el texto de la receta en el centro y una lista de ingredientes en el inspector a la derecha. Me encantaría resaltar las partes más importantes del texto de mis recetas para poder echarles un vistazo mientras cocino. Lo haré posible, actualizaré el editor para que admita texto enriquecido.

    Esta es la vista del editor, implementada usando la API TextEditor de SwiftUI. Como indica el tipo de estado de mi texto, String, actualmente solo admite texto simple. Puedo cambiar String por AttributedString, incrementando drásticamente las capacidades de la vista.

    Ahora que tengo soporte para texto enriquecido, puedo usar la IU del sistema para cambiar la negrita y aplicar otros formatos, como aumentar el tamaño de la fuente.

    También puedo insertar Genmoji con el teclado.

    Y gracias a la naturaleza semántica de los atributos de tipo de letra y color de SwiftUI, el nuevo TextEditor también admite el Modo Oscuro y el Tamaño de letra dinámico.

    TextEditor admite negrita, cursiva, subrayado, tachado, tamaño de punto de fuentes personalizadas, colores de frente y fondo, interletraje, tracking, desplazamiento de la línea de base, Genmoji y… ¡aspectos importantes del estilo de los párrafos! La altura de la línea, alineación del texto y dirección de la escritura base también están disponibles como atributos AttributedString independientes para SwiftUI.

    Todos estos atributos son coherentes con Text no editable de SwiftUI, por lo que puedes tomar el contenido de un TextEditor y mostrarlo con Text. Como con Text, TextEditor sustituye el valor por defecto calculado a partir del entorno para cualquier AttributedStringKeys con un valor nulo. Jeremy, debo ser honesto… Logré trabajar con AttributedStrings hasta ahora, pero me vendría bien un repaso para asegurarme de que mis conocimientos son sólidos antes de empezar a crear controles. Además, tengo que hacer esa masa de croissant, ¿te importaría compartir un repaso mientras lo hago? ¡Por supuesto, Max! Mientras empiezas a preparar la masa, hablaré de algunos conceptos básicos de AttributedString que te resultarán útiles cuando trabajes con editores de texto enriquecido. En resumen, los AttributedStrings contienen una secuencia de caracteres y una secuencia de ejecuciones de atributos. Por ejemplo, este AttributedString almacena el texto “¡Hola! ¿Quién tiene ganas de cocinar?”, y tiene tres ejecuciones de atributos: una ejecución inicial con una fuente, una con un color de frente adicional y una final con el atributo de fuente.

    El tipo AttributedString encaja perfectamente con otros tipos de Swift que usas en tus apps. Es un tipo de valor, y como String de la biblioteca estándar, almacena el contenido usando la codificación UTF-8. AttributedString también ofrece conformidad con muchos protocolos comunes de Swift, como Equatable, Hashable, Codable y Sendable. Los SDK de Apple incluyen varios atributos predefinidos, como los que Max compartió anteriormente, agrupados en ámbitos de atributos. AttributedString admite atributos personalizados que se definen en la app para personalizar el estilo de la IU.

    Crearé mi AttributedString de antes con unas pocas líneas de código. Primero, creo un AttributedString con el inicio de mi texto. Luego, creo la cadena “cocinar”, establezco el color de frente en naranja y la agrego al texto. A continuación, completo la frase con el signo de interrogación final. Por último, establezco la fuente de todo el texto a largeTitle. Ahora estoy listo para mostrarlo en la IU de mi dispositivo.

    Para más información sobre los aspectos básicos de la creación de AttributedStrings, y sobre la creación y el uso de atributos y ámbitos de atributos personalizados, consulta la sesión “What's new in Foundation” de la WWDC 2021.

    Max, ¡parece que terminaste de hacer la masa! ¿Todo listo para sumergirte en los detalles de uso de AttributedString en tu app de recetas? Desde luego, Jeremy, eso suena justo a lo que necesitaba. Me gustaría ofrecer mejores controles para conectar mi editor de texto con el resto de mi app. Quiero crear un botón que me permita agregar ingredientes a la lista del inspector de la derecha sin tener que volver a escribir el nombre del ingrediente de forma manual. Por ejemplo, solo quiero poder seleccionar la palabra “mantequilla”, que ya está en mi receta y marcarla como ingrediente presionando un botón.

    Mi inspector ya define una clave de preferencia que puedo usar en el editor para sugerir un nuevo ingrediente para la lista.

    El valor que paso al modificador de vista de preferencias aparecerá en la jerarquía de vistas y podrá leerse a través del nombre “NewIngredientPreferenceKey” por cualquier vista que use mi editor de recetas.

    Definamos una propiedad computada para este valor debajo de mi vista.

    Todo lo que necesito proporcionar para la sugerencia es el nombre, como AttributedString. Por supuesto, no solo quiero sugerir todo el texto que está en mi editor. En su lugar, quiero sugerir el texto que está actualmente seleccionado, como mostré con “mantequilla”. TextEditor comunica lo que está seleccionado través del argumento de selección opcional.

    Vincularé esto a un estado local de tipo AttributedTextSelection.

    Bien, ahora que tengo todo el contexto que necesito disponible en mi vista, déjame volver a la propiedad que computa la sugerencia de ingrediente.

    Ahora, necesito obtener el texto seleccionado.

    Voy a probar a suscribir texto con el resultado de esta función de índices en la selección.

    No parece ser el tipo correcto.

    Devuelve AttributedTextSelection.Indices. Déjame comprobarlo.

    Eso es interesante, solo tengo una selección, pero el segundo caso del tipo de Índices está representado por un conjunto de rangos.

    Jeremy, ¿puedes explicarme por qué mientras me pongo a doblar la masa de mi croissant? Eso es gracioso. Yo me rindo ante la anticipación de estos sabrosos croissants. Pero no te preocupes Max, te explicaré por qué esta API no usa el tipo Range que esperabas. Para explicar por qué esta API usa un RangeSet y no un Range único, hablaré de las selecciones AttributedString. Analizaré cómo múltiples rangos pueden formar selecciones y demostraré cómo usar RangeSets en tu código. Posiblemente hayas usado un rango único en las API de AttributedString o en otras API de colecciones. Un rango único permite porcionar una parte de un AttributedString y realizar una acción sobre esa porción única. AttributedString proporciona unas API que permiten aplicar rápidamente un atributo a una porción de texto de una sola vez. Usé la API .range(of:) para encontrar el rango del texto “cocinar” en mi AttributedString. Luego uso el operador de subíndice para cortar el AttributedString con ese rango para que toda la palabra “cocinar” sea de color naranja.

    Sin embargo, un AttributedString porcionado con un solo rango no es suficiente para representar selecciones en un editor de texto que funcione para todos los idiomas. Por ejemplo, puedo usar esta app de recetas para guardar la receta de sufganiá que pienso cocinar y que incluye texto en hebreo. Mi receta dice “Poner las sufganiás en la sartén”, que usa texto en inglés para las instrucciones y texto en hebreo para el nombre tradicional del alimento. En el editor de texto, seleccionaré una parte de la palabra “sufganiá” y la palabra “en” con una sola selección. Sin embargo, en realidad se trata de varios rangos en el AttributedString. Dado que el inglés es un idioma de izquierda a derecha, el editor presenta la frase visualmente de izquierda a derecha. Sin embargo, la porción hebrea, sufganiá, está dispuesta en sentido contrario, ya que el hebreo es un idioma de derecha a izquierda. Aunque la naturaleza bidireccional de este texto afecta la disposición visual en la pantalla, AttributedString sigue almacenando todo el texto en un orden coherente. Este orden divide mi selección en dos rangos: el comienzo de la palabra “sufganiá” y la palabra “en”, excluyendo el final del texto hebreo. Esta es la razón por la que el tipo de selección de texto SwiftUI usa múltiples rangos en lugar de un único rango.

    Para obtener más información sobre cómo localizar tu app para texto bidireccional, consulta la sesión “Get it right (to left)” de la WWDC 2022 y la sesión “Enhance your app's multilingual experience” de este año.

    Para admitir este tipo de selecciones, AttributedString admite el porcionamiento con un RangeSet, el tipo que Max observó anteriormente en la API de selección. Al igual que se puede porcionar un AttributedString con un rango singular, se puede porcionar con un RangeSet para producir una subcadena discontinua. En este caso creé un RangeSet con la función .indices(where:) en la vista de caracteres para encontrar los caracteres en mayúsculas en mi texto. Establecer el color de frente a azul en esta porción hará que todos los caracteres en mayúsculas sean azules, dejando a los otros caracteres sin modificar. SwiftUI también proporciona un subíndice equivalente que porciona un AttributedString con una selección directamente. Oye Max, si terminaste de doblar esa hermosa masa, ¡creo que usar la API de subíndices que acepta una selección podría resolver el error de compilación en tu código! ¡Déjame intentarlo! Puedo suscribir texto con la selección directamente y transformar el AttributedSubstring discontiguo en un nuevo AttributedString.

    ¡Increíble! Ahora, cuando ejecuto esto en el dispositivo y selecciono la palabra “mantequilla”, SwiftUI llama automáticamente a mi propiedad newIngredientSuggestion para calcular el valor, que aparecerá en el resto de mi app. Luego, mi inspector agrega automáticamente la sugerencia al final de la lista de ingredientes. A partir de ahí, puedo incluirlo en la lista de ingredientes con un toque. Estas funcionalidades pueden convertir un editor en una experiencia maravillosa.

    Estoy muy feliz con esta incorporación, pero con todo lo que Jeremy me enseñó hasta ahora, ¡creo que puedo llegar aún más lejos! Quiero visualizar mejor los ingredientes en el propio texto. Lo primero que necesito es un atributo personalizado que marque un rango de texto como ingrediente. Vamos a definirlo en un nuevo archivo.

    El valor de este atributo será el ID del ingrediente al que se refiere. Ahora, volveré a la propiedad que computa el IngredientSuggestion en el archivo RecipeEditor.

    IngredientSuggestion me permite proporcionar un cierre como segundo argumento.

    Este cierre se ejecuta cuando presiono el botón Más y el ingrediente se agrega a la lista. Usaré ese cierre para mutar el texto del editor, marcando las ocurrencias del nombre con mi atributo de ingrediente. Obtengo el ID del ingrediente recién creado pasado al cierre.

    A continuación, necesito encontrar todas las apariciones del nombre del ingrediente sugerido en mi texto.

    Puedo hacerlo invocando ranges(of:) en la vista de caracteres de AttributedString.

    Ahora que tengo los rangos, ¡solo tengo que actualizar el valor de mi atributo de ingrediente para cada rango!

    Aquí, estoy usando un nombre corto para el IngredientAttribute que ya había definido.

    Intentémoslo.

    No espero nada nuevo aquí, después de todo, mi atributo personalizado no tiene ningún formato asociado. Selecciono “levadura” y presiono el botón Más.

    Espera, ¿qué es eso? Mi cursor estaba arriba, no al final. Déjame intentarlo de nuevo.

    Selecciono “sal”, presiono el botón Más y mi selección salta al final. Jeremy, tengo que estirar la masa, así que no puedo depurar esto ahora mismo… ¿sabes por qué se reinicia mi selección? Definitivamente, no es una experiencia que deseemos para los cocineros que usan tu app. ¿Por qué no empiezas a estirar la masa y yo profundizo en este comportamiento inesperado?

    Para demostrar lo que ocurre aquí y cómo solucionarlo, explicaré los índices AttributedString que forman los rangos y selecciones de texto usados por un TextEditor enriquecido.

    AttributedString.Index representa una única ubicación dentro del texto. Para ofrecer un diseño potente y eficiente, AttributedString almacena el contenido en una estructura de árbol, y sus índices almacenan rutas a través de ese árbol. Dado que estos índices son los componentes básicos de las selecciones de texto en SwiftUI, el inesperado comportamiento de selección proviene del comportamiento de los índices AttributedString dentro de estos árboles. Debes tener en cuenta dos puntos clave cuando trabajes con índices AttributedString. En primer lugar, cualquier mutación de un AttributedString invalida todos sus índices, hasta los que no están dentro de los límites de la mutación. Las recetas nunca salen bien cuando usas ingredientes caducados, y te aseguro que te pasará lo mismo cuando uses índices viejos con un AttributedString. En segundo lugar, solo debes usar índices con el AttributedString a partir del cual se crearon.

    Ahora exploraré los índices del ejemplo AttributedString que creé anteriormente para explicar cómo funcionan. Como mencioné, AttributedString almacena el contenido en una estructura de árbol, y aquí tengo un ejemplo simplificado de ese árbol. El uso de un árbol permite un mejor rendimiento y evita copiar muchos datos al mutar el texto.

    AttributedString.Index hace referencia al texto almacenando una ruta a través del árbol hasta la ubicación referenciada. Esta ruta permite a AttributedString localizar rápidamente un texto a partir de un índice, pero significa también que este contiene información sobre la disposición de todo el árbol de AttributedString. Cuando se muta un AttributedString, puede que se ajuste la disposición del árbol. Esto invalida cualquier ruta registrada previamente, incluso si el destino de ese índice todavía existe dentro del texto.

    Además, aunque dos AttributedStrings tengan el mismo contenido de texto y atributos, los árboles pueden tener disposiciones diferentes, lo que hace que los índices sean incompatibles entre sí.

    El uso de un índice para recorrer estos árboles y encontrar información requiere el uso del índice dentro de una de las vistas de AttributedString. Aunque los índices son específicos de un AttributedString concreto, puedes usarlos en cualquier vista a partir de esa cadena. Foundation ofrece vistas sobre los caracteres, o grupos de grafemas, del contenido del texto, los escalares Unicode individuales que componen cada carácter y las ejecuciones de atributos de la cadena.

    Para más información sobre las diferencias entre las vistas escalares de caracteres y Unicode, consulta la documentación para desarrolladores de Apple sobre el tipo de caracteres Swift.

    Es posible que también quieras acceder a contenidos de nivel inferior cuando interactúes con otros tipos similares a cadenas que no usan el tipo Character de Swift, como NSString. AttributedString también ofrece ahora vistas de los escalares UTF-8 y UTF-16 del texto. Estas dos vistas comparten los mismos índices que todas las vistas existentes.

    Ahora que ya hablé de los detalles de los índices y las selecciones, volveré sobre el problema que Max encontró con la app de recetas. El cierre onApply de IngredientSuggestion muta la cadena de atributos, pero no actualiza los índices de la selección. SwiftUI detecta que estos índices ya no son válidos y mueve la selección al final del texto para evitar que la app produzca un error. Para solucionarlo, usa las API de AttributedString para actualizar tus índices y selecciones al mutar el texto.

    Aquí tengo un ejemplo simplificado de código que tiene el mismo problema que la app de recetas. En primer lugar, encuentro el ámbito de la palabra “cocinar” en mi texto. Luego, establezco el ámbito de “cocinar” en un color de frente naranja y también inserto la palabra “chef” en mi cadena para agregar algo de tematización de la receta.

    Mutar mi texto puede cambiar la disposición del árbol de mi AttributedString.

    No es válido usar la variable cookingRange después de haber mutado mi cadena. Incluso podría provocar un error. En su lugar, AttributedString proporciona una función de transformación que toma un Range, o una matriz de Ranges, y un cierre que muta el AttributedString en el lugar.

    Al final del cierre, la función de transformación actualizará el ámbito proporcionado con nuevos índices para garantizar que puedes usarlo correctamente en el AttributedString resultante. Mientras que el texto puede haber cambiado en el AttributedString, el ámbito sigue apuntando a la misma ubicación semántica, la palabra “cocinar”.

    SwiftUI también ofrece una función equivalente que actualiza una selección en lugar de un ámbito.

    ¡Vaya, Max, esos croissants tienen una pinta estupenda! Si estás listo para volver a tu app, creo que usar esta nueva función de transformación también te ayudará a poner tu código en forma. ¡Gracias! Es justo lo que estaba buscando. A ver si puedo aplicar esto en el código.

    En primer lugar, no debería repetir los rangos así. Cuando llego al último ámbito, el texto ya mutó varias veces y los índices están desfasados. Puedo evitar ese problema completamente convirtiendo primero mis Ranges en un RangeSet.

    Entonces puedo porcionar con eso y eliminar el bucle.

    De esta manera todo es un solo cambio, y no necesito actualizar el resto de los ámbitos después de cada mutación.

    Segundo, junto a los ámbitos que quiero cambiar, también está la selección que representa la posición de mi cursor. Tengo que asegurarme siempre de que coincide con el texto transformado. Puedo hacerlo usando la sobrecarga transform(updating:) de SwiftUI en AttributedString.

    Bien, ¡ahora mi selección se actualiza justo cuando el texto muta!

    ¡Veamos si funcionó! Puedo seleccionar “leche”. Aparece en la lista y, cuando lo agrego, la selección permanece intacta. Para comprobarlo, cuando presiono Comando y B, veo que la palabra “leche” aparece en negrita, tal y como esperaba.

    Ahora que ya tengo toda la información en el texto de mi receta, ¡quiero resaltar los ingredientes con algo de color! Por suerte, TextEditor ofrece una herramienta para eso: el protocolo de definición de formato de texto con atributos. Una definición de formato de texto personalizado se estructura según a qué AttributedStringKeys responde el editor de texto y qué valores pueden tener. Ya declaré un tipo conforme al protocolo AttributedTextFormattingDefinition aquí.

    Por defecto, el sistema usa el ámbito SwiftUIAttributes, sin restricciones en los valores de los atributos.

    En el ámbito de mi editor de recetas,

    solo quiero permitir el color de frente, Genmoji y mi atributo de ingrediente personalizado.

    En el editor de recetas, puedo usar el modificador attributedTextFormattingDefinition para pasar mi definición personalizada a TextEditor de SwiftUI.

    Con este cambio, mi TextEditor permitirá cualquier ingrediente, Genmoji y color de frente.

    Todos los demás atributos asumirán ahora su valor predeterminado. Ten en cuenta que aún puedes cambiar el valor por defecto para todo el editor modificando el entorno. En base a este cambio, TextEditor ya realizó algunos cambios importantes en la IU de formato del sistema. Ya no ofrece controles para cambiar la alineación, la altura de la línea o las propiedades de la fuente, ya que los respectivos AttributedStringKeys no están en mi ámbito. Sin embargo, aún puedo usar el control para aplicar colores arbitrarios a mi texto, aunque esos colores no tengan necesariamente sentido.

    ¡Oh no, la leche desapareció!

    En realidad solo quiero que los ingredientes se resalten en verde, y que todo lo demás use el color predeterminado. Puedo usar el protocolo AttributedTextValueConstraint de SwiftUI para implementar esta lógica.

    Volvamos al archivo RecipeFormattingDefinition y declaremos la restricción.

    Para seguir el protocolo AttributedTextValueConstraint, primero especifico el ámbito del AttributedTextFormattingDefinition al que pertenece, y luego el AttributedStringKey que quiero restringir, en mi caso el atributo de color de frente. La lógica real para restringir el atributo reside en la función de restricción. En esa función, establezco el valor del AttributeKey, el color de frente, en lo que considero válido.

    Acá, la lógica depende de si el atributo de ingrediente está configurado.

    Si es así, el color de frente debe ser verde.

    O, en caso contrario, debe ser nulo.

    Esto indica que TextEditor debe sustituir el color predeterminado.

    Ahora que definí la restricción, solo necesito agregarla al cuerpo del AttributedTextFormattingDefinition.

    A partir de aquí, SwiftUI se encarga de todo lo demás. TextEditor aplica automáticamente la definición y sus restricciones a cualquier parte del texto antes de que aparezca en pantalla.

    Ahora todos los ingredientes son verdes.

    Curiosamente, TextEditor desactivó el control de color, a pesar de que el color de frente está en el ámbito de mi definición de formato. Esto tiene sentido si tenemos en cuenta la restricción IngredientsAreGreen que agregué. El color de frente depende ahora únicamente de si el texto está marcado con el atributo de ingrediente. TextEditor sondea automáticamente AttributedTextValueConstraints para determinar si un cambio potencial es válido para la selección actual. Por ejemplo, podría intentar establecer de nuevo el color de frente de “leche” en blanco. Ejecutar la restricción IngredientsAreGreen después cambiaría el color de frente de nuevo a verde, por lo que TextEditor sabe que no es un cambio válido y desactiva el control. Mi restricción de valor se aplicará también al texto que pegue en el editor. Cuando copio un ingrediente con Comando y C, y lo vuelvo a pegar con Comando y V, mi atributo de ingrediente personalizado se conserva. Con CodableAttributedStringKeys, puede funcionar incluso entre TextEditors de distintas apps, mientras ambas apps incluyan el atributo en su AttributedTextFormattingDefinition.

    Esto está muy bien, pero todavía hay algunas cosas que mejorar: con el cursor al final del ingrediente “leche”, puedo borrar caracteres o seguir escribiendo y se comporta como un texto normal. De este modo, parece que se trata solo de un texto verde y no de un ingrediente con un nombre determinado. Para que parezca correcto, no quiero que el atributo de ingrediente se expanda mientras escribo al final de su ejecución. Quiero que el color de frente se reestablezca para toda la palabra a la vez si lo modifico.

    Jeremy, si te prometo que te daré un croissant adicional más tarde, ¿me ayudarás a implementarlo? No estoy seguro de que uno sea suficiente, pero si son varios, ¡trato hecho, Max! Mientras metes esos croissants en el horno, te explicaré qué API podrían ayudarte con este problema. Con las restricciones de definición de formato que Max demostró, puedes restringir qué atributos y qué valores específicos puede mostrar cada editor de texto. Para solucionar este nuevo problema con el editor de recetas, el protocolo AttributedStringKey proporciona API para restringir cómo se modifican los valores de atributos en los cambios de cualquier AttributedString. Cuando los atributos declaran restricciones, AttributedString mantiene siempre los atributos coherentes entre sí y con el contenido del texto para evitar estados inesperados con un código más sencillo y eficaz. Analizaré algunos ejemplos para explicarte cuándo podrías usar estas API para tus atributos. Primero, hablaré de los atributos cuyos valores están acoplados a otro contenido del AttributedString, como un atributo de corrección ortográfica.

    Aquí tengo un atributo de corrección ortográfica que indica que la palabra “listo” está mal escrita con un subrayado rojo discontinuo. Después de corregir la ortografía de mi texto, necesito asegurarme de que el atributo solo se aplique al texto que ya validé. Sin embargo, si sigo escribiendo en el editor, por defecto todos los atributos del texto existente se heredan en el texto insertado. Esto no es lo que quiero para un atributo de corrección ortográfica, así que agregaré una nueva propiedad a AttributedStringKey para solucionarlo. Al declarar una propiedad inheritedByAddedText en AttributedStringKey con un valor de “false”, cualquier texto agregado no heredará el valor de este atributo.

    Ahora, al agregar nuevo texto a la cadena, ese texto no contendrá el atributo de corrección ortográfica, ya que aún no comprobé la ortografía de esas palabras. Por desgracia, encontré otro problema con este atributo. Si agrego texto en medio de una palabra marcada como mal escrita, el atributo muestra una rara interrupción en la línea roja debajo del texto agregado. Ya que mi app aún no comprobó si esta palabra está mal escrita, quiero que se elimine el atributo de esta palabra para evitar información obsoleta en mi IU.

    Para solucionar este problema, agregaré otra propiedad a mi tipo AttributedStringKey: la propiedad invalidationConditions. Esta propiedad declara las situaciones en las que una ejecución de este atributo debe eliminarse del texto. AttributedString ofrece condiciones para cuando cambia el texto y atributos específicos, y las claves de atributo pueden invalidarse en varias condiciones. En este caso, necesito eliminar este atributo cada vez que cambie el texto de la ejecución del atributo, por lo que usaré el valor “textChanged”.

    Insertar texto en medio de una ejecución de atributo invalidará el atributo en toda la ejecución, garantizando que evite este estado incoherente en mi IU. Creo que ambas API podrían ayudar a mantener el atributo de ingrediente válido en la app de Max. Mientras Max termina con el horno, demostraré una categoría más de atributos que requieren valores coherentes en todas las secciones de texto. Por ejemplo, un atributo de alineación de párrafo.

    Puedo aplicar diferentes alineaciones a cada párrafo de mi texto, pero una sola palabra no puede usar una alineación diferente a la del resto del párrafo. Para hacer cumplir este requisito en las mutaciones de AttributedString, declararé una propiedad runBoundaries en mi tipo AttributedStringKey. Foundation permite restringir los límites de la ejecución a los bordes del párrafo o de un carácter específico. En este caso, definiré este atributo como restringido a los límites del párrafo para exigir que tenga un valor coherente desde el principio hasta el final de un párrafo.

    Ahora esta situación se vuelve imposible. Si aplico un valor de alineación a la izquierda a una sola palabra de un párrafo, AttributedString expande automáticamente el atributo a todo el ámbito del párrafo. Además, cuando enumero el atributo de alineación, AttributedString enumera cada párrafo individual, incluso si dos párrafos consecutivos contienen el mismo valor de atributo. Otros límites de ejecución se comportan igual: AttributedString expande los valores de un límite al siguiente y garantiza que las ejecuciones enumeradas se rompan en cada límite de ejecución.

    Vaya Max, ¡esos croissants huelen delicioso! Si los croissants están listos en el horno, ¿crees que alguna de esas API podría complementar tu definición de formato para lograr el comportamiento que deseas para tu atributo personalizado? Parece el ingrediente secreto que necesitaba. Los croissants están listos en el horno, ¡así que podré probarlo ahora mismo!

    En mi IngredientAttribute personalizado, voy a implementar el requisito opcional inheritedByAddedText para tener el valor false y, de esa manera, si escribo después de un ingrediente, no se expandirá.

    En segundo lugar, déjame implementar invalidationConditions con textChanged para que cuando borre caracteres en un ingrediente ya no se reconozca.

    Intentémoslo. Cuando agrego una letra al final de “leche”, la letra no es verde, y cuando suprimo un carácter de “leche”, el atributo de ingrediente se elimina de toda la palabra a la vez. Según mi AttributedTextFormattingDefinition, el atributo de color de frente sigue perfectamente el comportamiento de mi atributo de ingrediente personalizado.

    Gracias Jeremy, ¡esta app quedó realmente genial! ¡Ningún problema! Ahora, respecto a esos croissants que prometiste… No te preocupes, están casi listos. ¿Por qué no vigilas el horno? Me preocupa un poco que Luca pueda robarlos. Ah, ese Luca. Lo conozco, amante de los widgets y los croissants. Yo me encargo, chef. Ahora, antes de unirme a Jeremy, déjame darte algunos consejos finales: Puedes descargar mi app como un proyecto de ejemplo donde puedes aprender más sobre el uso de Transferable Wrapper de SwiftUI para arrastrar y soltar sin pérdidas o exportar a RTFD, y la persistencia de AttributedString con Swift Data. AttributedString forma parte del proyecto Foundation de código abierto de Swift. Encuentra su implementación en GitHub para contribuir a su evolución o comunícate con la comunidad en los foros de Swift. Con el nuevo TextEditor, nunca fue tan fácil agregar soporte para el uso de Genmoji en tu app, así que ya puedes considéralo. Estoy impaciente por ver cómo utilizarás esta API para mejorar la edición de texto en tus apps. Basta una pizca para que resalte.

    ¡Qué delicioso! No, ¡qué RICO!

    • 1:15 - TextEditor and String

      import SwiftUI
      
      struct RecipeEditor: View {
          @Binding var text: String
      
          var body: some View {
              TextEditor(text: $text)
          }
      }
    • 1:45 - TextEditor and AttributedString

      import SwiftUI
      
      struct RecipeEditor: View {
          @Binding var text: AttributedString
      
          var body: some View {
              TextEditor(text: $text)
          }
      }
    • 4:43 - AttributedString Basics

      var text = AttributedString(
        "Hello 👋🏻! Who's ready to get "
      )
      
      var cooking = AttributedString("cooking")
      cooking.foregroundColor = .orange
      text += cooking
      
      text += AttributedString("?")
      
      text.font = .largeTitle
    • 5:36 - Build custom controls: Basics (initial attempt)

      import SwiftUI
      
      struct RecipeEditor: View {
          @Binding var text: AttributedString
          @State private var selection = AttributedTextSelection()
      
          var body: some View {
              TextEditor(text: $text, selection: $selection)
                  .preference(key: NewIngredientPreferenceKey.self, value: newIngredientSuggestion)
          }
      
          private var newIngredientSuggestion: IngredientSuggestion {
              let name = text[selection.indices(in: text)] // build error
      
              return IngredientSuggestion(
                  suggestedName: AttributedString())
          }
      }
    • 8:53 - Slicing AttributedString with a Range

      var text = AttributedString(
        "Hello 👋🏻! Who's ready to get cooking?"
      )
      
      guard let cookingRange = text.range(of: "cooking") else {
        fatalError("Unable to find range of cooking")
      }
      
      text[cookingRange].foregroundColor = .orange
    • 10:50 - Slicing AttributedString with a RangeSet

      var text = AttributedString(
        "Hello 👋🏻! Who's ready to get cooking?"
      )
      
      let uppercaseRanges = text.characters
        .indices(where: \.isUppercase)
      
      text[uppercaseRanges].foregroundColor = .blue
    • 11:40 - Build custom controls: Basics (fixed)

      import SwiftUI
      
      struct RecipeEditor: View {
          @Binding var text: AttributedString
          @State private var selection = AttributedTextSelection()
      
          var body: some View {
              TextEditor(text: $text, selection: $selection)
                  .preference(key: NewIngredientPreferenceKey.self, value: newIngredientSuggestion)
          }
      
          private var newIngredientSuggestion: IngredientSuggestion {
              let name = text[selection]
      
              return IngredientSuggestion(
                  suggestedName: AttributedString(name))
          }
      }
    • 12:32 - Build custom controls: Recipe attribute

      import SwiftUI
      
      struct IngredientAttribute: CodableAttributedStringKey {
          typealias Value = Ingredient.ID
      
          static let name = "SampleRecipeEditor.IngredientAttribute"
      }
      
      extension AttributeScopes {
          /// An attribute scope for custom attributes defined by this app.
          struct CustomAttributes: AttributeScope {
              /// An attribute for marking text as a reference to an recipe's ingredient.
              let ingredient: IngredientAttribute
          }
      }
      
      extension AttributeDynamicLookup {
          /// The subscript for pulling custom attributes into the dynamic attribute lookup.
          ///
          /// This makes them available throughout the code using the name they have in the
          /// `AttributeScopes.CustomAttributes` scope.
          subscript<T: AttributedStringKey>(
              dynamicMember keyPath: KeyPath<AttributeScopes.CustomAttributes, T>
          ) -> T {
              self[T.self]
          }
      }
    • 12:56 - Build custom controls: Modifying text (initial attempt)

      import SwiftUI
      
      struct RecipeEditor: View {
          @Binding var text: AttributedString
          @State private var selection = AttributedTextSelection()
      
          var body: some View {
              TextEditor(text: $text, selection: $selection)
                  .preference(key: NewIngredientPreferenceKey.self, value: newIngredientSuggestion)
          }
      
          private var newIngredientSuggestion: IngredientSuggestion {
              let name = text[selection]
      
              return IngredientSuggestion(
                  suggestedName: AttributedString(name),
                  onApply: { ingredientId in
                      let ranges = text.characters.ranges(of: name.characters)
      
                      for range in ranges {
                          // modifying `text` without updating `selection` is invalid and resets the cursor 
                          text[range].ingredient = ingredientId
                      }
                  })
          }
      }
    • 17:40 - AttributedString Character View

      text.characters[index] // "👋🏻"
    • 17:44 - AttributedString Unicode Scalar View

      text.unicodeScalars[index] // "👋"
    • 17:49 - AttributedString Runs View

      text.runs[index] // "Hello 👋🏻! ..."
    • 18:13 - AttributedString UTF-8 View

      text.utf8[index] // "240"
    • 18:17 - AttributedString UTF-16 View

      text.utf16[index] // "55357"
    • 18:59 - Updating Indices during AttributedString Mutations

      var text = AttributedString(
        "Hello 👋🏻! Who's ready to get cooking?"
      )
      
      guard var cookingRange = text.range(of: "cooking") else {
        fatalError("Unable to find range of cooking")
      }
      
      let originalRange = cookingRange
      text.transform(updating: &cookingRange) { text in
        text[originalRange].foregroundColor = .orange
        
        let insertionPoint = text
          .index(text.startIndex, offsetByCharacters: 6)
        
        text.characters
          .insert(contentsOf: "chef ", at: insertionPoint)
      }
      
      print(text[cookingRange])
    • 20:22 - Build custom controls: Modifying text (fixed)

      import SwiftUI
      
      struct RecipeEditor: View {
          @Binding var text: AttributedString
          @State private var selection = AttributedTextSelection()
      
          var body: some View {
              TextEditor(text: $text, selection: $selection)
                  .preference(key: NewIngredientPreferenceKey.self, value: newIngredientSuggestion)
          }
      
          private var newIngredientSuggestion: IngredientSuggestion {
              let name = text[selection]
      
              return IngredientSuggestion(
                  suggestedName: AttributedString(name),
                  onApply: { ingredientId in
                      let ranges = RangeSet(text.characters.ranges(of: name.characters))
      
                      text.transform(updating: &selection) { text in
                          text[ranges].ingredient = ingredientId
                      }
                  })
          }
      }
    • 22:03 - Define your text format: RecipeFormattingDefinition Scope

      struct RecipeFormattingDefinition: AttributedTextFormattingDefinition {
          struct Scope: AttributeScope {
              let foregroundColor: AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute
              let adaptiveImageGlyph: AttributeScopes.SwiftUIAttributes.AdaptiveImageGlyphAttribute
              let ingredient: IngredientAttribute
          }
      
          var body: some AttributedTextFormattingDefinition<Scope> {
      
          }
      }
      
      // pass the custom formatting definition to the TextEditor in the updated `RecipeEditor.body`:
      
              TextEditor(text: $text, selection: $selection)
                  .preference(key: NewIngredientPreferenceKey.self, value: newIngredientSuggestion)
                  .attributedTextFormattingDefinition(RecipeFormattingDefinition())
    • 23:50 - Define your text format: AttributedTextValueConstraints

      struct IngredientsAreGreen: AttributedTextValueConstraint {
          typealias Scope = RecipeFormattingDefinition.Scope
          typealias AttributeKey = AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute
      
          func constrain(_ container: inout Attributes) {
              if container.ingredient != nil {
                  container.foregroundColor = .green
              } else {
                  container.foregroundColor = nil
              }
          }
      }
      
      // list the value constraint in the recipe formatting definition's body:
          var body: some AttributedTextFormattingDefinition<Scope> {
              IngredientsAreGreen()
          }
    • 29:28 - AttributedStringKey Constraint: Inherited by Added Text

      static let inheritedByAddedText = false
    • 30:12 - AttributedStringKey Constraint: Invalidation Conditions

      static let invalidationConditions:
        Set<AttributedString.AttributeInvalidationCondition>? =
        [.textChanged]
    • 31:25 - AttributedStringKey Constraint: Run Boundaries

      static let runBoundaries:
        AttributedString.AttributeRunBoundaries? =
        .paragraph
    • 32:46 - Define your text format: AttributedStringKey Constraints

      struct IngredientAttribute: CodableAttributedStringKey {
          typealias Value = Ingredient.ID
      
          static let name = "SampleRecipeEditor.IngredientAttribute"
      
          static let inheritedByAddedText: Bool = false
      
          static let invalidationConditions: Set<AttributedString.AttributeInvalidationCondition>? = [.textChanged]
      }
    • 0:00 - Introducción
    • Esta sección presenta la meta de la sesión: demostrar cómo crear experiencias de edición de texto enriquecidas en SwiftUI usando AttributedString. El presentador, Max, cubrirá tres áreas principales: actualizar TextEditor a fin de admitir texto enriquecido, crear controles personalizados para mejorar el editor y crear una definición de formato de texto personalizado para garantizar un estilo consistente.

    • 1:15 - TextEditor y AttributedString
    • Esta sección se centra en la actualización de un TextEditor de SwiftUI para admitir texto enriquecido cambiando el tipo de datos subyacente de String a AttributedString. Esto permite inmediatamente el soporte para los controles de la IU del sistema para opciones de formato, como negrita, cursiva, tamaño de fuente, colores y Genmoji. TextEditor admite una amplia gama de atributos, incluido el estilo de párrafo. La sección también proporciona un repaso rápido de los conceptos básicos de AttributedString, incluida su estructura (caracteres y ejecuciones de atributos), su naturaleza de tipo de valor, su codificación UTF-8 y su conformidad con los protocolos Swift comunes. Destaca el uso de atributos predefinidos y la posibilidad de definir atributos personalizados.

    • 5:36 - Crea controles personalizados
    • Esta sección explica cómo crear controles personalizados para un TextEditor que interactúen con el resto de la app. Se muestra cómo agregar un botón que permite al usuario marcar el texto seleccionado como ingrediente. Cubre el uso de teclas de preferencia para comunicarse entre el editor y otras partes de la IU. También se profundiza en las complejidades de las selecciones de texto en AttributedString y se explica por qué el tipo AttributedTextSelection usa un RangeSet en lugar de un solo Range para manejar texto bidireccional y selecciones no contiguas. También se destaca el uso de una API de subíndice que porciona un AttributedString con una selección directamente. Finalmente, en la sección se demuestra cómo utilizar un cierre para mutar el texto del editor marcando las ocurrencias del texto seleccionado con un atributo Ingredient personalizado. También se analiza el problema de la selección que se restablece después de mutar la cadena de atributos e introduce el concepto de índices AttributedString y la importancia de actualizarlos después de las mutaciones usando la función de transformación.

    • 22:02 - Define el formato de tu texto
    • Esta sección se centra en el uso del protocolo de definición de formato de texto atribuido para controlar las opciones de formato disponibles en el TextEditor. Se explica cómo crear una definición de formato personalizada que especifica a qué AttributedStringKeys debe responder el editor y se limita a las opciones de formato disponibles. También se presenta AttributedTextValueConstraint para aplicar reglas de formato específicas, como garantizar que los ingredientes siempre estén resaltados en verde. En la sección se explica además cómo restringir los valores de los atributos usando el protocolo AttributedStringKey. Cubre propiedades, como inheritedByAddedText e invalidationConditions, para controlar cómo se heredan e invalidan los atributos durante las mutaciones de texto. Por último, se analiza la propiedad runBoundaries para imponer valores consistentes en todas las secciones de texto, como los párrafos.

    • 34:08 - Próximos pasos
    • En esta sección se proporcionan consejos y recursos finales. Se menciona un proyecto de muestra que enseña las funcionalidades de arrastrar y soltar sin pérdida, exportación RTFD y persistencia de AttributedString con Swift Data. Se destaca que AttributedString es parte del proyecto de código abierto de la fundación Swift y que fomenta las contribuciones. En la sección también se alienta a los desarrolladores a agregar soporte para Genmoji a sus apps con el nuevo TextEditor.

Developer Footer

  • Videos
  • WWDC25
  • Codificación conjunta: Crea una experiencia de texto enriquecida en SwiftUI con AttributedString
  • Open Menu Close Menu
    • iOS
    • iPadOS
    • macOS
    • tvOS
    • visionOS
    • watchOS
    Open Menu Close Menu
    • Swift
    • SwiftUI
    • Swift Playground
    • TestFlight
    • Xcode
    • Xcode Cloud
    • Icon Composer
    • SF Symbols
    Open Menu Close Menu
    • Accessibility
    • Accessories
    • App Store
    • Audio & Video
    • Augmented Reality
    • Business
    • Design
    • Distribution
    • Education
    • Fonts
    • Games
    • Health & Fitness
    • In-App Purchase
    • Localization
    • Maps & Location
    • Machine Learning & AI
    • Open Source
    • Security
    • Safari & Web
    Open Menu Close Menu
    • Documentation
    • Sample Code
    • Tutorials
    • Downloads
    • Forums
    • Videos
    Open Menu Close Menu
    • Support Articles
    • Contact Us
    • Bug Reporting
    • System Status
    Open Menu Close Menu
    • Apple Developer
    • App Store Connect
    • Certificates, IDs, & Profiles
    • Feedback Assistant
    Open Menu Close Menu
    • Apple Developer Program
    • Apple Developer Enterprise Program
    • App Store Small Business Program
    • MFi Program
    • News Partner Program
    • Video Partner Program
    • Security Bounty Program
    • Security Research Device Program
    Open Menu Close Menu
    • Meet with Apple
    • Apple Developer Centers
    • App Store Awards
    • Apple Design Awards
    • Apple Developer Academies
    • WWDC
    Get the Apple Developer app.
    Copyright © 2025 Apple Inc. All rights reserved.
    Terms of Use Privacy Policy Agreements and Guidelines