Per mantenere aggiornati gli highlight di ricerca in un NSTextView integrato in SwiftUI, invalida gli attributi di rendering di TextKit 2 quando cambia la query o il testo, ricostruisci gli highlight a partire dai contenuti correnti nel validatore di rendering e richiedi un nuovo disegno.

Questa guida realizza un piccolo editor per Mac con etichette sintetiche come Tile A e Circle B. L’obiettivo concreto è evidenziare le etichette corrispondenti mentre si modifica il campo di ricerca o il documento nella stessa app.

Cosa deve aggiornarsi quando cambia un highlight?

Gli input indipendenti sono due: i caratteri del documento e la query per gli highlight. Modificare il documento cambia il contenuto testuale. Modificare un campo di ricerca SwiftUI cambia lo stato dell’applicazione senza necessariamente cambiare nulla nel sistema di testo. Il wrapper deve collegare entrambi gli eventi all’evidenziatore.

Apple fornisce renderingAttributesValidator come callback per fornire gli attributi di rendering di un frammento di layout. Fornisce anche invalidateRenderingAttributes(for:) per invalidare tali attributi in un intervallo di testo. Insieme, queste API consentono di memorizzare la query come stato corrente e ricostruire gli highlight quando occorre convalidare il rendering. Validatore degli attributi di rendering, Invalida gli attributi di rendering.

Il nuovo disegno è una questione distinta. Impostare needsDisplay contrassegna la vista per il disegno al passaggio successivo del ciclo degli eventi. Da solo, non indica quale regola di evidenziazione è cambiata né calcola le nuove corrispondenze. L’implementazione seguente invalida quindi gli attributi di rendering e richiede un nuovo disegno. NSView needsDisplay.

Evento Stato da aggiornare Azione
Cambia il campo di ricerca La query corrente della vista di testo Invalida gli attributi di rendering e richiedi un nuovo disegno
Cambia il testo del documento AppKit contiene già il testo modificato Invalida gli highlight tramite il delegato del testo
Un frammento deve essere convalidato I suoi attributi temporanei di evidenziazione Rimuovi lo sfondo precedente e applica le corrispondenze correnti
La query diventa vuota La query corrente Rimuovi gli highlight precedenti senza aggiungerne di nuovi

Mantieni la vista di testo su TextKit 2

Per questo esempio, usa una destinazione di distribuzione macOS 13 o successiva e inizializza esplicitamente la vista di testo con NSTextView(usingTextLayoutManager: true). Apple ha introdotto i costruttori di convenienza che selezionano il motore con la transizione descritta nella sessione WWDC22. Le API principali di AppKit per gli attributi di rendering usate qui sono documentate come disponibili da macOS 12. Novità di TextKit e delle viste di testo, NSTextLayoutManager.

Quando diagnostichi il motore attivo, controlla textView.textLayoutManager. Accedere a layoutManager, anche tramite il contenitore di testo, può attivare la modalità di compatibilità con TextKit 1. Apple descrive questa transizione come irreversibile per la vista di testo esistente. Escludi le query al gestore di layout legacy dal percorso TextKit 2. Compatibilità e modernizzazione di TextKit.

L’altro confine riguarda il controllo del layout da parte di SwiftUI. Apple sconsiglia di modificare direttamente frame o bounds della vista rappresentata. In questo caso, la vista radice rappresentata è un NSScrollView; l’assegnazione iniziale del frame spetta alla sottovista documento di AppKit. SwiftUI controlla le dimensioni esterne della vista di scorrimento. NSViewRepresentable.

Crea un esempio modificabile completo

Crea un’app SwiftUI per macOS in Xcode, imposta la destinazione di distribuzione su macOS 13 o successiva e sostituisci le dichiarazioni generate dell’app e della vista di contenuto con questo singolo file. Mantieni esattamente una dichiarazione @main nella destinazione.

SwiftUI gestisce la query di ricerca. Per tutta la durata dell’esempio, la vista di testo di AppKit gestisce il documento dimostrativo. La ricerca è volutamente letterale, distingue maiuscole e minuscole e non ammette sovrapposizioni, così l’esempio si comporta in modo prevedibile con le etichette sintetiche. È una dimostrazione in memoria e non implementa il salvataggio dei documenti.

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)
        }
    }
}

Il codice è stato esaminato confrontandolo con le dichiarazioni API recuperate, ma in questo ambiente non è stato compilato né eseguito con AppKit. Verifiche deterministiche separate hanno confermato gli offset UTF-16 illustrativi descritti di seguito; tali verifiche non dimostrano il comportamento dell’interfaccia grafica.

Perché il wrapper usa due percorsi di aggiornamento

updateNSView riceve il nuovo stato SwiftUI e copia la query aggiornata nella vista di testo esistente. Il validatore legge la query memorizzata ogni volta che viene eseguito. Il suo riferimento debole punta alla vista AppKit, anziché acquisire un valore iniziale del representable che potrebbe contenere una query non aggiornata. Apple definisce il metodo di aggiornamento del representable come il punto in cui applicare le nuove informazioni SwiftUI. Ciclo di vita di NSViewRepresentable.

La digitazione nel documento segue il percorso del coordinatore. Apple documenta textDidChange(_:) come notifica del delegato che segnala modifiche ai caratteri o agli attributi di formattazione. Il coordinatore risponde aggiornando subito gli highlight; non serve un binding al documento per attivare un aggiornamento SwiftUI. textDidChange(_:).

Il testo di esempio iniziale viene assegnato solo durante la creazione. Gli aggiornamenti successivi della query lasciano intatto il documento. Se in seguito aggiungi un binding al documento, confronta il contenuto in arrivo prima di sostituirlo e stabilisci quale componente gestisce le modifiche. Riassegnare il testo di esempio a ogni aggiornamento sovrascriverebbe il documento dell’utente.

Perché occorre rimuovere gli highlight prima di controllare se la query è vuota

A ogni convalida, l’evidenziatore rimuove dal frammento il precedente attributo .backgroundColor prima di cercare corrispondenze. Anche con una query vuota viene quindi eseguita la pulizia. Questo risolve un errore comune: la ricerca funziona finché il campo viene svuotato, ma l’ultimo insieme di highlight resta visibile.

L’esempio usa le API documentate per rimuovere e aggiungere singoli attributi e limita le modifiche all’attributo dello sfondo. Presuppone che questo evidenziatore gestisca gli sfondi di rendering; se anche un’altra funzionalità li fornisce, riunisci queste regole in un’unica politica di rendering. Rimuovi attributo di rendering, Aggiungi attributo di rendering.

Le corrispondenze vengono inoltre intersecate con il frammento in convalida. Apple definisce rangeInElement rispetto all’origine del documento, mentre intersection(_:) restituisce l’intervallo di testo condiviso. In questo modo, una corrispondenza contribuisce solo con la parte che appartiene al frammento corrente. Intervallo del frammento, Intersezione di intervalli di testo.

Mantieni gli offset delle corrispondenze coerenti con il documento corrente

L’esempio cerca in un’istantanea NSString e usa il valore NSRange restituito. Apple specifica che lunghezze, indici e intervalli di NSString usano unità di codice UTF-16. Swift espone anche la rappresentazione UTF-16 di una stringa tramite utf16; il conteggio dei caratteri Swift non sostituisce questi offset. Indicizzazione di NSString, String.utf16.

Per una verifica illustrativa, anteponi al documento di esempio il simbolo neutro e lo spazio 🔷 . La prima occorrenza di Tile inizia allora all’offset UTF-16 3 e ha lunghezza 4. La seconda inizia all’offset 19. Inserendo X all’inizio, queste posizioni diventano 5 e 21. Sono esempi calcolati, non risultati osservati durante un’esecuzione di AppKit.

Converti ogni corrispondenza tramite il gestore dei contenuti corrente: calcola l’offset dall’inizio del documento all’inizio della corrispondenza, poi da lì alla sua fine. Non creare autonomamente posizioni di testo a partire da indici interi e non conservare vecchie posizioni delle corrispondenze dopo le modifiche al documento. Apple illustra questa conversione tramite il gestore dei contenuti, la cui API delle posizioni può restituire nil per un offset fuori dai limiti. Indicazioni per la conversione degli intervalli, Posizioni di archiviazione dei contenuti di testo.

Il ciclo di ricerca avanza usando la fine effettiva della corrispondenza restituita. Apple documenta che i risultati della ricerca sono relativi all’inizio della stringa, anche quando la ricerca avviene in un intervallo più piccolo. Il controllo della query vuota e l’avanzamento in avanti dell’esempio impediscono anche che il ciclo rimanga bloccato. Per una funzione Trova destinata agli utenti, scegli semantiche di corrispondenza appropriate alla lingua; Apple consiglia la ricerca standard localizzata per il testo presentato agli utenti. Ricerca di intervalli NSString.

Diagnostica gli highlight obsoleti nell’ordine giusto

Segui questa sequenza per distinguere un aggiornamento di stato mancante da un problema di rendering o degli intervalli. Sono verifiche previste per l’esempio, non risultati di test dichiarati.

  1. Inizia con Tile. Entrambe le etichette Tile dovrebbero avere lo sfondo evidenziato.
  2. Cambia la query in Circle. Gli sfondi precedenti dovrebbero scomparire e l’etichetta Circle dovrebbe essere evidenziata.
  3. Svuota la query. Tutti gli sfondi di ricerca dovrebbero scomparire.
  4. Ripristina Tile, poi modifica un’etichetta corrispondente in Square. L’etichetta modificata non dovrebbe più corrispondere.
  5. Inserisci 🔷 prima della prima etichetta. La corrispondenza dovrebbe seguire il testo anziché restare ai precedenti offset interi.
  6. Aggiungi abbastanza righe di etichette sintetiche da poter scorrere. Cambia la query, poi scorri il documento per controllare i frammenti appena visualizzati.

Se la query cambia ma non si aggiorna nulla, controlla prima updateNSView, la query memorizzata e la chiamata di invalidazione. Se le modifiche al documento non funzionano mentre quelle alla query sì, controlla l’assegnazione del delegato e textDidChange(_:). Se le corrispondenze precedenti restano colorate, verifica che la pulizia venga eseguita prima di ogni uscita anticipata dovuta a una query vuota.

Se textLayoutManager diventa nil, verifica la modalità di compatibilità prima di aggiungere chiamate di ridisegno. Apple fornisce notifiche di cambio del gestore di layout per diagnosticare il ripiego di un NSTextView. Questa dimostrazione richiede TextKit 2 e non implementa un evidenziatore per TextKit 1. Diagnostica della modalità di compatibilità.

Adatta l’implementazione ai documenti più grandi

Questa implementazione privilegia un percorso di aggiornamento facile da esaminare: invalida gli attributi di rendering del documento e analizza la stringa corrente durante la convalida di ogni frammento. Questo ripete il lavoro ed è adatto a una piccola dimostrazione, non a carichi di lavoro con documenti grandi non misurati.

Per documenti più grandi, calcola le corrispondenze una volta per ogni revisione di documento e query, poi fai in modo che la convalida le intersechi con ciascun frammento. Associa le corrispondenze memorizzate in cache all’esatta revisione del testo da cui sono state ricavate. Se la ricerca viene eseguita in modo asincrono, scarta i risultati se la revisione del documento o della query è cambiata prima della loro applicazione.

Separa il lavoro di rendering dalle richieste di geometria dell’intero documento. La documentazione di Apple sul layout distingue l’invalidazione del rendering da quella del layout e le indicazioni sull’architettura di TextKit consigliano di concentrare il lavoro di layout sull’area visibile, perché forzare il layout fuori schermo può essere costoso. Una modifica alla query del colore di sfondo dovrebbe iniziare con l’invalidazione del rendering, senza aggiungere per impostazione predefinita calcoli di layout dell’intero documento. Operazioni di NSTextLayoutManager, Scopri TextKit 2.

Man mano che l’editor cresce, mantieni esplicita la sequenza di aggiornamento: ricevi lo stato corrente, invalida gli attributi di rendering interessati, rimuovi i valori di evidenziazione non più validi e ricostruisci le corrispondenze sul testo corrente. Verifica separatamente i cambi di query, le modifiche al documento, lo svuotamento della query e lo scorrimento, così ogni problema rimanda a una parte precisa dell’implementazione.

Fonti