För att hålla sökmarkeringar uppdaterade i en SwiftUI-inlindad NSTextView ska du ogiltigförklara TextKit 2:s renderingsattribut när frågan eller texten ändras, återskapa markeringarna utifrån aktuellt innehåll i renderingsvalidatorn och begära omritning.

Den här genomgången bygger en liten Mac-redigerare för syntetiska etiketter som Tile A och Circle B. Den konkreta uppgiften är att markera etiketter som matchar medan någon ändrar sökfältet eller redigerar dokumentet i samma app.

Vad behöver uppdateras när en markering ändras?

Det finns två oberoende indata: dokumentets tecken och markeringsfrågan. När dokumentet redigeras ändras textinnehållet. När ett SwiftUI-sökfält ändras ändras applikationstillståndet utan att något nödvändigtvis ändras i textsystemet. Din wrapper måste koppla båda händelserna till markeringsfunktionen.

Apple tillhandahåller renderingAttributesValidator som callback för att ange renderingsattribut för ett layoutfragment. Det tillhandahåller också invalidateRenderingAttributes(for:) för att ogiltigförklara dessa attribut inom ett textintervall. Tillsammans stöder dessa API:er en design där frågan lagras som aktuellt tillstånd och markeringar återskapas när renderingen behöver valideras. Validerare för renderingsattribut, Ogiltigförklara renderingsattribut.

Omritning är en separat fråga. När needsDisplay sätts markeras vyn för ritning vid nästa händelseslinga. Det anger inte i sig vilken markeringsregel som ändrades eller beräknar nya träffar. Därför ogiltigförklarar implementationen nedan renderingsattribut och begär omritning. NSView needsDisplay.

Händelse Tillstånd att uppdatera Åtgärd
Sökfältet ändras Textvyns aktuella fråga Ogiltigförklara renderingsattribut och begär omritning
Dokumenttexten ändras AppKit har redan den redigerade texten Ogiltigförklara markeringarna via textdelegaten
Ett fragment behöver valideras Dess tillfälliga markeringsattribut Rensa den tidigare bakgrunden och tillämpa aktuella träffar
Frågan blir tom Den aktuella frågan Rensa tidigare markeringar utan att lägga till nya

Behåll textvyn i TextKit 2

Använd macOS 13 eller senare som distributionsmål för det här exemplet och initiera textvyn uttryckligen med NSTextView(usingTextLayoutManager: true). Apple introducerade de motorväljande bekvämlighetskonstruktorerna i samband med övergången som beskrivs i WWDC22-sessionen. De centrala AppKit-API:erna för renderingsattribut som används här dokumenteras som tillgängliga från macOS 12. Nyheter i TextKit och textvyer, NSTextLayoutManager.

Kontrollera textView.textLayoutManager när du felsöker den aktiva motorn. Åtkomst till layoutManager, även via textcontainern, kan utlösa TextKit 1-kompatibilitetsläge. Apple beskriver den övergången som enkelriktad för den befintliga textvyn. Undvik frågor till den äldre layout-hanteraren i TextKit 2-flödet. TextKit-kompatibilitet och modernisering.

Den andra gränsen gäller SwiftUIs ansvar för layouten. Apple avråder från att direkt ändra den representerade vyns frame eller bounds. Här är den representerade rotvyn en NSScrollView; den initiala ramstorleken tilldelas dess dokumentundervy i AppKit. SwiftUI styr den yttre storleken på rullningsvyn. NSViewRepresentable.

Bygg ett komplett redigerbart exempel

Skapa en macOS SwiftUI-app i Xcode, ange macOS 13 eller senare som distributionsmål och ersätt de genererade app- och innehållsvydeklarationerna med den här enda filen. Behåll exakt en @main-appdeklaration i målet.

SwiftUI äger sökfrågan. AppKit-textvyn äger demonstrationsdokumentet under hela dess livstid. Matchningen är avsiktligt bokstavlig, skiftlägeskänslig och utan överlappning, så att exemplet får ett förutsägbart beteende för syntetiska etiketter. Det här är en demonstration i minnet; den implementerar inte dokumentsparande.

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

Koden har granskats mot de hämtade API-deklarationerna, men den har inte kompilerats eller körts med AppKit i den här miljön. Separata deterministiska kontroller verifierade de illustrativa UTF-16-förskjutningar som diskuteras nedan; de kontrollerna fastställer inte GUI-beteende.

Varför wrappern använder två uppdateringsvägar

updateNSView tar emot nytt SwiftUI-tillstånd och kopierar den ändrade frågan till den befintliga textvyn. Validatorn läser den lagrade frågan varje gång den körs. Dess svaga referens pekar på AppKit-vyn i stället för att fånga ett initialt representerbart värde som kan innehålla en inaktuell fråga. Apple anger att representerbarens uppdateringsmetod är platsen där ny SwiftUI-information ska tillämpas. NSViewRepresentable-livscykeln.

När användaren skriver i dokumentet går det via koordinatorn. Apple dokumenterar textDidChange(_:) som en delegatavisering om att tecken eller formateringsattribut har ändrats. Koordinatorn svarar genom att uppdatera markeringarna direkt; den behöver ingen dokumentbindning för att utlösa en SwiftUI-uppdatering. textDidChange(_:).

Den ursprungliga exempeltexten tilldelas bara när vyn skapas. Senare uppdateringar av frågan lämnar dokumentet orört. Om du senare lägger till en dokumentbindning ska du jämföra inkommande innehåll innan du ersätter det och fastställa vilken sida som äger redigeringarna. Om exempeltexten tilldelades på nytt vid varje uppdatering skulle användarens dokument skrivas över.

Varför rensningen måste ske före kontrollen av tom fråga

Vid varje validering tar högfunktionen bort sitt tidigare .backgroundColor-attribut från fragmentet innan den söker efter träffar. En tom fråga utför alltså ändå rensningen. Det löser ett vanligt implementeringsfel där sökningen fungerar tills fältet rensas, medan den senaste uppsättningen markeringar blir kvar.

Exemplet använder de dokumenterade API:erna för att ta bort och lägga till enskilda nycklar, och begränsar ändringarna till bakgrundsattributet. Det förutsätter att den här högfunktionen äger renderingsbakgrunderna. Om en annan funktion också anger sådana ska du kombinera reglerna i en enda renderingspolicy. Ta bort renderingsattribut, Lägg till renderingsattribut.

Träffarna begränsas också till det fragment som valideras. Apple definierar rangeInElement i förhållande till dokumentets början, och intersection(_:) returnerar det gemensamma textintervallet. På så sätt bidrar en träff bara med den del som hör till det aktuella fragmentet. Fragmentintervall, Snitt mellan textintervall.

Håll träffarnas förskjutningar i linje med det aktuella dokumentet

Exemplet söker i en NSString-ögonblicksbild och använder den returnerade NSRange-en. Apple anger att längder, index och intervall i NSString använder UTF-16-kodenheter. Swift exponerar också en strängs UTF-16-representation via utf16; antalet Swift-tecken kan inte ersätta dessa förskjutningar. Indexering i NSString, String.utf16.

Som ett illustrativt test kan du lägga till den neutrala symbolen och ett mellanslag 🔷 före exempeldokumentet. Den första Tile börjar då vid UTF-16-förskjutningen 3 och har längden 4. Den andra förekomsten börjar vid förskjutningen 19. Infoga X i början så blir positionerna 5 och 21. Det här är beräknade exempel, inte observationer från en AppKit-körning.

Konvertera varje träff via den aktuella innehållshanteraren: beräkna förskjutningen från dokumentets början till träffens början och därefter till träffens slut. Konstruera inte textpositioner själv utifrån heltalsindex och behåll inte gamla träffpositioner efter dokumentredigeringar. Apple visar den här konverteringen via innehållshanteraren, vars positions-API kan returnera nil för en förskjutning utanför intervallet. Vägledning för intervallkonvertering, Platser i textinnehållslagring.

Sökloopen går vidare med den faktiska slutpositionen för den returnerade träffen. Apple dokumenterar att sökresultat är relativa till strängens början, även när ett mindre intervall genomsöks. Exemplet förhindrar också en loop som inte går framåt med en kontroll för tom fråga och genom att gå vidare. För en allmän Find-funktion riktad till användare ska du välja lämpliga lokaliserade matchningsregler; Apple rekommenderar lokaliserad standardsökning för text som visas för användare. Sökning efter intervall i NSString.

Felsök inaktuella markeringar i en användbar ordning

Följ den här ordningen för att skilja mellan ett missat tillstånd som inte uppdaterats och ett renderings- eller intervallproblem. Det här är förväntade kontroller för exemplet, inte rapporterade testresultat.

  1. Börja med Tile. Båda Tile-etiketterna ska ha en bakgrundsmarkering.
  2. Ändra frågan till Circle. De tidigare bakgrunderna ska försvinna och etiketten Circle ska markeras.
  3. Rensa frågan. Alla sökbakgrunder ska försvinna.
  4. Återställ Tile och redigera sedan en matchande etikett till Square. Den redigerade etiketten ska inte längre matcha.
  5. Infoga 🔷 före den första etiketten. Matchningen ska följa texten i stället för att ligga kvar vid de tidigare heltalspositionerna.
  6. Lägg till tillräckligt många syntetiska etiketter för att behöva rulla. Ändra frågan och rulla sedan genom dokumentet för att kontrollera nyligen visade fragment.

Om frågan ändras men inget uppdateras ska du först kontrollera updateNSView, den lagrade frågan och anropet för ogiltigförklaring. Om dokumentredigeringar inte fungerar trots att frågeändringar gör det ska du kontrollera delegattilldelningen och textDidChange(_:). Om tidigare träffar fortfarande är färgade ska du kontrollera att rensningen körs före varje tidig retur vid tom fråga.

Om textLayoutManager blir nil ska du undersöka kompatibilitetsläget innan du lägger till fler omritningsanrop. Apple tillhandahåller aviseringar om byte av layouthanterare för att diagnostisera när en NSTextView faller tillbaka. Den här demonstrationen kräver TextKit 2 och implementerar inte en TextKit 1-markeringsfunktion. Diagnostik av kompatibilitetsläge.

Anpassa implementationen för större dokument

Den här implementationen prioriterar ett lättgranskat uppdateringsflöde: den ogiltigförklarar dokumentets renderingsattribut och söker igenom den aktuella strängen vid varje validering av ett fragment. Det upprepar arbete och passar för en liten demonstration snarare än en stor dokumentarbetsbelastning som inte har mätts.

För större dokument kan du beräkna träffar en gång per dokument- och frågeversion och sedan låta valideringen skära ut träffarna för varje fragment. Knyt de cachade träffarna till exakt den textversion som skapade dem. Om matchningen körs asynkront ska du kasta bort resultat vars dokument- eller frågeversion har ändrats innan de tillämpas.

Håll renderingsarbetet åtskilt från begäranden om geometriberäkning för hela dokumentet. Apples layoutdokumentation skiljer mellan renderings- och layoutogiltigförklaring, och vägledningen om TextKit-arkitekturen rekommenderar att layoutarbetet begränsas till det synliga området, eftersom det kan bli dyrt att säkerställa layout även utanför skärmen. En ändring av bakgrundsfärgen på grund av en fråga bör börja med ogiltigförklaring av renderingen, utan att fullständiga layoutberäkningar för hela dokumentet läggs till som standard. NSTextLayoutManager-åtgärder, Möt TextKit 2.

Håll uppdateringssekvensen tydlig när redigeraren växer: ta emot aktuellt tillstånd, ogiltigförklara berörda renderingsattribut, rensa inaktuella markeringsvärden och återskapa träffar utifrån aktuell text. Kontrollera frågeändringar, dokumentredigeringar, rensning och rullning var för sig så att varje fel pekar på en specifik del av implementationen.

Källor