Aby wyróżnienia wyników wyszukiwania aktualizowały się w widoku NSTextView opakowanym w SwiftUI, unieważniaj atrybuty renderowania TextKit 2 po zmianie zapytania lub tekstu, odtwarzaj wyróżnienia na podstawie bieżącej zawartości w walidatorze renderowania i żądaj ponownego wyświetlenia.
Ten poradnik pokazuje, jak zbudować prosty edytor na Maca do obsługi przykładowych etykiet, takich jak Tile A i Circle B. Zadanie polega na wyróżnianiu pasujących etykiet podczas zmiany pola wyszukiwania lub edytowania dokumentu w tej samej aplikacji.
Co trzeba aktualizować po zmianie wyróżnienia?
Istnieją dwa niezależne wejścia: znaki w dokumencie i zapytanie do wyróżniania. Edytowanie dokumentu zmienia jego treść. Zmiana pola wyszukiwania w SwiftUI aktualizuje stan aplikacji, niekoniecznie zmieniając cokolwiek w systemie tekstowym. Opakowanie musi przekazywać oba zdarzenia do mechanizmu wyróżniania.
Apple udostępnia renderingAttributesValidator jako funkcję zwrotną służącą do dostarczania atrybutów renderowania fragmentu układu. Udostępnia również invalidateRenderingAttributes(for:), która unieważnia te atrybuty w określonym zakresie tekstu. Te interfejsy API pozwalają zastosować rozwiązanie, w którym zapytanie jest przechowywane jako bieżący stan, a wyróżnienia są odtwarzane, gdy trzeba zweryfikować renderowanie. Walidator atrybutów renderowania, Unieważnianie atrybutów renderowania.
Ponowne wyświetlenie to osobna kwestia. Ustawienie needsDisplay oznacza widok jako wymagający rysowania podczas następnego przebiegu pętli zdarzeń. Samo w sobie nie określa, która reguła wyróżniania się zmieniła, ani nie oblicza nowych dopasowań. Dlatego poniższa implementacja unieważnia atrybuty renderowania i żąda ponownego wyświetlenia. NSView needsDisplay.
| Zdarzenie | Stan do aktualizacji | Działanie |
|---|---|---|
| Zmiana pola wyszukiwania | Bieżące zapytanie widoku tekstowego | Unieważnij atrybuty renderowania i zażądaj ponownego wyświetlenia |
| Zmiana tekstu dokumentu | AppKit przechowuje już edytowany tekst | Unieważnij wyróżnienia przez delegata tekstu |
| Fragment wymaga weryfikacji | Tymczasowe atrybuty wyróżnienia | Usuń poprzednie tło i zastosuj bieżące dopasowania |
| Puste zapytanie | Bieżące zapytanie | Usuń poprzednie wyróżnienia bez dodawania nowych |
Używaj widoku tekstowego z TextKit 2
W tym przykładzie ustaw cel wdrożeniowy macOS 13 lub nowszy i jawnie zainicjalizuj widok tekstowy za pomocą NSTextView(usingTextLayoutManager: true). Apple wprowadziło konstruktory pomocnicze pozwalające wybrać silnik przy okazji przejścia opisanego w sesji WWDC22. Podstawowe używane tutaj interfejsy API AppKit do obsługi atrybutów renderowania są udokumentowane jako dostępne od macOS 12. Nowości w TextKit i widokach tekstowych, NSTextLayoutManager.
Podczas diagnozowania aktywnego silnika sprawdź textView.textLayoutManager. Dostęp do layoutManager, także przez kontener tekstu, może uruchomić tryb zgodności z TextKit 1. Apple opisuje to przejście jako jednokierunkowe dla istniejącego widoku tekstowego. Nie używaj zapytań do starszego menedżera układu w ścieżce TextKit 2. Zgodność i modernizacja TextKit.
Druga kwestia to kontrola układu przez SwiftUI. Apple ostrzega przed bezpośrednią zmianą frame lub bounds widoku reprezentowanego. W tym przykładzie widokiem głównym reprezentacji jest NSScrollView; początkowe przypisanie ramki dotyczy jego podrzędnego widoku dokumentu AppKit. SwiftUI kontroluje zewnętrzny rozmiar widoku przewijania. NSViewRepresentable.
Zbuduj kompletny edytowalny przykład
Utwórz w Xcode aplikację macOS SwiftUI, ustaw cel wdrożeniowy na macOS 13 lub nowszy i zastąp wygenerowane deklaracje aplikacji oraz widoku zawartości tym jednym plikiem. W celu pozostaw dokładnie jedną deklarację aplikacji @main.
SwiftUI przechowuje zapytanie wyszukiwania. Widok tekstowy AppKit przechowuje przykładowy dokument przez cały czas działania. Dopasowanie jest celowo dosłowne, uwzględnia wielkość liter i nie nakłada się, dzięki czemu przykład zachowuje przewidywalne działanie dla przykładowych etykiet. To demonstracja działająca w pamięci; nie implementuje zapisywania dokumentu.
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)
}
}
}
Kod sprawdzono pod kątem pobranych deklaracji API, ale w tym środowisku nie został skompilowany ani uruchomiony z AppKit. Osobne deterministyczne kontrole zweryfikowały przykładowe przesunięcia UTF-16 omówione poniżej; nie potwierdzają one działania interfejsu graficznego.
Dlaczego opakowanie korzysta z dwóch ścieżek aktualizacji
updateNSView otrzymuje nowy stan SwiftUI i kopiuje zmienione zapytanie do istniejącego widoku tekstowego. Walidator odczytuje zapisane zapytanie przy każdym uruchomieniu. Jego słabe odwołanie wskazuje na widok AppKit, zamiast przechwytywać początkową wartość reprezentacji, która mogłaby zawierać nieaktualne zapytanie. Apple określa metodę aktualizacji reprezentacji jako miejsce do zastosowania nowych informacji SwiftUI. Cykl życia NSViewRepresentable.
Wpisywanie tekstu do dokumentu odbywa się ścieżką koordynatora. Apple opisuje textDidChange(_:) jako powiadomienie delegata o zmianie znaków lub atrybutów formatowania. Koordynator natychmiast odświeża wyróżnienia; do wywołania aktualizacji SwiftUI nie potrzebuje powiązania z dokumentem. textDidChange(_:).
Tekst przykładowy jest przypisywany tylko podczas tworzenia widoku. Kolejne aktualizacje zapytania pozostawiają dokument bez zmian. Jeśli później dodasz powiązanie z dokumentem, porównuj przychodzącą treść przed jej zastąpieniem i ustal, która strona odpowiada za edycje. Ponowne przypisywanie tekstu przykładowego przy każdej aktualizacji nadpisałoby dokument użytkownika.
Dlaczego poprzednie wyróżnienia trzeba usuwać przed sprawdzeniem pustego zapytania
Podczas każdej weryfikacji mechanizm wyróżniania usuwa poprzedni atrybut .backgroundColor z fragmentu, zanim zacznie szukać dopasowań. Puste zapytanie również powoduje więc czyszczenie. Rozwiązuje to częsty błąd implementacji: wyszukiwanie działa, dopóki pole nie zostanie wyczyszczone, a ostatni zestaw wyróżnień pozostaje widoczny.
Przykład korzysta z udokumentowanych interfejsów API do usuwania i dodawania pojedynczych kluczy, ograniczając zmiany do atrybutu tła. Zakłada, że ten mechanizm wyróżniania odpowiada za tła renderowania; jeśli dostarcza je też inna funkcja, połącz te reguły w ramach jednej polityki renderowania. Usuwanie atrybutu renderowania, Dodawanie atrybutu renderowania.
Dopasowania są również przecinane z weryfikowanym fragmentem. Apple definiuje rangeInElement względem początku dokumentu, a intersection(_:) zwraca wspólny zakres tekstu. Dzięki temu dopasowanie wnosi tylko tę część, która należy do bieżącego fragmentu. Zakres fragmentu, Przecięcie zakresów tekstu.
Utrzymuj zgodność przesunięć dopasowań z bieżącym dokumentem
Przykład przeszukuje migawkę NSString i używa zwróconego przez nią NSRange. Apple określa, że długości, indeksy i zakresy NSString są wyrażane w jednostkach kodowych UTF-16. Swift udostępnia również reprezentację UTF-16 ciągu znaków przez utf16; liczba znaków w ciągu Swift nie zastępuje tych przesunięć. Indeksowanie NSString, String.utf16.
Jako przykładową kontrolę dodaj przed dokumentem przykładowym neutralny symbol i spację 🔷 . Pierwszy ciąg Tile zaczyna się wtedy pod przesunięciem UTF-16 3 i ma długość 4. Drugie wystąpienie zaczyna się pod przesunięciem 19. Wstaw X na początku, a te położenia zmienią się na 5 i 21. To obliczone przykłady, a nie obserwacje z uruchomienia AppKit.
Konwertuj każde dopasowanie za pomocą bieżącego menedżera zawartości: oblicz przesunięcie od początku dokumentu do początku dopasowania, a następnie od tego miejsca do końca dopasowania. Nie konstruuj samodzielnie lokalizacji tekstu z indeksów całkowitych ani nie zachowuj starych lokalizacji dopasowań po edycji dokumentu. Apple pokazuje tę konwersję za pomocą menedżera zawartości, którego API lokalizacji może zwrócić nil dla przesunięcia poza zakresem. Wskazówki dotyczące konwersji zakresów, Lokalizacje w zasobniku zawartości tekstu.
Pętla wyszukiwania przesuwa się dalej, używając rzeczywistego końca zwróconego dopasowania. Apple dokumentuje, że wyniki wyszukiwania są względne wobec początku ciągu, nawet podczas przeszukiwania mniejszego zakresu. W przykładzie kontrola pustego zapytania i przejście do przodu zapobiegają również pętli bez postępu. W ogólnej funkcji Znajdź przeznaczonej dla użytkowników wybierz odpowiednią lokalizowaną semantykę dopasowania; Apple zaleca standardowe wyszukiwanie z uwzględnieniem lokalizacji dla tekstu prezentowanego użytkownikom. Wyszukiwanie zakresu NSString.
Diagnozuj nieaktualne wyróżnienia w użytecznej kolejności
Skorzystaj z poniższej sekwencji, aby odróżnić brak aktualizacji stanu od problemu renderowania lub zakresu. To kontrole, których należy oczekiwać w przykładzie, a nie zgłoszone wyniki testów.
- Zacznij od
Tile. Obie etykietyTilepowinny mieć wyróżnione tło. - Zmień zapytanie na
Circle. Poprzednie tła powinny zniknąć, a etykietaCirclepowinna zostać wyróżniona. - Wyczyść zapytanie. Wszystkie tła wyszukiwania powinny zniknąć.
- Przywróć
Tile, a następnie zmień jedną pasującą etykietę naSquare. Zmieniona etykieta nie powinna już pasować. - Wstaw
🔷przed pierwszą etykietą. Dopasowanie powinno podążać za tekstem, a nie pozostać przy poprzednich przesunięciach całkowitych. - Dodaj wystarczająco dużo przykładowych wierszy etykiet, aby można było przewijać. Zmień zapytanie, a następnie przewiń dokument i sprawdź nowo wyświetlane fragmenty.
Jeśli zmiana zapytania niczego nie aktualizuje, najpierw sprawdź updateNSView, zapisane zapytanie i wywołanie unieważnienia. Jeśli edycje dokumentu nie działają, mimo że zmiana zapytania działa, sprawdź przypisanie delegata i textDidChange(_:). Jeśli dawne dopasowania nadal są pokolorowane, upewnij się, że czyszczenie odbywa się przed każdym wczesnym wyjściem dla pustego zapytania.
Jeśli textLayoutManager zmieni się w nil, przed dodaniem wywołań ponownego rysowania sprawdź tryb zgodności. Apple udostępnia powiadomienia o przełączeniu menedżera układu, które pomagają diagnozować powrót NSTextView do starszego trybu. Ten przykład wymaga TextKit 2 i nie implementuje mechanizmu wyróżniania dla TextKit 1. Diagnostyka trybu zgodności.
Dostosuj implementację do większych dokumentów
Ta implementacja stawia na łatwą do prześledzenia ścieżkę aktualizacji: unieważnia atrybuty renderowania dokumentu i przeszukuje bieżący ciąg podczas weryfikacji każdego fragmentu. Powoduje to powtarzanie pracy i nadaje się do małego przykładu, a nie do niezmierzonego obciążenia związanego z dużym dokumentem.
W przypadku większych dokumentów oblicz dopasowania raz dla każdej wersji dokumentu i zapytania, a następnie podczas weryfikacji przecinaj te dopasowania z każdym fragmentem. Powiąż buforowane dopasowania z dokładną wersją tekstu, na podstawie której je utworzono. Jeśli dopasowanie odbywa się asynchronicznie, odrzuć wyniki, których wersja dokumentu lub zapytania zmieniła się przed ich zastosowaniem.
Oddziel pracę związaną z renderowaniem od żądań dotyczących geometrii całego dokumentu. Dokumentacja układu Apple rozróżnia unieważnianie renderowania od unieważniania układu, a wskazówki dotyczące architektury TextKit zalecają skupienie pracy nad układem na obszarze widoku, ponieważ zapewnianie układu poza ekranem może być kosztowne. Zmiana zapytania dotyczącego koloru tła powinna zaczynać się od unieważnienia renderowania, bez domyślnego dodawania obliczeń układu całego dokumentu. Operacje NSTextLayoutManager, Poznaj TextKit 2.
W miarę rozbudowy edytora utrzymuj jawny porządek aktualizacji: odbierz bieżący stan, unieważnij odpowiednie atrybuty renderowania, usuń nieaktualne wartości wyróżnień i odtwórz dopasowania na podstawie bieżącego tekstu. Osobno sprawdzaj zmiany zapytania, edycje dokumentu, czyszczenie i przewijanie, aby każda usterka wskazywała konkretną część implementacji.
Źródła
- NSViewRepresentable — Apple
- NSTextLayoutManager — Apple
- renderingAttributesValidator — Apple
- invalidateRenderingAttributes(for:) — Apple
- needsDisplay — Apple
- Nowości w TextKit i widokach tekstowych — 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
- Poznaj TextKit 2 — Apple