Om zoekmarkeringen bijgewerkt te houden in een door SwiftUI omwikkelde NSTextView, maakt u de renderingattributen van TextKit 2 ongeldig wanneer de zoekopdracht of tekst verandert, bouwt u markeringen opnieuw op vanuit de huidige inhoud in de renderingvalidator en vraagt u om opnieuw tekenen.
In deze uitleg bouwen we een kleine Mac-editor voor synthetische labels zoals Tile A en Circle B. De concrete taak is om overeenkomende labels te markeren terwijl iemand het zoekveld wijzigt of het document in dezelfde app bewerkt.
Wat moet er worden bijgewerkt wanneer een markering verandert?
Er zijn twee onafhankelijke invoerwaarden: de tekens in het document en de markeringzoekopdracht. Wanneer u het document bewerkt, verandert de tekstinhoud. Wanneer u een SwiftUI-zoekveld wijzigt, verandert de status van de app zonder dat er per se iets in het tekstsysteem verandert. Uw wrapper moet beide gebeurtenissen doorgeven aan de markeringsfunctie.
Apple biedt renderingAttributesValidator als callback voor het leveren van renderingattributen voor een lay-outfragment. Daarnaast biedt het invalidateRenderingAttributes(for:) om die attributen voor een tekstrange ongeldig te maken. Samen ondersteunen deze API's een ontwerp waarbij de zoekopdracht als huidige status wordt opgeslagen en markeringen opnieuw worden opgebouwd wanneer renderingvalidatie nodig is. Renderingattributenvalidator, Renderingattributen ongeldig maken.
Opnieuw tekenen is een afzonderlijke kwestie. Als u needsDisplay instelt, wordt de view gemarkeerd om bij de volgende gebeurtenisronde van de eventloop te worden getekend. Dit geeft op zichzelf niet aan welke markeringsregel is veranderd en berekent geen nieuwe treffers. Daarom maakt de onderstaande implementatie de renderingattributen ongeldig en vraagt deze om opnieuw tekenen. NSView needsDisplay.
| Gebeurtenis | Bij te werken status | Actie |
|---|---|---|
| Het zoekveld verandert | De huidige zoekopdracht van de tekstview | Renderingattributen ongeldig maken en opnieuw tekenen aanvragen |
| De documenttekst verandert | AppKit bevat de bewerkte tekst al | Markeringen via de tekstdelegate ongeldig maken |
| Een fragment moet worden gevalideerd | De tijdelijke markeringsattributen | De vorige achtergrond wissen en huidige treffers toepassen |
| De zoekopdracht wordt leeg | De huidige zoekopdracht | Vorige markeringen wissen zonder vervangingen toe te voegen |
Houd de tekstview op TextKit 2
Gebruik voor dit voorbeeld een macOS 13- of hoger-implementatiedoel en initialiseer de tekstview expliciet met NSTextView(usingTextLayoutManager: true). Apple introduceerde de convenienceconstructors waarmee u de engine kiest tijdens de overgang die wordt beschreven in de WWDC22-sessie. De hier gebruikte AppKit-API's voor renderingattributen zijn gedocumenteerd als beschikbaar vanaf macOS 12. Nieuw in TextKit en tekstviews, NSTextLayoutManager.
Controleer textView.textLayoutManager wanneer u de actieve engine diagnosticeert. Toegang tot layoutManager, ook via de tekstcontainer, kan de compatibiliteitsmodus van TextKit 1 activeren. Apple beschrijft die overgang als onomkeerbaar voor de bestaande tekstview. Gebruik geen verouderde queries voor lay-outmanagers in het TextKit 2-pad. TextKit-compatibiliteit en modernisering.
De andere grens is het lay-outeigenaarschap van SwiftUI. Apple waarschuwt ervoor om de frame of bounds van de vertegenwoordigde view rechtstreeks te wijzigen. Hier is de vertegenwoordigde root een NSScrollView; de eerste frametoewijzing hoort bij de document-subview van AppKit. SwiftUI bepaalt de buitenafmetingen van de scrollview. NSViewRepresentable.
Bouw een volledig bewerkbaar voorbeeld
Maak in Xcode een macOS SwiftUI-app, stel het implementatiedoel in op macOS 13 of hoger en vervang de gegenereerde app- en content-viewdeclaraties door dit ene bestand. Zorg ervoor dat er in het target precies één @main-appdeclaratie staat.
SwiftUI beheert de zoekopdracht. De AppKit-tekstview beheert dit demodocument gedurende zijn levensduur. De overeenkomende tekst wordt bewust letterlijk, hoofdlettergevoelig en zonder overlap gezocht, zodat het voorbeeld voorspelbaar gedrag vertoont voor synthetische labels. Dit is een demoversie in het geheugen; het document kan hiermee niet worden opgeslagen.
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)
}
}
}
De code is gecontroleerd aan de hand van de opgehaalde API-declaraties, maar is in deze omgeving niet met AppKit gecompileerd of uitgevoerd. Afzonderlijke deterministische controles hebben de hieronder besproken illustratieve UTF-16-offsets geverifieerd; die controles bevestigen geen GUI-gedrag.
Waarom de wrapper twee updatepaden gebruikt
updateNSView ontvangt nieuwe SwiftUI-status en kopieert de gewijzigde zoekopdracht naar de bestaande tekstview. De validator leest de opgeslagen zoekopdracht telkens wanneer hij wordt uitgevoerd. De zwakke referentie wijst naar de AppKit-view en legt geen aanvankelijke representable-waarde vast die een verouderde zoekopdracht kan bevatten. Apple beschrijft de updatemethode van de representable als de plek om nieuwe SwiftUI-informatie toe te passen. Levenscyclus van NSViewRepresentable.
Typen in het document volgt het pad via de coordinator. Apple documenteert textDidChange(_:) als een delegate-melding dat tekens of opmaakattributen zijn veranderd. De coordinator werkt de markeringen meteen bij; een documentbinding is niet nodig om een SwiftUI-update te activeren. textDidChange(_:).
De voorbeeldtekst wordt alleen bij het aanmaken toegewezen. Daaropvolgende updates van de zoekopdracht laten het document ongemoeid. Als u later een documentbinding toevoegt, vergelijk dan de binnenkomende inhoud voordat u deze vervangt en bepaal welke kant de bewerkingen beheert. Als u bij elke update opnieuw de voorbeeldtekst toewijst, overschrijft u het document van de gebruiker.
Waarom u eerst moet wissen voordat u controleert of de zoekopdracht leeg is
Bij elke validatie verwijdert de markeringsfunctie eerst het vorige attribuut .backgroundColor uit het fragment en zoekt daarna naar treffers. Ook bij een lege zoekopdracht wordt dus opgeschoond. Zo voorkomt u een veelgemaakte implementatiefout: het zoeken werkt totdat het veld wordt gewist, waarna de laatste set markeringen zichtbaar blijft.
Het voorbeeld gebruikt de gedocumenteerde API's om attributen per sleutel te verwijderen en toe te voegen, en beperkt de wijzigingen tot het achtergrondattribuut. Het gaat ervan uit dat deze markeringsfunctie de renderingachtergronden beheert; als een andere functie die ook levert, combineer dan beide regels in één renderingbeleid. Renderingattribuut verwijderen, Renderingattribuut toevoegen.
Treffers worden ook beperkt tot het fragment dat wordt gevalideerd. Apple definieert rangeInElement relatief aan de documentoorsprong, en intersection(_:) retourneert de gedeelde tekstrange. Zo draagt een treffer alleen het deel bij dat bij het huidige fragment hoort. Fragmentbereik, Doorsnede van tekstranges.
Zorg dat treffer-offsets overeenkomen met het huidige document
Het voorbeeld doorzoekt een momentopname van een NSString en gebruikt de geretourneerde NSRange. Apple geeft aan dat lengtes, indexen en ranges van NSString UTF-16-code-eenheden gebruiken. Swift biedt ook toegang tot de UTF-16-weergave van een string via utf16; het aantal Swift-tekens is geen vervanging voor deze offsets. Indexering van NSString, String.utf16.
Voeg voor een illustratieve controle het neutrale symbool en de spatie 🔷 toe aan het begin van het voorbeelddocument. De eerste Tile begint dan op UTF-16-offset 3 en heeft lengte 4. Het tweede exemplaar begint op offset 19. Voeg X toe aan het begin en die locaties worden 5 en 21. Dit zijn berekende voorbeelden, geen waarnemingen uit een uitvoering met AppKit.
Zet elke treffer om via de huidige contentmanager: bereken eerst de offset vanaf het begin van het document tot het begin van de treffer en daarna vanaf daar tot het einde ervan. Maak zelf geen tekstlocaties aan op basis van gehele indexen en bewaar geen oude trefferlocaties na documentbewerkingen. Apple demonstreert deze omzetting via de contentmanager, waarvan de locatie-API nil kan retourneren voor een offset buiten de grenzen. Richtlijnen voor omzetting van ranges, Locaties in tekstcontentopslag.
De zoeklus gaat verder vanaf het daadwerkelijk geretourneerde einde van de treffer. Apple documenteert dat zoekresultaten relatief zijn aan het begin van de string, ook wanneer u in een kleinere range zoekt. De controle op een lege zoekopdracht en de voorwaartse verplaatsing in het voorbeeld voorkomen bovendien dat de lus niet verdergaat. Kies voor een algemene zoekfunctie met Zoek voor gebruikers de juiste gelokaliseerde zoeksemantiek; Apple raadt gelokaliseerd standaardzoeken aan voor tekst die aan gebruikers wordt getoond. NSString range(of:options:range:).
Spoor verouderde markeringen op in een handige volgorde
Gebruik deze volgorde om onderscheid te maken tussen een ontbrekende statusupdate en een probleem met rendering of ranges. Dit zijn controles die voor het voorbeeld worden verwacht, geen gerapporteerde testresultaten.
- Begin met
Tile. Beide labelsTilemoeten een achtergrondmarkering hebben. - Wijzig de zoekopdracht in
Circle. De vorige achtergronden moeten verdwijnen en het labelCirclemoet worden gemarkeerd. - Wis de zoekopdracht. Alle zoekachtergronden moeten verdwijnen.
- Herstel
Tileen wijzig vervolgens een overeenkomend label inSquare. Dat bewerkte label mag niet langer overeenkomen. - Voeg
🔷toe vóór het eerste label. De markering moet de tekst volgen en op de voormalige gehele offsets achterblijven. - Voeg genoeg synthetische labellijnen toe om te kunnen scrollen. Wijzig de zoekopdracht en scroll vervolgens door het document om nieuw weergegeven fragmenten te controleren.
Als de zoekopdracht verandert maar er niets wordt bijgewerkt, controleer dan eerst updateNSView, de opgeslagen zoekopdracht en de aanroep om attributen ongeldig te maken. Als documentbewerkingen niet werken terwijl wijzigingen in de zoekopdracht wel werken, controleer dan de toewijzing van de delegate en textDidChange(_:). Als voormalige treffers gekleurd blijven, controleer dan of de opschoning wordt uitgevoerd vóór elke vroege terugkeer bij een lege zoekopdracht.
Als textLayoutManager verandert in nil, onderzoek dan eerst de compatibiliteitsmodus voordat u meer aanroepen voor opnieuw tekenen toevoegt. Apple biedt meldingen over de overstap naar een layout manager om een terugval van NSTextView te diagnosticeren. Dit voorbeeld vereist TextKit 2 en implementeert geen TextKit 1-markeringsfunctie. Diagnostiek van de compatibiliteitsmodus.
Pas de implementatie aan voor grotere documenten
Deze implementatie geeft voorrang aan een controleerbaar updatepad: de renderingattributen van het document worden ongeldig gemaakt en de huidige string wordt bij elke fragmentvalidatie doorzocht. Daardoor wordt hetzelfde werk herhaald. Dit is geschikt voor een klein voorbeeld, niet voor een niet-gemeten belasting met grote documenten.
Bereken voor grotere documenten de treffers één keer per document- en zoekopdrachtversie en laat de validatie deze treffers vervolgens met elk fragment vergelijken. Koppel de gecachete treffers aan de exacte tekstversie waaruit ze zijn afgeleid. Als het zoeken asynchroon wordt uitgevoerd, verwerp dan resultaten waarvan de document- of zoekopdrachtversie is gewijzigd voordat u ze toepast.
Houd renderingwerk gescheiden van aanvragen om geometrie voor het hele document. In de lay-outdocumentatie maakt Apple onderscheid tussen rendering- en lay-outinvalidatie. De architectuurrichtlijnen voor TextKit raden aan om lay-outwerk op het zichtbare gebied te richten, omdat het verzekeren van de lay-out buiten beeld kostbaar kan zijn. Een wijziging in de query voor achtergrondkleur moet beginnen met renderinginvalidatie, zonder standaard berekeningen voor de lay-out van het volledige document toe te voegen. Bewerkingen van NSTextLayoutManager, Maak kennis met TextKit 2.
Houd de updatevolgorde expliciet naarmate de editor groeit: ontvang de huidige status, maak de betreffende renderingattributen ongeldig, wis verouderde markeringswaarden en bouw treffers opnieuw op aan de hand van de huidige tekst. Controleer wijzigingen in de zoekopdracht, documentbewerkingen, wissen en scrollen afzonderlijk, zodat elke fout naar een specifiek onderdeel van de implementatie wijst.
Bronnen
- NSViewRepresentable — Apple
- NSTextLayoutManager — Apple
- renderingAttributesValidator — Apple
- invalidateRenderingAttributes(for:) — Apple
- needsDisplay — Apple
- Nieuw in TextKit en tekstviews — Apple
- textDidChange(_:) — Apple
- removeRenderingAttribute(_:for:) — Apple
- addRenderingAttribute(_:value:for:) — Apple
- rangeInElement — Apple
- intersection(_:) — Apple
- NSString — Apple
- utf16 — Apple
- location(_:offsetBy:) — Apple
- range(of:options:range:) — Apple
- Maak kennis met TextKit 2 — Apple