Crea un editor per Mac mantenendo il testo approvato nella vista principale e modificando una bozza separata in una finestra modale. Solo Commit ricopia la bozza; Cancel la chiude. Questo tutorial implementa questo flusso per una breve nota in testo semplice nella tua app.
Definisci cosa devono fare commit e cancel
Il compito del lettore è semplice: aprire una nota esistente, modificarne diverse righe e accettare la revisione completa oppure lasciare intatto l’originale. L’esempio usa testo sintetico e una singola sessione di modifica. Mantiene il testo approvato in memoria; aggiungere l’archiviazione su file è una decisione applicativa separata.
Il TextEditor di Apple mostra testo multilinea scorrevole e accetta un binding al valore modificato. Un binding al valore approvato permetterebbe quindi alle modifiche di aggiornarlo immediatamente. Per implementare un’accettazione differita, collega invece l’editor a una bozza. Consulta la documentazione di TextEditor di Apple.
Il design ha tre elementi: il testo approvato di proprietà della vista principale, un’istantanea creata all’avvio della modifica e lo stato modificabile della bozza di proprietà della finestra modale. Le String di Swift hanno semantica di valore, quindi modificare la bozza non modifica la stringa approvata. L’archiviazione copy-on-write è un’ottimizzazione d’implementazione; il comportamento con valori indipendenti resta invariato. Consulta String.
| Evento | Comportamento della bozza | Testo approvato |
|---|---|---|
| Apri l’editor | Parte dal testo approvato corrente | Invariato |
| Inserisci o rimuovi testo | Cambia nella finestra modale | Invariato |
Scegli Commit |
Viene passato alla vista principale, poi la finestra modale si chiude | Sostituito dalla bozza |
Scegli Cancel |
La finestra modale si chiude senza passare indietro la bozza | Invariato |
| Riapri l’editor | Avvia una nuova sessione | Fornisce il nuovo testo iniziale |
Questa tabella definisce anche il contratto d’implementazione. Chiudere la finestra modale non equivale ad accettare. Rendere esplicita questa distinzione consente di annullare senza un’operazione di ripristino.
Crea l’esempio completo per macOS
Crea in Xcode un progetto app per macOS usando SwiftUI e imposta come versione minima di distribuzione macOS 12 o successiva. Sostituisci le dichiarazioni generate dell’app e della vista del contenuto con il seguente singolo file sorgente, rimuovendo la vecchia dichiarazione dell’app in modo che il target contenga esattamente un punto d’ingresso @main. L’esempio usa la modifica di testo semplice anziché l’inizializzatore più recente per il testo con attributi.
Il requisito minimo di macOS 12 dipende da DismissAction della finestra modale; il TextEditor per testo semplice è disponibile da macOS 11. Apple documenta questi limiti di disponibilità in DismissAction e TextEditor.
import SwiftUI
import Foundation
@main
struct DraftEditorApp: App {
var body: some Scene {
WindowGroup {
NoteView()
}
}
}
private struct EditSession: Identifiable {
let id = UUID()
let initialText: String
}
private struct NoteView: View {
@State private var acceptedText = """
Sample note
Arrange the blue tiles beside the green tiles.
"""
@State private var editSession: EditSession?
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Accepted note")
.font(.headline)
ScrollView {
Text(acceptedText.isEmpty ? "(Empty note)" : acceptedText)
.frame(maxWidth: .infinity, alignment: .leading)
}
Button("Edit Note") {
editSession = EditSession(initialText: acceptedText)
}
}
.padding(20)
.frame(minWidth: 460, minHeight: 300)
.sheet(item: $editSession) { session in
DraftEditor(initialText: session.initialText) { newText in
acceptedText = newText
}
.id(session.id)
}
}
}
private struct DraftEditor: View {
@Environment(\.dismiss) private var dismiss
@State private var draft: String
private let initialText: String
private let onCommit: (String) -> Void
init(
initialText: String,
onCommit: @escaping (String) -> Void
) {
self.initialText = initialText
self.onCommit = onCommit
_draft = State(initialValue: initialText)
}
private var hasChanges: Bool {
draft != initialText
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Edit note")
.font(.headline)
TextEditor(text: $draft)
.font(.body)
.accessibilityLabel("Note draft")
.frame(minHeight: 220)
Text(hasChanges ? "Draft changed" : "No changes")
.font(.caption)
.foregroundColor(.secondary)
HStack {
Spacer()
Button("Cancel") {
dismiss()
}
.keyboardShortcut(.cancelAction)
Button("Commit") {
onCommit(draft)
dismiss()
}
.keyboardShortcut(.return, modifiers: .command)
}
}
.padding(20)
.frame(minWidth: 480, minHeight: 340)
}
}
Questo è un esempio applicativo completo, con testo iniziale, stato di presentazione, editor e punto d’ingresso. Nell’ambiente di ricerca non erano disponibili un compilatore Swift e un SDK macOS, quindi l’esempio è stato confrontato con la documentazione API recuperata, ma non è stato compilato né eseguito.
Perché il valore approvato appartiene alla vista principale
La vista principale è proprietaria di acceptedText perché mostra il risultato approvato e fornisce il valore iniziale per la sessione di modifica successiva. La vista figlia è proprietaria di draft perché solo l’interfaccia di modifica deve poterlo cambiare. Questo segue le indicazioni di Apple di mantenere privato lo stato e collocarlo al livello appropriato nella gerarchia delle viste. Consulta State.
La vista principale non passa $acceptedText all’editor. Passa invece un valore stringa e una closure. La closure contiene l’unica assegnazione che accetta una revisione: acceptedText = newText. In questa implementazione, né la digitazione né l’annullamento possono raggiungere tale assegnazione.
Perché ogni apertura riceve un identificatore di sessione
L’EditSession opzionale fornisce sia lo stato di presentazione sia il testo iniziale. Apple documenta che sheet(item:onDismiss:content:) presenta il contenuto quando il suo elemento associato non è nil, passando tale elemento alla closure del contenuto. Consulta sheet(item:onDismiss:content:).
Ogni pressione di Edit Note crea un nuovo identificatore. Applicare questo identificatore all’editor rende esplicito il ciclo di vita previsto dello stato: una bozza appartiene a una singola sessione di modifica. L’inizializzatore fornisce il valore iniziale dello stato; non è un meccanismo per sincronizzare continuamente una bozza esistente con le modifiche successive della vista principale.
Per questo compito, il comportamento basato su un’istantanea è auspicabile. Una nota modificata solo in parte non dovrebbe azzerarsi all’improvviso perché si aggiorna un’altra parte della vista principale. Alla riapertura viene creata una nuova bozza a partire dal testo accettato più di recente.
Gestisci con cura la chiusura e i tasti di scelta rapida
Leggi @Environment(\.dismiss) all’interno dell’editor presentato, come nell’esempio. Apple spiega che l’azione si applica all’ambiente in cui viene dichiarata. Leggerla nella vista radice che presenta l’editor può indirizzarla alla finestra di quella vista anziché alla finestra modale. Consulta DismissAction.
Il pulsante Cancel si limita a chiudere la finestra modale. La bozza può scomparire perché non ha mai modificato il valore approvato. Il pulsante Commit invoca prima la closure di accettazione e poi chiude la finestra modale. Non spostare l’accettazione in onDismiss: quel callback indica la chiusura, che avviene anche quando si annulla.
L’esempio assegna Escape a Cancel usando .cancelAction, la scorciatoia standard di annullamento documentata da Apple. Assegna Command-Return a Commit con un equivalente esplicito del tasto e il relativo modificatore. Sono scorciatoie di controllo nell’interfaccia dell’applicazione. Consulta cancelAction e keyboardShortcut(_:modifiers:).
Return senza modificatori resta disponibile per la modifica multilinea perché l’esempio non assegna .defaultAction al pulsante di commit. Apple definisce .defaultAction come Return senza modificatori. Command-Return è una scelta editoriale per questa interfaccia multilinea, non l’affermazione che ogni editor per Mac debba usarlo. Consulta defaultAction.
Mantieni le scorciatoie univoche nell’interfaccia pertinente. Apple documenta che, quando più controlli condividono la stessa scorciatoia, viene usato il primo controllo corrispondente trovato durante la risoluzione. Prova le scorciatoie mentre il cursore si trova nell’editor, oltre a fare clic sui pulsanti visibili. Consulta keyboardShortcut(_:modifiers:).
Verifica il limite di accettazione con una breve sequenza di test
Esegui questi controlli nell’app per Mac. Verificano il comportamento che distingue un editor di bozze da un editor in tempo reale, incluso il caso della stringa vuota, facile da trascurare.
- Apri
Edit Note, sostituisci la seconda riga conArrange the green tiles in a row.e fai clic suCancel. La nota approvata dovrebbe conservare la seconda riga originale. - Riapri l’editor. Dovrebbe contenere il testo approvato originale, senza la revisione annullata.
- Apporta la stessa modifica e fai clic su
Commit. La nota approvata dovrebbe ora mostrare la riga aggiornata. - Riapri l’editor, aggiungi un’interruzione di riga e usa Command-Return. La bozza multilinea completa dovrebbe diventare il testo approvato.
- Riapri l’editor, apporta una modifica e prova Escape durante la modifica. Verifica che l’annullamento lasci invariato il testo approvato.
- Riapri l’editor, elimina tutto il testo e fai clic su
Commit. La vista principale dovrebbe mostrare(Empty note). Riaprendo l’editor, il campo dovrebbe essere vuoto.
In questo esempio il testo vuoto è valido. L’implementazione non elimina gli spazi iniziali e finali, non rimuove le interruzioni di riga e non disabilita l’accettazione per una stringa vuota. Se il tuo tipo di nota richiede contenuto, aggiungi una regola di convalida visibile e applicala sia nell’azione di commit sia allo stato di abilitazione del pulsante.
Il confronto hasChanges controlla solo l’etichetta di stato. Lasciare Commit abilitato anche quando non ci sono modifiche rende l’accettazione una semplice assegnazione dello stesso valore. Se in seguito lo disabiliti quando il testo non è cambiato, lascia disponibile Cancel affinché la finestra modale abbia sempre un’uscita esplicita.
Quando basta SwiftUI e quando è utile AppKit
Per questo compito di testo semplice, TextEditor offre già la superficie di modifica necessaria. Non servono né un layout di testo personalizzato né un bridge AppKit per separare le modifiche alla bozza dal testo approvato. Tale separazione dipende dalla progettazione dello stato dell’applicazione.
SwiftUI attuale supporta anche un inizializzatore di TextEditor che accetta una stringa con attributi, disponibile da macOS 26. Il testo formattato quindi non richiede automaticamente di sostituire SwiftUI. Richiede però di cambiare il tipo della bozza e rispettare il requisito di distribuzione più recente. Apple documenta anche i limiti di conversione degli attributi di formattazione per quell’editor. Consulta init(text:selection:).
Se ti servono comportamenti di modifica specifici di AppKit o un controllo diretto sul layout del testo, il percorso d’integrazione supportato è un NSTextView ospitato tramite NSViewRepresentable. Apple descrive i metodi di representable per creare, aggiornare e smantellare una vista AppKit, con un coordinatore che comunica a SwiftUI le modifiche delegate. Consulta NSViewRepresentable.
Mantieni lo stesso contratto di accettazione nel bridge: le modifiche in AppKit aggiornano la bozza e solo l’azione esplicita di commit aggiorna il modello approvato. Come precauzione d’implementazione, evita di sostituire l’intero contenuto della vista di testo a ogni aggiornamento SwiftUI; distingui le modifiche in entrata effettive da quelle in uscita generate dall’editor.
TextKit presenta un ulteriore limite: NSTextView espone il layout moderno tramite textLayoutManager e quello legacy tramite layoutManager. Apple documenta che accedere al secondo su macOS 12 o successivo imposta la vista di testo in modalità di compatibilità usando NSLayoutManager. Scegli deliberatamente il motore quando aggiungi funzionalità di layout. Consulta TextKit e NSTextView.
Infine, l’operazione di commit dell’esempio è sincrona e avviene in memoria. Se la estendi per salvare un documento, definisci se l’accettazione significa aggiornare il modello o completare la scrittura. Per una progettazione che richiede la scrittura prima della chiusura, lascia aperta la finestra modale quando la scrittura fallisce e conserva la bozza per un nuovo tentativo. Se un’altra interfaccia gestita dall’app può modificare la stessa nota durante la sessione di modifica, aggiungi un controllo della revisione prima di sostituirla.
Il design essenziale resta semplice: crea una bozza per ogni sessione di modifica, consenti all’editor di modificarla e fornisci un solo percorso esplicito per accettarla. Con questo limite ben definito, l’annullamento resta prevedibile anche quando l’interfaccia di modifica si amplia.
Fonti
- TextEditor — Apple
- String — Apple
- State — Apple
- sheet(item:onDismiss:content:) — Apple
- DismissAction — Apple
- cancelAction — Apple
- defaultAction — Apple
- keyboardShortcut(_:modifiers:) — Apple
- init(text:selection:) — Apple
- NSViewRepresentable — Apple
- TextKit — Apple
- NSTextView — Apple