Damit Suchhervorhebungen in einer von SwiftUI umschlossenen NSTextView aktuell bleiben, müssen die Rendering-Attribute von TextKit 2 ungültig gemacht werden, wenn sich die Suchanfrage oder der Text ändert. Anschließend werden die Hervorhebungen anhand des aktuellen Inhalts im Rendering-Validator neu erstellt und die erneute Anzeige angefordert.

In dieser Anleitung erstellen wir einen kleinen Mac-Editor für künstliche Bezeichnungen wie Tile A und Circle B. Die konkrete Aufgabe besteht darin, passende Bezeichnungen hervorzuheben, während jemand das Suchfeld ändert oder das Dokument in derselben App bearbeitet.

Was muss aktualisiert werden, wenn sich eine Hervorhebung ändert?

Es gibt zwei unabhängige Eingaben: die Zeichen des Dokuments und die Suchanfrage für die Hervorhebungen. Beim Bearbeiten des Dokuments ändert sich der Textinhalt. Beim Ändern eines SwiftUI-Suchfelds ändert sich der Anwendungszustand, ohne dass sich zwangsläufig etwas im Textsystem ändert. Ihr Wrapper muss beide Ereignisse mit dem Hervorhebungsmechanismus verbinden.

Apple stellt renderingAttributesValidator als Callback bereit, über den Rendering-Attribute für ein Layoutfragment angegeben werden. Außerdem gibt es invalidateRenderingAttributes(for:), um diese Attribute für einen Textbereich ungültig zu machen. Zusammen ermöglichen diese APIs einen Entwurf, bei dem die Suchanfrage als aktueller Zustand gespeichert und die Hervorhebungen bei der Validierung des Renderings neu erstellt werden. Rendering attributes validator, Invalidate rendering attributes.

Die erneute Anzeige ist ein separater Aspekt. Das Setzen von needsDisplay markiert die Ansicht dafür, beim nächsten Durchlauf der Ereignisschleife gezeichnet zu werden. Es gibt weder an, welche Hervorhebungsregel sich geändert hat, noch berechnet es neue Treffer. Die folgende Implementierung macht daher die Rendering-Attribute ungültig und fordert die erneute Anzeige an. NSView needsDisplay.

Ereignis Zu aktualisierender Zustand Aktion
Suchfeld ändert sich Die aktuelle Suchanfrage der Textansicht Rendering-Attribute ungültig machen und erneute Anzeige anfordern
Dokumenttext ändert sich AppKit enthält bereits den bearbeiteten Text Hervorhebungen über den Text-Delegate ungültig machen
Ein Fragment muss validiert werden Seine temporären Hervorhebungsattribute Den bisherigen Hintergrund entfernen und aktuelle Treffer hervorheben
Suchanfrage wird leer Die aktuelle Suchanfrage Bisherige Hervorhebungen entfernen, ohne neue hinzuzufügen

Textansicht mit TextKit 2 betreiben

Verwenden Sie für dieses Beispiel ein macOS-13- oder neueres Deployment Target und initialisieren Sie die Textansicht explizit mit NSTextView(usingTextLayoutManager: true). Apple führte die Convenience-Konstruktoren zur Auswahl der Engine mit dem Übergang ein, den die WWDC22-Session beschreibt. Die hier verwendeten zentralen AppKit-APIs für Rendering-Attribute sind ab macOS 12 verfügbar. What’s new in TextKit and text views, NSTextLayoutManager.

Prüfen Sie textView.textLayoutManager, wenn Sie die aktive Engine ermitteln. Der Zugriff auf layoutManager, auch über den Textcontainer, kann den Kompatibilitätsmodus von TextKit 1 auslösen. Apple beschreibt diesen Übergang für eine bestehende Textansicht als Einbahnstraße. Verwenden Sie im TextKit-2-Pfad keine Abfragen des alten Layout-Managers. TextKit compatibility and modernization.

Die andere Grenze betrifft die Layout-Verantwortung von SwiftUI. Apple warnt davor, frame oder bounds der dargestellten Ansicht direkt zu ändern. Hier ist ein NSScrollView die dargestellte Stammansicht; die anfängliche Zuweisung des Frames gehört zu seiner AppKit-Dokument-Unteransicht. SwiftUI steuert die äußere Größe der Scrollansicht. NSViewRepresentable.

Ein vollständiges bearbeitbares Beispiel erstellen

Erstellen Sie in Xcode eine macOS-SwiftUI-App, setzen Sie ihr Deployment Target auf macOS 13 oder neuer und ersetzen Sie die generierten Deklarationen für App und Inhaltsansicht durch diese einzelne Datei. Im Target darf es genau eine @main-App-Deklaration geben.

SwiftUI verwaltet die Suchanfrage. Die AppKit-Textansicht verwaltet dieses Beispieldokument während ihrer gesamten Lebensdauer. Die Suche ist absichtlich wörtlich, berücksichtigt Groß- und Kleinschreibung und erlaubt keine überlappenden Treffer, damit das Beispiel für künstliche Bezeichnungen ein vorhersagbares Verhalten zeigt. Es handelt sich um ein In-Memory-Beispiel; das Speichern von Dokumenten wird nicht implementiert.

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

Der Code wurde anhand der abgerufenen API-Deklarationen geprüft, aber in dieser Umgebung nicht mit AppKit kompiliert oder ausgeführt. Separate deterministische Prüfungen bestätigten die unten besprochenen beispielhaften UTF-16-Offsets; sie belegen kein GUI-Verhalten.

Warum der Wrapper zwei Aktualisierungspfade verwendet

updateNSView empfängt neuen SwiftUI-Zustand und überträgt die geänderte Suchanfrage in die vorhandene Textansicht. Der Validator liest bei jedem Aufruf die dort gespeicherte Suchanfrage aus. Seine schwache Referenz zeigt auf die AppKit-Ansicht, statt einen anfänglichen Representable-Wert zu erfassen, der eine veraltete Suchanfrage enthalten könnte. Apple definiert die Aktualisierungsmethode des Representable als den Ort, an dem neue SwiftUI-Informationen angewendet werden. NSViewRepresentable lifecycle.

Das Tippen im Dokument läuft über den Coordinator-Pfad. Apple dokumentiert textDidChange(_:) als Delegate-Benachrichtigung, dass sich Zeichen oder Formatierungsattribute geändert haben. Der Coordinator aktualisiert die Hervorhebungen daraufhin sofort; eine Dokumentbindung ist nicht nötig, um eine SwiftUI-Aktualisierung auszulösen. textDidChange(_:).

Der anfängliche Beispieltext wird nur beim Erstellen zugewiesen. Nachfolgende Aktualisierungen der Suchanfrage lassen das Dokument unverändert. Wenn Sie später eine Dokumentbindung hinzufügen, vergleichen Sie den eingehenden Inhalt vor dem Ersetzen und legen Sie fest, welche Seite für Änderungen verantwortlich ist. Würde der Beispieltext bei jeder Aktualisierung erneut zugewiesen, würde das Dokument der Nutzerin oder des Nutzers überschrieben.

Warum vor der Prüfung auf eine leere Suchanfrage erst aufgeräumt werden muss

Bei jeder Validierung entfernt der Hervorhebungsmechanismus das zuvor gesetzte Attribut .backgroundColor aus dem Fragment, bevor er nach Treffern sucht. Eine leere Suchanfrage führt also trotzdem zur Bereinigung. So wird ein häufiger Implementierungsfehler vermieden: Die Suche funktioniert, bis das Feld geleert wird, und die letzten Hervorhebungen bleiben sichtbar.

Das Beispiel verwendet die dokumentierten APIs zum Entfernen und Hinzufügen einzelner Schlüssel und beschränkt Änderungen auf das Hintergrundattribut. Es setzt voraus, dass dieser Hervorhebungsmechanismus für Rendering-Hintergründe zuständig ist. Falls eine andere Funktion ebenfalls solche Hintergründe bereitstellt, sollten beide Regeln in einer Rendering-Richtlinie zusammengeführt werden. Remove rendering attribute, Add rendering attribute.

Treffer werden außerdem auf das gerade validierte Fragment beschränkt. Apple definiert rangeInElement relativ zum Dokumentursprung; intersection(_:) gibt den gemeinsamen Textbereich zurück. So trägt ein Treffer nur den Teil bei, der zum aktuellen Fragment gehört. Fragment range, Text-range intersection.

Treffer-Offsets mit dem aktuellen Dokument konsistent halten

Das Beispiel durchsucht einen NSString-Snapshot und verwendet dessen zurückgegebenen NSRange. Laut Apple verwenden Längen, Indizes und Bereiche von NSString UTF-16-Codeeinheiten. Swift stellt die UTF-16-Darstellung eines Strings außerdem über utf16 bereit; die Anzahl der Swift-Zeichen ist kein Ersatz für diese Offsets. NSString indexing, String.utf16.

Fügen Sie für eine beispielhafte Prüfung das neutrale Symbol mit Leerzeichen 🔷 vor dem Beispieldokument ein. Das erste Tile beginnt dann bei UTF-16-Offset 3 und hat die Länge 4. Das zweite Vorkommen beginnt bei Offset 19. Fügen Sie am Anfang X ein, verschieben sich diese Positionen auf 5 und 21. Dies sind berechnete Beispiele und keine Beobachtungen aus einer Ausführung mit AppKit.

Wandeln Sie jeden Treffer über den aktuellen Content Manager um: vom Dokumentanfang bis zum Trefferanfang und anschließend von dort bis zum Trefferende. Erstellen Sie Textpositionen nicht selbst aus Integer-Indizes und behalten Sie nach Dokumentänderungen keine alten Trefferpositionen bei. Apple zeigt diese Umwandlung über den Content Manager, dessen Positions-API bei einem Offset außerhalb des gültigen Bereichs nil zurückgeben kann. Range conversion guidance, Text content storage locations.

Die Suchschleife rückt um das tatsächlich zurückgegebene Trefferende vor. Apple dokumentiert, dass Suchergebnisse relativ zum String-Anfang sind, auch wenn nur ein kleinerer Bereich durchsucht wird. Die Abbruchbedingung für eine leere Suchanfrage und das Vorwärtsrücken verhindern außerdem eine Endlosschleife. Für eine allgemeine Suchfunktion, die Nutzerinnen und Nutzern angeboten wird, sollten Sie passende lokalisierte Suchregeln wählen; Apple empfiehlt für nutzerseitig sichtbaren Text die lokalisierte Standardsuche. NSString range search.

Veraltete Hervorhebungen sinnvoll untersuchen

Mit dieser Reihenfolge lässt sich unterscheiden, ob eine Zustandsaktualisierung fehlt oder ein Rendering- beziehungsweise Bereichsproblem vorliegt. Dies sind erwartete Prüfschritte für das Beispiel und keine berichteten Testergebnisse.

  1. Beginnen Sie mit Tile. Beide Tile-Bezeichnungen sollten im Hintergrund hervorgehoben sein.
  2. Ändern Sie die Suchanfrage in Circle. Die bisherigen Hintergründe sollten verschwinden und die Bezeichnung Circle sollte hervorgehoben werden.
  3. Leeren Sie die Suchanfrage. Alle Suchhintergründe sollten verschwinden.
  4. Stellen Sie Tile wieder her und ändern Sie anschließend eine passende Bezeichnung in Square. Diese bearbeitete Bezeichnung sollte nicht mehr gefunden werden.
  5. Fügen Sie vor der ersten Bezeichnung 🔷 ein. Die Hervorhebung sollte dem Text folgen und nicht an den früheren Integer-Offsets bleiben.
  6. Fügen Sie genügend künstliche Bezeichnungszeilen hinzu, sodass Scrollen nötig ist. Ändern Sie die Suchanfrage und scrollen Sie dann durch das Dokument, um neu angezeigte Fragmente zu prüfen.

Wenn sich die Suchanfrage ändert, aber nichts aktualisiert wird, prüfen Sie zuerst updateNSView, die gespeicherte Suchanfrage und den Aufruf zur Ungültigmachung. Wenn Änderungen am Dokument keine Wirkung haben, Änderungen der Suchanfrage aber funktionieren, prüfen Sie die Delegate-Zuweisung und textDidChange(_:). Bleiben frühere Treffer eingefärbt, muss die Bereinigung vor jeder vorzeitigen Rückgabe bei einer leeren Suchanfrage erfolgen.

Wenn sich textLayoutManager in nil ändert, sollten Sie den Kompatibilitätsmodus untersuchen, bevor Sie weitere Neuzeichen-Aufrufe hinzufügen. Apple stellt Benachrichtigungen über einen Wechsel des Layout-Managers bereit, mit denen sich ein Fallback von NSTextView diagnostizieren lässt. Dieses Beispiel setzt TextKit 2 voraus und implementiert keinen TextKit-1-Hervorhebungsmechanismus. Compatibility-mode diagnostics.

Die Implementierung für größere Dokumente anpassen

Diese Implementierung setzt auf einen nachvollziehbaren Aktualisierungspfad: Sie macht die Rendering-Attribute des Dokuments ungültig und durchsucht bei jeder Validierung eines Fragments den aktuellen String. Das wiederholt Arbeit und eignet sich für ein kleines Beispiel, nicht für eine ungemessene Verarbeitung großer Dokumente.

Bei größeren Dokumenten sollten Treffer einmal pro Dokument- und Suchanfrage-Version berechnet werden. Bei der Validierung werden diese Treffer dann mit jedem Fragment abgeglichen. Ordnen Sie den zwischengespeicherten Treffern genau die Textversion zu, aus der sie berechnet wurden. Wenn die Suche asynchron läuft, verwerfen Sie Ergebnisse, deren Dokument- oder Suchanfrage-Version sich vor ihrer Anwendung geändert hat.

Trennen Sie Rendering-Arbeit von Anforderungen für dokumentweite Geometrieberechnungen. In Apples Layout-Dokumentation wird zwischen Rendering- und Layout-Invalidierung unterschieden. Die Architekturhinweise zu TextKit empfehlen, Layout-Arbeit auf den sichtbaren Bereich zu beschränken, da das Erzwingen eines Layouts für nicht sichtbare Inhalte teuer sein kann. Eine Änderung der Hintergrundfarbe sollte zunächst die Rendering-Attribute ungültig machen, ohne standardmäßig Layout-Berechnungen für das gesamte Dokument hinzuzufügen. NSTextLayoutManager operations, Meet TextKit 2.

Halten Sie den Aktualisierungsablauf beim Ausbau des Editors explizit: aktuellen Zustand übernehmen, die betroffenen Rendering-Attribute ungültig machen, veraltete Hervorhebungswerte entfernen und Treffer anhand des aktuellen Texts neu erstellen. Prüfen Sie Änderungen der Suchanfrage, Dokumentbearbeitungen, das Leeren der Suchanfrage und das Scrollen getrennt, damit sich Fehler einem bestimmten Teil der Implementierung zuordnen lassen.

Quellen