Per inviare un comando di menu SwiftUI all’NSTextView attivo, individua il primo responder della finestra in primo piano quando viene eseguito il comando, quindi invia l’azione a quella vista. Questo tutorial crea un esempio con due editor in cui il comando Select First Line agisce solo sull’editor attivo.
L’obiettivo è un’azione di menu personalizzata all’interno della propria app macOS. L’esempio usa testo sintetico, una sottoclasse di NSTextView di proprietà dell’app e le API pubbliche di SwiftUI e AppKit. La decisione progettuale fondamentale è stabilire se un comando debba seguire la catena dei responder di AppKit o richiedere uno specifico editor attivo.
Scegliere cosa si intende per «vista di testo attiva»
In questo esempio, l’editor attivo è l’istanza di SampleTextView che al momento è il primo responder della finestra in primo piano. Apple definisce finestra in primo piano quella che riceve gli eventi della tastiera; il suo firstResponder identifica il responder che normalmente riceve per primo gli eventi e le azioni. Leggere queste proprietà al momento dell’invocazione fornisce al comando la sua destinazione attuale. Documentazione Apple di keyWindow, documentazione Apple di firstResponder.
AppKit supporta anche le azioni senza destinazione: passa nil come destinazione a NSApplication.sendAction(_:to:from:) e l’applicazione cerca un responder che implementi il selettore. La ricerca inizia dal primo responder della finestra in primo piano, ma può proseguire attraverso i delegati e la finestra principale prima di raggiungere i gestori a livello di applicazione. Di conseguenza, un’azione senza destinazione non significa «esegui solo sulla vista di testo attiva di questa finestra in primo piano». Documentazione Apple sull’instradamento delle azioni.
| Comportamento richiesto | Scelta di instradamento | Conseguenza |
|---|---|---|
| Lasciare che AppKit trovi un gestore idoneo | Inviare l’azione con to: nil |
Si applica il normale fallback della catena dei responder. |
| Agire solo sull’editor attivo di proprietà dell’app | Individuare e verificare il tipo del primo responder, quindi impostarlo come destinazione esplicita | Il comando si interrompe se quell’editor non è attivo. |
| Operare su un editor specifico indipendentemente dallo stato attivo | Fornire quell’editor come destinazione | È un’interazione diversa da un comando per l’editor attivo. |
L’implementazione seguente sceglie il secondo comportamento. Verificare anche la sottoclasse personalizzata impedisce che l’editor di un campo di testo non correlato diventi la destinazione solo perché si tratta di una vista di testo.
Creare l’esempio completo con SwiftUI e AppKit
Crea un progetto app macOS con SwiftUI e, per questo esempio, imposta come destinazione di distribuzione macOS 13 o successiva. Sostituisci il punto di ingresso dell’app generato con il seguente singolo file Swift; rimuovi ogni altra dichiarazione @main. Il programma fornisce entrambi gli editor, il testo iniziale, il menu e il gestore dell’azione.
NSViewRepresentable di SwiftUI è il ponte supportato per incorporare una vista AppKit. I comandi della scena forniscono le voci di menu, mentre CommandMenu crea un menu specifico dell’app nella barra dei menu di macOS. Documentazione Apple di NSViewRepresentable, comandi della scena, menu dei comandi.
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.
}
}
Questo è un esempio di app completo, con contenuti degli editor conservati in memoria. È stato confrontato con la documentazione API recuperata; qui non è stato compilato né eseguito perché l’ambiente disponibile è Linux e non include la toolchain Swift.
Perché il selettore raggiunge l’editor previsto
La funzione di instradamento recupera il primo responder immediatamente prima dell’invio. Accetta solo SampleTextView e fornisce quell’oggetto esatto come destinazione dell’azione. Il menu quindi non conserva l’editor su cui si è fatto clic in precedenza e non cerca in un’altra finestra se il controllo del tipo non va a buon fine.
Il metodo dell’azione usa @objc perché l’invio target-azione usa selettori Objective-C. L’espressione #selector fa riferimento al metodo dichiarato anziché a una stringa di selettore scritta a mano. Apple documenta questa combinazione per richiamare metodi Swift tramite le API target-azione di Objective-C. Uso delle funzionalità del runtime Objective-C in Swift.
Il valore restituito da sendAction indica se il messaggio è stato inviato. Non indica se il gestore ha modificato la selezione: il gestore può ricevere il messaggio e uscire in anticipo in caso di contenuto vuoto o testo contrassegnato. Questo esempio ignora intenzionalmente il risultato dell’invio perché la destinazione e il metodo sono già noti. Documentazione Apple sul valore restituito di sendAction.
Che cosa seleziona effettivamente il comando di selezione
Il gestore chiede a NSString.lineRange(for:) l’intervallo logico della riga che contiene l’inizio della stringa. Se presente, l’intervallo include il terminatore di riga. Seleziona quindi la prima riga delimitata da newline, anche quando quella riga va a capo su più righe visualizzate. Documentazione Apple sugli intervalli di riga.
Per il testo fornito, l’editor a sinistra seleziona Sample Alpha e il newline che segue; l’editor a destra seleziona Sample Beta e il newline che segue. Il comando sostituisce la selezione precedente. Non modifica il testo.
setSelectedRange(_:) applica la selezione e scrollRangeToVisible(_:) porta i suoi primi caratteri in vista. Il controllo del contenuto vuoto evita di tentare un’operazione di selezione in assenza di testo. Il controllo del testo contrassegnato è una scelta esplicita di questo esempio: rinviare l’azione di selezione personalizzata mentre è in corso la composizione dell’input. Intervalli di selezione, scorrimento di un intervallo fino alla vista, testo contrassegnato.
Tenere separati creazione, modifica e stato attivo della vista
Il representable crea un NSScrollView e installa la vista di testo personalizzata come vista del documento. L’editor si espande verticalmente, mentre il contenitore di testo si adatta alla sua larghezza. Questo segue la disposizione documentata da Apple per le viste di testo scorrevoli. Tale disposizione compare nella guida AppKit archiviata di Apple; il ponte SwiftUI e le API per le azioni citati qui fanno riferimento alla documentazione attuale. Inserire un oggetto NSTextView in un NSScrollView.
SwiftUI controlla la disposizione dello scroll view rappresentato. Il codice configura la geometria iniziale della vista documento annidata e il comportamento di ridimensionamento di AppKit; non assegna ripetutamente il frame dello scroll view rappresentato. Apple mette esplicitamente in guardia dal modificare le proprietà di layout gestite da SwiftUI per un representable. Indicazioni Apple sul layout dei representable.
L’esempio assegna initialText solo durante la creazione. Il metodo di aggiornamento vuoto lascia intenzionalmente la digitazione successiva sotto il controllo di AppKit. Se l’applicazione ha bisogno di un modello di documento SwiftUI, aggiungi un coordinator e la sincronizzazione basata su delegate, definendo una politica per le modifiche al modello che arrivano durante la modifica. Riassegnare la stringa iniziale a ogni aggiornamento sostituirebbe i contenuti correnti dell’utente. Apple descrive i coordinator come il ponte per inoltrare le interazioni AppKit a SwiftUI. Indicazioni Apple sul coordinamento dei representable.
Fare clic su un editor imposta lo stato attivo per la procedura guidata. Se l’app deve spostare lo stato attivo a livello di codice, usa makeFirstResponder(_:) della finestra contenitore dopo che la vista ne fa parte. Apple specifica di non richiamare direttamente becomeFirstResponder() della vista di testo. Evita di richiedere lo stato attivo a ogni aggiornamento SwiftUI, perché ciò reindirizzerebbe ripetutamente la destinazione di modifica dell’utente. Indicazioni Apple sullo stato attivo delle viste di testo.
Mantenere la configurazione TextKit esistente
L’instradamento dei menu non richiede di cambiare il motore di layout del testo. Questo esempio usa NSTextView(frame:); Apple documenta che questo inizializzatore usa NSLayoutManager per impostazione predefinita. La costruzione esplicita con NSTextLayoutManager è supportata da macOS 12. Documentazione Apple sull’inizializzazione delle viste di testo.
Per un editor TextKit 2 esistente, mantieni la sua costruzione e aggiungi l’azione alla sua sottoclasse. Evita di leggere layoutManager solo per individuare o instradare verso l’editor: Apple documenta che l’accesso a questa proprietà su una vista di testo TextKit 2 può attivare la modalità di compatibilità. Il codice di instradamento ha bisogno solo del responder e del metodo dell’azione. Documentazione Apple sulla modalità di compatibilità di NSTextView.
Verificare l’instradamento e gestire i comandi non disponibili
Segui questa sequenza per verificare il comportamento previsto su macOS. Si tratta di risultati attesi in base all’implementazione, non di risultati di un’esecuzione riportati.
- Fai clic nell’editor a sinistra, poi scegli
Sample Actions→Select First Line. La prima riga logica dell’editor a sinistra dovrebbe essere selezionata. - Fai clic nell’editor a destra e invoca lo stesso comando. Dovrebbe cambiare solo la selezione dell’editor a destra.
- Fai clic su
Window labele invoca il comando. Entrambi gli editor personalizzati dovrebbero rimanere invariati perché il primo responder non supera il controllo della sottoclasse. - Elimina tutto il contenuto di un editor e invoca il comando al suo interno. Il controllo del contenuto vuoto dovrebbe terminare senza modificare il testo.
- Aggiungi diverse righe, scorri verso il basso e invoca il comando. La prima riga dovrebbe essere selezionata e portata in vista.
In questo esempio minimale, il comando rimane abilitato anche quando lo stato attivo è altrove. È il controllo al momento dell’invocazione a determinare se agisce. Leggere keyWindow?.firstResponder soltanto all’interno di un’espressione SwiftUI .disabled non stabilirebbe un meccanismo per aggiornare quell’espressione ogni volta che cambia lo stato attivo di AppKit.
Per un’interfaccia SwiftUI più ampia, pubblica una capacità di comando adeguata tramite uno stato sensibile allo stato attivo, osservala con @FocusedValue e ricava da tale stato la condizione .disabled del pulsante. Il ponte AppKit deve mantenere tale capacità allineata con lo stato attivo effettivo dell’editor. Mantieni anche il controllo al momento dell’invocazione. Apple documenta FocusedValue come strumento per osservare valori della vista attiva o dei suoi antenati. Valori dello stato attivo, disabilitare l’interazione.
Se invece crei una voce di menu AppKit la cui destinazione è l’editor, validateMenuItem(_:) fornisce l’hook di convalida di AppKit. Aggiungere quel metodo a questa sottoclasse, da solo, non convaliderebbe il pulsante SwiftUI: la sua azione è una closure che in seguito invia un’altra azione. Apple richiede che l’oggetto di convalida sia la destinazione della voce di menu. Documentazione Apple sulla convalida delle voci di menu.
Scegli la politica di destinazione prima di aggiungere altri comandi. Usa un’azione senza destinazione quando il fallback della catena dei responder fa parte del comportamento desiderato; individua e imposta come destinazione il primo responder quando l’operazione riguarda esclusivamente l’editor attivo. Questa scelta permette allo stesso menu di rimanere prevedibile mentre gli utenti passano tra viste di testo e altri controlli.
Fonti
sendAction(_:to:from:)— ApplekeyWindow— ApplefirstResponder— AppleNSViewRepresentable— Applecommands(content:)— AppleCommandMenu— Apple- Uso delle funzionalità del runtime Objective-C in Swift — Apple
lineRange(for:)— ApplesetSelectedRange(_:)— ApplescrollRangeToVisible(_:)— ApplehasMarkedText()— Apple- Inserire un oggetto NSTextView in un NSScrollView — Apple
NSTextView— Appleinit(frame:textContainer:)— AppleFocusedValue— Appledisabled(_:)— ApplevalidateMenuItem(_:)— Apple