Aby wysłać polecenie menu SwiftUI do aktywnego NSTextView, w chwili jego wykonania ustal pierwszy obiekt obsługujący zdarzenia w aktywnym oknie, a następnie wyślij polecenie do tego widoku. W tym samouczku zbudujemy przykład z dwoma edytorami, w którym polecenie Select First Line działa tylko na edytorze, w którym znajduje się kursor.
Chodzi o niestandardową czynność menu w Twojej aplikacji na macOS. Przykład używa tekstu syntetycznego, podklasy NSTextView należącej do aplikacji oraz publicznych interfejsów API SwiftUI i AppKit. Kluczowa decyzja projektowa dotyczy tego, czy polecenie ma podążać za łańcuchem obsługi zdarzeń AppKit, czy wymagać konkretnego aktywnego edytora.
Określ, co oznacza „aktywny widok tekstowy”
W tym przykładzie aktywnym edytorem jest instancja SampleTextView, która jest obecnie pierwszym obiektem obsługującym zdarzenia w aktywnym oknie. Apple definiuje aktywne okno jako okno odbierające zdarzenia klawiatury; jego firstResponder wskazuje obiekt, który zwykle jako pierwszy odbiera zdarzenia i polecenia. Odczytanie tych właściwości w chwili wywołania pozwala ustalić aktualny cel polecenia. Dokumentacja Apple dotycząca keyWindow, dokumentacja Apple dotycząca firstResponder.
AppKit obsługuje również polecenia bez określonego celu: przekaż nil jako cel do NSApplication.sendAction(_:to:from:), a aplikacja wyszuka obiekt obsługujący dany selektor. Wyszukiwanie rozpoczyna się od pierwszego obiektu obsługującego zdarzenia w aktywnym oknie, ale może przebiegać przez delegatów i okno główne, zanim dotrze do procedur obsługi na poziomie aplikacji. Dlatego polecenie bez określonego celu nie oznacza „wykonaj tylko na aktywnym widoku tekstowym w tym oknie”. Dokumentacja Apple dotycząca routingu poleceń.
| Wymagane działanie | Wybór routingu | Konsekwencja |
|---|---|---|
| Pozwól AppKit znaleźć odpowiednią procedurę obsługi | Wyślij polecenie z to: nil |
Zastosowane zostanie standardowe przekazywanie w łańcuchu obsługi zdarzeń. |
| Działaj tylko na aktywnym edytorze należącym do aplikacji | Ustal pierwszy obiekt obsługujący zdarzenia, sprawdź jego typ, a następnie wskaż go jako cel | Polecenie nie zadziała, jeśli ten edytor nie jest aktywny. |
| Działaj na konkretnym edytorze niezależnie od fokusu | Podaj ten edytor jako cel | To inny sposób interakcji niż polecenie dotyczące aktywnego edytora. |
Poniższa implementacja wybiera drugie zachowanie. Sprawdzenie niestandardowej podklasy zapobiega też ustawieniu jako celu edytora tekstu innego pola tylko dlatego, że jego edytor jest widokiem tekstowym.
Zbuduj kompletny przykład SwiftUI i AppKit
Utwórz projekt aplikacji macOS używający SwiftUI i ustaw dla niego minimalną wersję macOS 13 lub nowszą. Zastąp wygenerowany punkt wejścia aplikacji poniższym pojedynczym plikiem Swift; usuń pozostałe deklaracje @main. Program definiuje oba edytory, ich początkowy tekst, menu i procedurę obsługi polecenia.
NSViewRepresentable w SwiftUI to obsługiwany sposób osadzania widoku AppKit. Polecenia sceny definiują zawartość menu, a CommandMenu tworzy menu specyficzne dla aplikacji na pasku menu macOS. Dokumentacja Apple dotycząca NSViewRepresentable, polecenia sceny, menu poleceń.
import SwiftUI
import AppKit
import Foundation
@main
@MainActor
struct RoutingDemoApp: App {
var body: some Scene {
WindowGroup("Routing Demo") {
ContentView()
}
.commands {
CommandMenu("Sample Actions") {
Button("Select First Line") {
Self.routeSelectFirstLine()
}
}
}
}
private static func routeSelectFirstLine() {
let application = NSApplication.shared
guard let editor = application.keyWindow?.firstResponder
as? SampleTextView else {
return
}
_ = application.sendAction(
#selector(SampleTextView.selectFirstSampleLine(_:)),
to: editor,
from: nil
)
}
}
@MainActor
struct ContentView: View {
@State private var windowLabel = "Sample Window"
var body: some View {
VStack(alignment: .leading, spacing: 12) {
TextField("Window label", text: $windowLabel)
HStack(spacing: 12) {
SampleEditor(initialText: "Sample Alpha\nAlpha details")
SampleEditor(initialText: "Sample Beta\nBeta details")
}
Text("Click an editor, then use Sample Actions.")
.font(.caption)
}
.padding()
.frame(minWidth: 640, minHeight: 320)
}
}
@MainActor
final class SampleTextView: NSTextView {
@objc func selectFirstSampleLine(_ sender: Any?) {
guard isSelectable, !hasMarkedText() else { return }
let contents = string as NSString
guard contents.length > 0 else { return }
let firstLine = contents.lineRange(
for: NSRange(location: 0, length: 0)
)
setSelectedRange(firstLine)
scrollRangeToVisible(firstLine)
}
}
@MainActor
struct SampleEditor: NSViewRepresentable {
let initialText: String
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView()
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.borderType = .bezelBorder
let editor = SampleTextView(
frame: NSRect(x: 0, y: 0, width: 300, height: 240)
)
editor.isEditable = true
editor.isSelectable = true
editor.isRichText = false
editor.allowsUndo = true
editor.font = NSFont.monospacedSystemFont(
ofSize: 14, weight: .regular
)
editor.minSize = .zero
editor.maxSize = NSSize(
width: CGFloat.greatestFiniteMagnitude,
height: CGFloat.greatestFiniteMagnitude
)
editor.isVerticallyResizable = true
editor.isHorizontallyResizable = false
editor.autoresizingMask = [.width]
editor.textContainer?.containerSize = NSSize(
width: 300,
height: CGFloat.greatestFiniteMagnitude
)
editor.textContainer?.widthTracksTextView = true
editor.string = initialText
scrollView.documentView = editor
return scrollView
}
func updateNSView(_ nsView: NSScrollView, context: Context) {
// AppKit owns the editable contents in this example.
}
}
To kompletny przykład aplikacji z zawartością edytorów przechowywaną w pamięci. Kod sprawdzono w oparciu o pobraną dokumentację API; nie został tu skompilowany ani uruchomiony, ponieważ dostępne środowisko to Linux i nie ma w nim łańcucha narzędzi Swift.
Dlaczego selektor trafia do właściwego edytora
Funkcja routująca pobiera pierwszy obiekt obsługujący zdarzenia bezpośrednio przed wysłaniem polecenia. Akceptuje tylko SampleTextView i wskazuje dokładnie ten obiekt jako cel polecenia. Menu nie zachowuje więc referencji do edytora, który został kliknięty wcześniej, ani nie szuka w innym oknie, gdy sprawdzenie typu się nie powiedzie.
Metoda obsługi polecenia używa @objc, ponieważ mechanizm target-action korzysta z selektorów Objective-C. Wyrażenie #selector odwołuje się do zadeklarowanej metody zamiast do ręcznie wpisanego ciągu selektora. Apple opisuje to połączenie jako sposób wywoływania metod Swift za pośrednictwem interfejsów API target-action Objective-C. Korzystanie z funkcji środowiska wykonawczego Objective-C w języku Swift.
Wartość zwracana przez sendAction informuje, czy wiadomość została wysłana. Nie informuje, czy procedura obsługi zmieniła zaznaczenie: może ona odebrać wiadomość, a następnie zakończyć działanie, gdy zawartość jest pusta lub tekst jest oznaczony. W tym przykładzie celowo pomijamy wynik wysłania, ponieważ cel i metoda są już znane. Dokumentacja Apple dotycząca wartości zwracanej przez sendAction.
Co dokładnie zaznacza polecenie
Procedura obsługi pyta NSString.lineRange(for:) o logiczny wiersz zawierający początek ciągu znaków. Zakres obejmuje też terminator wiersza, jeśli występuje. W efekcie zaznaczany jest pierwszy wiersz tekstu oddzielony znakiem nowej linii, nawet jeśli zawija się on na kilka wyświetlanych wierszy. Dokumentacja Apple dotycząca zakresów wierszy.
Dla podanego tekstu lewy edytor zaznacza Sample Alpha wraz z następującym po nim znakiem nowej linii, a prawy edytor zaznacza Sample Beta wraz z następującym po nim znakiem nowej linii. Polecenie zastępuje poprzednie zaznaczenie. Nie zmienia tekstu.
setSelectedRange(_:) ustawia zaznaczenie, a scrollRangeToVisible(_:) przewija widok tak, aby jego początkowe znaki były widoczne. Kontrola pustej zawartości zapobiega próbie ustawienia zaznaczenia, gdy nie ma tekstu. Sprawdzenie oznaczonego tekstu jest jawną regułą przyjętą w tym przykładzie: odłóż niestandardowe polecenie zaznaczania na czas trwania aktywnego wprowadzania tekstu. Zakresy zaznaczenia, przewijanie zakresu do widoku, oznaczony tekst.
Oddziel tworzenie widoków, edycję i fokus
Komponent reprezentowalny tworzy NSScrollView i ustawia niestandardowy widok tekstowy jako jego widok dokumentu. Edytor rozciąga się w pionie, a jego kontener tekstu dopasowuje szerokość. Jest to zgodne z udokumentowanym przez Apple układem przewijanego widoku tekstowego. Układ ten opisano w zarchiwizowanym przewodniku AppKit firmy Apple; przytoczone tutaj interfejsy API mostka SwiftUI i obsługi poleceń pochodzą z aktualnej dokumentacji. Umieszczanie obiektu NSTextView w NSScrollView.
SwiftUI kontroluje układ reprezentowanego widoku przewijania. Kod konfiguruje początkową geometrię zagnieżdżonego widoku dokumentu oraz zachowanie AppKit przy zmianie rozmiaru; nie przypisuje wielokrotnie ramki reprezentowanemu widokowi przewijania. Apple wyraźnie przestrzega przed zmianą właściwości układu reprezentowalnego, którymi zarządza SwiftUI. Wskazówki Apple dotyczące układu reprezentowalnych widoków.
Przykład przypisuje initialText tylko podczas tworzenia widoku. Pusta metoda aktualizacji celowo pozostawia dalsze wpisywanie tekstu pod kontrolą AppKit. Jeśli aplikacja potrzebuje modelu dokumentu w SwiftUI, dodaj koordynator i synchronizację opartą na delegacie, określając zasady postępowania ze zmianami modelu przychodzącymi w trakcie edycji. Ponowne przypisywanie początkowego ciągu przy każdej aktualizacji zastąpiłoby bieżącą zawartość użytkownika. Apple opisuje koordynatory jako most przekazujący interakcje z AppKit do SwiftUI. Wskazówki Apple dotyczące koordynacji reprezentowalnych widoków.
Kliknięcie edytora ustawia fokus na potrzeby tego omówienia. Jeśli aplikacja musi programowo przenieść fokus, użyj makeFirstResponder(_:) okna zawierającego widok, gdy widok będzie już należał do tego okna. Apple wyraźnie zaleca, by nie wywoływać bezpośrednio becomeFirstResponder() widoku tekstowego. Unikaj ustawiania fokusu przy każdej aktualizacji SwiftUI, ponieważ wielokrotnie przekierowywałoby to użytkownika do innego miejsca edycji. Wskazówki Apple dotyczące fokusu widoku tekstowego.
Zachowaj istniejącą konfigurację TextKit
Routing menu nie wymaga zmiany mechanizmu układu tekstu. W tym przykładzie użyto NSTextView(frame:); Apple dokumentuje, że ten inicjalizator domyślnie korzysta z NSLayoutManager. Jawne tworzenie z użyciem NSTextLayoutManager jest obsługiwane od macOS 12. Dokumentacja Apple dotycząca inicjalizacji widoku tekstowego.
W przypadku istniejącego edytora TextKit 2 zachowaj jego konstrukcję i dodaj obsługę polecenia do jego podklasy. Nie odczytuj layoutManager tylko po to, by znaleźć edytor lub przekazać do niego polecenie: Apple dokumentuje, że uzyskanie dostępu do tej właściwości widoku tekstowego TextKit 2 może przełączyć go w tryb zgodności. Kod routujący potrzebuje tylko obiektu obsługującego zdarzenia i metody obsługi polecenia. Dokumentacja Apple dotycząca trybu zgodności NSTextView.
Sprawdź routing i obsłuż niedostępne polecenia
Wykonaj poniższe czynności, aby sprawdzić oczekiwane działanie w systemie macOS. Są to przewidywane wyniki wynikające z implementacji, a nie rezultaty faktycznego uruchomienia.
- Kliknij lewy edytor, a następnie wybierz
Sample Actions→Select First Line. Powinien zostać zaznaczony pierwszy logiczny wiersz lewego edytora. - Kliknij prawy edytor i wywołaj to samo polecenie. Powinno zmienić się tylko zaznaczenie w prawym edytorze.
- Kliknij
Window labeli wywołaj polecenie. Oba niestandardowe edytory powinny pozostać bez zmian, ponieważ pierwszy obiekt obsługujący zdarzenia nie przejdzie sprawdzenia podklasy. - Usuń całą zawartość edytora i wywołaj w nim polecenie. Kontrola pustej zawartości powinna zakończyć działanie bez zmiany tekstu.
- Dodaj kilka wierszy, przewiń w dół i wywołaj polecenie. Pierwszy wiersz powinien zostać zaznaczony i przewinięty do widoku.
W tym minimalnym przykładzie polecenie pozostaje aktywne, również wtedy, gdy fokus znajduje się gdzie indziej. Warunek sprawdzany w chwili wywołania decyduje, czy polecenie zadziała. Sam odczyt keyWindow?.firstResponder wewnątrz wyrażenia SwiftUI .disabled nie zapewni mechanizmu aktualizowania tego wyrażenia za każdym razem, gdy zmieni się fokus AppKit.
W większym interfejsie SwiftUI udostępnij odpowiednią możliwość wykonywania polecenia za pomocą stanu uwzględniającego fokus, obserwuj go przez @FocusedValue i wyznaczaj warunek .disabled przycisku na podstawie tego stanu. Most AppKit musi utrzymywać tę możliwość w zgodzie z rzeczywistym fokusem edytora. Zachowaj też sprawdzanie w chwili wywołania. Apple opisuje FocusedValue jako mechanizm obserwowania wartości z widoku z fokusem lub jego przodków. Wartości związane z fokusem, wyłączanie interakcji.
Jeśli zamiast tego utworzysz element menu AppKit, którego celem jest edytor, validateMenuItem(_:) udostępnia mechanizm walidacji AppKit. Dodanie tej metody do podklasy samo w sobie nie zwalidowałoby przycisku SwiftUI: jego akcją jest domknięcie, które następnie wysyła kolejne polecenie. Apple wymaga, aby obiekt walidujący był celem elementu menu. Dokumentacja Apple dotycząca walidacji elementów menu.
Zdecyduj o polityce wyboru celu, zanim dodasz kolejne polecenia. Użyj polecenia bez określonego celu, gdy pożądane jest przekazywanie w łańcuchu obsługi zdarzeń; ustal pierwszy obiekt obsługujący zdarzenia i wskaż go jako cel, gdy operacja dotyczy wyłącznie aktywnego edytora. Dzięki temu menu pozostaje przewidywalne, gdy użytkownicy przechodzą między widokami tekstowymi i innymi elementami sterującymi.
Źródła
sendAction(_:to:from:)— ApplekeyWindow— ApplefirstResponder— AppleNSViewRepresentable— Applecommands(content:)— AppleCommandMenu(https://developer.apple.com/documentation/swiftui/commandmenu) — Apple- Korzystanie z funkcji środowiska wykonawczego Objective-C w języku Swift — Apple
lineRange(for:)— ApplesetSelectedRange(_:)— ApplescrollRangeToVisible(_:)— ApplehasMarkedText()— Apple- Umieszczanie obiektu NSTextView w NSScrollView — Apple
NSTextView— Appleinit(frame:textContainer:)— AppleFocusedValue— Appledisabled(_:)— ApplevalidateMenuItem(_:)— Apple