Para mantener actualizados los resaltados de búsqueda en un NSTextView integrado en SwiftUI, invalida los atributos de renderizado de TextKit 2 cuando cambie la consulta o el texto, reconstruye los resaltados a partir del contenido actual en el validador de renderizado y solicita que se vuelva a dibujar.

En este tutorial se crea un pequeño editor para Mac con etiquetas sintéticas como Tile A y Circle B. La tarea concreta consiste en resaltar las etiquetas coincidentes mientras alguien cambia el campo de búsqueda o edita el documento en la misma app.

¿Qué debe actualizarse cuando cambia un resaltado?

Hay dos entradas independientes: los caracteres del documento y la consulta de resaltado. Al editar el documento, cambia el contenido del texto. Al cambiar un campo de búsqueda de SwiftUI, cambia el estado de la aplicación sin que necesariamente cambie nada en el sistema de texto. Tu contenedor debe conectar ambos eventos con el resaltador.

Apple proporciona renderingAttributesValidator como devolución de llamada para proporcionar atributos de renderizado a un fragmento de diseño. También proporciona invalidateRenderingAttributes(for:) para invalidar esos atributos en un rango de texto. En conjunto, estas API permiten un diseño en el que la consulta se almacena como estado actual y los resaltados se reconstruyen cuando es necesario validar el renderizado. Validador de atributos de renderizado, Invalidar atributos de renderizado.

Volver a dibujar es un asunto independiente. Asignar needsDisplay marca la vista para que se dibuje en la siguiente iteración del bucle de eventos. Esto no indica por sí solo qué regla de resaltado cambió ni calcula las nuevas coincidencias. Por eso, la implementación que sigue invalida los atributos de renderizado y solicita que se vuelva a dibujar. NSView needsDisplay.

Evento Estado que se debe actualizar Acción
Cambia el campo de búsqueda La consulta actual de la vista de texto Invalidar los atributos de renderizado y solicitar que se vuelva a dibujar
Cambia el texto del documento AppKit ya contiene el texto editado Invalidar los resaltados mediante el delegado de texto
Un fragmento necesita validación Sus atributos temporales de resaltado Eliminar el fondo anterior y aplicar las coincidencias actuales
La consulta queda vacía La consulta actual Eliminar los resaltados anteriores sin añadir otros

Mantener la vista de texto en TextKit 2

Para este ejemplo, usa un destino de implementación de macOS 13 o posterior e inicializa explícitamente la vista de texto con NSTextView(usingTextLayoutManager: true). Apple introdujo los constructores de conveniencia que seleccionan el motor con la transición descrita en su sesión WWDC22. Las API principales de AppKit para los atributos de renderizado que se usan aquí están documentadas como disponibles desde macOS 12. Novedades de TextKit y las vistas de texto, NSTextLayoutManager.

Comprueba textView.textLayoutManager al diagnosticar el motor activo. Acceder a layoutManager, incluso a través del contenedor de texto, puede activar el modo de compatibilidad con TextKit 1. Apple describe esa transición como irreversible para la vista de texto existente. No uses consultas del administrador de diseño heredado en la ruta de TextKit 2. Compatibilidad y modernización de TextKit.

El otro límite es quién controla el diseño en SwiftUI. Apple advierte que no se deben cambiar directamente frame o bounds de la vista representada. Aquí, la vista raíz representada es un NSScrollView; la asignación del marco inicial corresponde a su subvista de documento de AppKit. SwiftUI controla el tamaño externo de la vista de desplazamiento. NSViewRepresentable.

Crear un ejemplo editable completo

Crea una app de SwiftUI para macOS en Xcode, establece el destino de implementación en macOS 13 o posterior y reemplaza las declaraciones generadas de la app y la vista de contenido por este único archivo. Mantén exactamente una declaración de app @main en el destino.

SwiftUI es responsable de la consulta de búsqueda. La vista de texto de AppKit es responsable de este documento de demostración durante toda su vida útil. La búsqueda es deliberadamente literal, distingue mayúsculas de minúsculas y no permite coincidencias superpuestas, para que el ejemplo tenga un comportamiento predecible con etiquetas sintéticas. Esta demostración se mantiene en memoria; no implementa el guardado de documentos.

import SwiftUI
import AppKit

@main
struct HighlightDemoApp: App {
    var body: some Scene {
        WindowGroup { ContentView() }
    }
}

struct ContentView: View {
    @State private var query = "Tile"
    var body: some View {
        VStack {
            TextField("Highlight label", text: $query)
            HighlightEditor(query: query)
        }
        .padding()
        .frame(minWidth: 480, minHeight: 300)
    }
}

struct HighlightEditor: NSViewRepresentable {
    let query: String

    func makeCoordinator() -> Coordinator { Coordinator() }

    func makeNSView(context: Context) -> NSScrollView {
        let scroll = NSScrollView()
        scroll.hasVerticalScroller = true
        let view = HighlightTextView(usingTextLayoutManager: true)
        view.frame = scroll.contentView.bounds
        view.autoresizingMask = [.width]
        view.isVerticallyResizable = true
        view.isHorizontallyResizable = false
        view.textContainer?.widthTracksTextView = true
        view.textContainer?.containerSize = NSSize(
            width: scroll.contentSize.width,
            height: CGFloat.greatestFiniteMagnitude
        )
        view.isRichText = false
        view.font = .systemFont(ofSize: 16)
        view.string = "Tile A\nCircle B\nTile C\nSquare D"
        view.delegate = context.coordinator
        scroll.documentView = view
        guard let manager = view.textLayoutManager else {
            preconditionFailure("This demo requires TextKit 2")
        }
        manager.renderingAttributesValidator = { [weak view] manager, fragment in
            view?.render(manager, fragment: fragment)
        }
        view.query = query
        view.refreshHighlights()
        return scroll
    }

    func updateNSView(_ scroll: NSScrollView, context: Context) {
        guard let view = scroll.documentView as? HighlightTextView,
              view.query != query else { return }
        view.query = query
        view.refreshHighlights()
    }

    @MainActor
    final class Coordinator: NSObject, NSTextViewDelegate {
        func textDidChange(_ notification: Notification) {
            (notification.object as? HighlightTextView)?.refreshHighlights()
        }
    }
}

@MainActor
final class HighlightTextView: NSTextView {
    var query = ""

    func refreshHighlights() {
        guard let manager = textLayoutManager else { return }
        manager.invalidateRenderingAttributes(for: manager.documentRange)
        needsDisplay = true
    }

    func render(_ manager: NSTextLayoutManager, fragment: NSTextLayoutFragment) {
        let fragmentRange = fragment.rangeInElement
        manager.removeRenderingAttribute(.backgroundColor, for: fragmentRange)
        guard !query.isEmpty, let content = manager.textContentManager else { return }
        let text = string as NSString
        var cursor = 0
        while cursor < text.length {
            let match = text.range(of: query, options: [.literal],
                range: NSRange(location: cursor, length: text.length - cursor))
            guard match.location != NSNotFound else { break }
            guard let start = content.location(content.documentRange.location,
                                               offsetBy: match.location),
                  let end = content.location(start, offsetBy: match.length),
                  let range = NSTextRange(location: start, end: end) else {
                assertionFailure("Match does not map to current text content")
                return
            }
            if let part = range.intersection(fragmentRange), !part.isEmpty {
                manager.addRenderingAttribute(.backgroundColor,
                    value: NSColor.systemYellow.withAlphaComponent(0.35), for: part)
            }
            cursor = NSMaxRange(match)
        }
    }
}

El código se ha revisado con las declaraciones de API recuperadas, pero no se ha compilado ni ejecutado con AppKit en este entorno. Se realizaron comprobaciones deterministas independientes para verificar los desplazamientos UTF-16 ilustrativos que se analizan más abajo; esas comprobaciones no demuestran el comportamiento de la interfaz gráfica.

Por qué el contenedor usa dos rutas de actualización

updateNSView recibe el nuevo estado de SwiftUI y copia la consulta modificada en la vista de texto existente. El validador lee la consulta almacenada cada vez que se ejecuta. Su referencia débil apunta a la vista de AppKit, en lugar de capturar un valor inicial de la vista representable que podría contener una consulta desactualizada. Apple define el método de actualización de la vista representable como el lugar donde se aplica la nueva información de SwiftUI. Ciclo de vida de NSViewRepresentable.

La escritura en el documento sigue la ruta del coordinador. Apple documenta textDidChange(_:) como una notificación del delegado que indica que cambiaron los caracteres o los atributos de formato. El coordinador responde actualizando los resaltados de inmediato; no necesita un enlace al documento para activar una actualización de SwiftUI. textDidChange(_:).

El texto de muestra inicial solo se asigna durante la creación. Las actualizaciones posteriores de la consulta no modifican el documento. Si más adelante añades un enlace al documento, compara el contenido entrante antes de reemplazarlo y establece qué lado es responsable de las ediciones. Volver a asignar el texto de muestra en cada actualización sobrescribiría el documento de la persona usuaria.

Por qué hay que limpiar antes de comprobar si la consulta está vacía

En cada validación, el resaltador elimina del fragmento el atributo .backgroundColor anterior antes de buscar coincidencias. Por tanto, una consulta vacía también realiza la limpieza. Esto resuelve un error de implementación común: la búsqueda funciona hasta que se borra el campo, pero el último conjunto de resaltados permanece visible.

El ejemplo usa las API documentadas para eliminar y añadir atributos por clave, y limita los cambios al atributo de fondo. Supone que este resaltador es responsable de los fondos de renderizado; si otra función también los proporciona, combina esas reglas en una única política de renderizado. Eliminar atributo de renderizado, Añadir atributo de renderizado.

Las coincidencias también se intersectan con el fragmento que se está validando. Apple define rangeInElement en relación con el origen del documento, y intersection(_:) devuelve el rango de texto compartido. Así, una coincidencia solo contribuye con la parte que pertenece al fragmento actual. Rango del fragmento, Intersección de rangos de texto.

Mantener los desplazamientos de las coincidencias sincronizados con el documento actual

El ejemplo busca en una instantánea de NSString y usa el NSRange devuelto. Apple especifica que las longitudes, los índices y los rangos de NSString usan unidades de código UTF-16. Swift también expone la representación UTF-16 de una cadena mediante utf16; el recuento de caracteres de una cadena de Swift no sustituye estos desplazamientos. Indexación de NSString, String.utf16.

Como comprobación ilustrativa, antepón el símbolo neutro y el espacio 🔷 al documento de muestra. El primer Tile comienza entonces en el desplazamiento UTF-16 3 y tiene una longitud de 4. La segunda aparición comienza en el desplazamiento 19. Inserta X al principio y esas ubicaciones pasan a ser 5 y 21. Son ejemplos calculados, no observaciones de una ejecución de AppKit.

Convierte cada coincidencia mediante el administrador de contenido actual: calcula el desplazamiento desde el inicio del documento hasta el inicio de la coincidencia y, desde allí, hasta el final. No construyas ubicaciones de texto a partir de índices enteros ni conserves ubicaciones de coincidencias antiguas después de editar el documento. Apple muestra esta conversión mediante el administrador de contenido, cuya API de ubicación puede devolver nil si el desplazamiento está fuera de los límites. Guía para convertir rangos, Ubicaciones en el almacenamiento de contenido de texto.

El bucle de búsqueda avanza usando el final real de la coincidencia devuelta. Apple documenta que los resultados de búsqueda son relativos al principio de la cadena, incluso al buscar en un rango menor. La comprobación de consulta vacía y el avance hacia delante del ejemplo también evitan un bucle que no avance. Para una función de búsqueda general dirigida a personas usuarias, elige una semántica de coincidencia localizada adecuada; Apple recomienda la búsqueda estándar localizada para el texto que se presenta a las personas usuarias. Búsqueda de rangos en NSString.

Diagnosticar los resaltados desactualizados en un orden útil

Sigue esta secuencia para distinguir entre una actualización de estado ausente y un problema de renderizado o de rangos. Estas son comprobaciones previstas para el ejemplo, no resultados de pruebas declarados.

  1. Empieza con Tile. Ambas etiquetas Tile deberían tener un fondo resaltado.
  2. Cambia la consulta a Circle. Los fondos anteriores deberían desaparecer y la etiqueta Circle debería quedar resaltada.
  3. Borra la consulta. Todos los fondos de búsqueda deberían desaparecer.
  4. Vuelve a Tile y edita una de las etiquetas coincidentes para que diga Square. Esa etiqueta editada debería dejar de coincidir.
  5. Inserta 🔷 antes de la primera etiqueta. El resaltado debería seguir al texto y no quedarse en los antiguos desplazamientos enteros.
  6. Añade suficientes líneas de etiquetas sintéticas para poder desplazarte. Cambia la consulta y luego desplázate por el documento para comprobar los fragmentos que se muestren por primera vez.

Si la consulta cambia pero no ocurre nada, revisa primero updateNSView, la consulta almacenada y la llamada de invalidación. Si las ediciones del documento fallan mientras que los cambios de consulta funcionan, revisa la asignación del delegado y textDidChange(_:). Si las coincidencias anteriores siguen coloreadas, comprueba que la limpieza se ejecute antes de cada retorno anticipado por una consulta vacía.

Si textLayoutManager pasa a ser nil, investiga el modo de compatibilidad antes de añadir llamadas para volver a dibujar. Apple proporciona notificaciones de cambio del administrador de diseño para diagnosticar el uso alternativo de NSTextView. Esta demostración requiere TextKit 2 y no implementa un resaltador de TextKit 1. Diagnóstico del modo de compatibilidad.

Adaptar la implementación a documentos más grandes

Esta implementación prioriza una ruta de actualización fácil de inspeccionar: invalida los atributos de renderizado del documento y analiza la cadena actual durante cada validación de fragmento. Eso repite trabajo y es adecuado para una demostración pequeña, no para una carga de trabajo con documentos grandes que no se haya medido.

Para documentos más grandes, calcula las coincidencias una vez por revisión del documento y de la consulta, y luego haz que la validación las interseque con cada fragmento. Asocia las coincidencias almacenadas en caché con la revisión exacta del texto que las generó. Si la búsqueda se ejecuta de forma asíncrona, descarta los resultados cuya revisión del documento o de la consulta haya cambiado antes de aplicarlos.

Mantén el trabajo de renderizado separado de las solicitudes de geometría de todo el documento. La documentación de Apple sobre diseño distingue entre la invalidación del renderizado y la del diseño, y sus directrices sobre la arquitectura de TextKit recomiendan centrar el trabajo de diseño en la región visible, ya que garantizar el diseño del contenido fuera de pantalla puede ser costoso. Un cambio en la consulta del color de fondo debería comenzar por la invalidación del renderizado, sin añadir por defecto cálculos de diseño para todo el documento. Operaciones de NSTextLayoutManager, Conoce TextKit 2.

A medida que crezca el editor, mantén explícita la secuencia de actualización: recibe el estado actual, invalida los atributos de renderizado afectados, elimina los valores de resaltado obsoletos y reconstruye las coincidencias usando el texto actual. Verifica por separado los cambios de consulta, las ediciones del documento, el borrado de la consulta y el desplazamiento, para que cada fallo apunte a una parte concreta de la implementación.

Fuentes