Crie um editor para Mac mantendo o texto aceito na visualização principal e editando um rascunho separado em uma folha. Somente Commit copia o rascunho de volta; Cancel fecha a folha. Este tutorial implementa esse fluxo para uma pequena nota em texto simples dentro do seu próprio app.
Defina o que confirmar e cancelar devem fazer
A tarefa do leitor é simples: abrir uma nota existente, revisar várias linhas e aceitar a revisão completa ou deixar o original intacto. O exemplo usa texto sintético e uma única sessão de edição. Ele mantém o texto aceito na memória; adicionar armazenamento em arquivos é uma decisão separada da aplicação.
O TextEditor da Apple exibe texto multilinha e rolável, e aceita uma associação com o valor que está sendo editado. Uma associação com o valor aceito permitiria, portanto, que as edições alterassem esse valor imediatamente. Para implementar uma aceitação adiada, associe o editor a um rascunho. Consulte a documentação do TextEditor da Apple.
O design tem três partes: o texto aceito, pertencente à visualização principal; uma cópia criada quando a edição começa; e o estado mutável do rascunho, pertencente à folha. String, em Swift, tem semântica de valor, portanto alterar o rascunho não altera a string aceita. O armazenamento copy-on-write é uma otimização de implementação; o comportamento de valores independentes permanece o mesmo. Consulte String.
| Evento | Comportamento do rascunho | Texto aceito |
|---|---|---|
| Abrir o editor | Começa com o texto aceito atual | Inalterado |
| Digitar ou remover texto | Altera o texto dentro da folha | Inalterado |
Escolher Commit |
É enviado à visualização principal, e então a folha fecha | Substituído pelo rascunho |
Escolher Cancel |
A folha fecha sem enviar o rascunho de volta | Inalterado |
| Reabrir o editor | Começa uma nova sessão | Fornece o novo texto inicial |
Esta tabela também define o contrato da implementação. Fechar a folha não significa, por si só, que o texto foi aceito. Manter essa distinção explícita permite cancelar sem precisar desfazer alterações.
Crie o exemplo completo para macOS
Crie um projeto de app para macOS no Xcode usando SwiftUI e defina o destino de implantação como macOS 12 ou posterior. Substitua as declarações geradas do app e da visualização de conteúdo pelo arquivo-fonte único a seguir, removendo a declaração antiga do app para que o destino contenha exatamente um ponto de entrada @main. O exemplo usa edição de texto simples, em vez do inicializador mais recente para texto com atributos.
O requisito mínimo de macOS 12 vem do DismissAction usado pela folha; o TextEditor para texto simples está disponível desde o macOS 11. A Apple documenta esses limites de disponibilidade em 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)
}
}
Este é um exemplo completo de aplicação, incluindo o texto inicial, o estado de apresentação, o editor e o ponto de entrada. Não havia um compilador Swift nem o SDK do macOS disponíveis no ambiente de pesquisa, portanto o exemplo foi conferido com base na documentação de API consultada, em vez de ser compilado ou executado aqui.
Por que a visualização principal é proprietária do valor aceito
A visualização principal é proprietária de acceptedText porque exibe o resultado aceito e fornece o valor inicial da próxima sessão de edição. A visualização filha é proprietária de draft porque somente a interface de edição precisa alterá-lo. Isso segue a orientação da Apple de manter o estado privado e colocá-lo no nível apropriado da hierarquia de visualizações. Consulte State.
A visualização principal não passa $acceptedText ao editor. Em vez disso, passa um valor de string e um closure. O closure contém a única atribuição que aceita uma revisão: acceptedText = newText. Nesta implementação, nem a digitação nem o cancelamento podem chegar a essa atribuição.
Por que cada abertura recebe um identificador de sessão
O EditSession opcional fornece tanto o estado de apresentação quanto o texto inicial. A Apple documenta que sheet(item:onDismiss:content:) apresenta conteúdo quando o item associado não é nil, passando esse item ao closure de conteúdo. Consulte sheet(item:onDismiss:content:).
Cada toque em Edit Note cria um novo identificador. Aplicar esse identificador ao editor torna explícito o tempo de vida pretendido para o estado: um rascunho pertence a uma sessão de edição. O inicializador fornece o valor inicial do estado; ele não é um mecanismo para sincronizar continuamente um rascunho existente com alterações posteriores na visualização principal.
Para esta tarefa, esse comportamento de cópia inicial é desejável. Uma nota parcialmente editada não deve ser redefinida de repente porque alguma parte não relacionada da visualização principal foi atualizada. Reabrir cria um novo rascunho com o texto aceito mais recentemente.
Torne deliberados o fechamento e o comportamento do teclado
Leia @Environment(\.dismiss) dentro do editor apresentado, como faz o exemplo. A Apple explica que a ação se aplica ao ambiente em que é declarada. Lê-la na visualização raiz que apresenta a folha pode direcionar a ação à janela dessa visualização, em vez de à folha. Consulte DismissAction.
O botão Cancel simplesmente fecha a folha. O rascunho pode desaparecer porque nunca alterou o valor aceito. O botão Commit primeiro chama o closure de aceitação e depois fecha a folha. Não mova a aceitação para onDismiss: esse callback descreve o fechamento, que também ocorre quando alguém cancela.
O exemplo associa Escape a Cancel usando .cancelAction, o atalho padrão de cancelamento documentado pela Apple. Associa Command-Return a Commit usando uma combinação explícita de tecla e modificador. Esses são atalhos de controle da interface da aplicação. Consulte cancelAction e keyboardShortcut(_:modifiers:).
Return simples continua disponível para edição multilinha porque o exemplo não atribui .defaultAction ao botão de confirmação. A Apple define .defaultAction como Return sem modificadores. Command-Return é uma escolha editorial para esta interface multilinha, e não uma afirmação de que todo editor para Mac deva usá-lo. Consulte defaultAction.
Mantenha os atalhos exclusivos dentro da interface pertinente. A Apple documenta que, quando vários controles têm o mesmo atalho, o primeiro controle correspondente encontrado durante a resolução é usado. Teste os atalhos com o cursor dentro do editor e também clique nos botões visíveis. Consulte keyboardShortcut(_:modifiers:).
Verifique o limite de aceitação com uma sequência curta de testes
Faça as verificações a seguir no app para Mac em execução. Elas exercitam o comportamento que diferencia um editor de rascunho de um editor em tempo real, incluindo o caso da string vazia, fácil de deixar passar.
- Abra
Edit Note, substitua a segunda linha porArrange the green tiles in a row.e clique emCancel. A nota aceita deve manter a segunda linha original. - Reabra o editor. Ele deve conter o texto aceito original, sem nenhuma parte da revisão cancelada.
- Faça a mesma revisão e clique em
Commit. A nota aceita deve exibir a linha revisada. - Reabra, adicione uma quebra de linha e use Command-Return. O rascunho multilinha completo deve passar a ser o texto aceito.
- Reabra, faça uma alteração e teste Escape durante a edição. Verifique se o cancelamento mantém o texto aceito inalterado.
- Reabra, remova todo o texto e clique em
Commit. A visualização principal deve exibir(Empty note). Ao reabrir, o editor deve estar vazio.
Texto vazio é válido neste exemplo. A implementação não remove espaços em branco nem quebras de linha, e não desativa a aceitação quando a string está vazia. Se o seu tipo de nota exigir conteúdo, adicione uma regra de validação visível e aplique-a tanto na ação de confirmação quanto ao estado habilitado do botão.
A comparação hasChanges controla apenas o rótulo de status. Manter Commit habilitado quando não há alterações torna a aceitação uma atribuição inofensiva do mesmo valor. Se depois você o desabilitar quando não houver alterações, mantenha Cancel disponível para que a folha sempre tenha uma saída explícita.
Saiba quando SwiftUI basta e quando AppKit ajuda
Para esta tarefa de texto simples, TextEditor já fornece a superfície de edição necessária. Não é preciso usar layout de texto personalizado nem integrar AppKit para separar as alterações no rascunho do texto aceito. Essa separação pertence ao design de estado da aplicação.
O SwiftUI atual também oferece um inicializador de TextEditor para strings com atributos, disponível desde o macOS 26. Portanto, texto formatado não exige automaticamente substituir o SwiftUI. É necessário alterar o tipo do rascunho e respeitar o requisito de implantação mais recente. A Apple também documenta limites de conversão de atributos de formatação para esse editor. Consulte init(text:selection:).
Se você precisar de comportamento de edição específico do AppKit ou controle direto do layout do texto, o caminho de integração compatível é um NSTextView hospedado por meio de NSViewRepresentable. A Apple descreve métodos do tipo representable para criar, atualizar e desmontar uma visualização do AppKit, com um coordenador para comunicar alterações delegadas de volta ao SwiftUI. Consulte NSViewRepresentable.
Preserve o mesmo contrato de aceitação nessa integração: as alterações feitas no AppKit atualizam o rascunho, e somente a ação explícita de confirmação atualiza o modelo aceito. Como precaução de implementação, evite substituir todo o conteúdo da visualização de texto em cada atualização do SwiftUI; diferencie alterações realmente recebidas das alterações enviadas pelo próprio editor.
O TextKit tem outro limite: NSTextView expõe o layout moderno por meio de textLayoutManager e o layout legado por meio de layoutManager. A Apple documenta que acessar este último no macOS 12 ou posterior coloca a visualização de texto em modo de compatibilidade usando NSLayoutManager. Escolha deliberadamente o mecanismo ao adicionar recursos de layout. Consulte TextKit e NSTextView.
Por fim, a operação de confirmação do exemplo é síncrona e feita em memória. Se você ampliá-la para gravar um documento, defina se a aceitação significa atualizar o modelo ou concluir a gravação. Em um design que grava antes de fechar, mantenha a folha aberta quando a gravação falhar e preserve o rascunho para uma nova tentativa. Se o mesmo registro puder ser alterado por outra interface do app durante a edição, verifique a revisão antes de substituí-lo.
O design essencial continua simples: crie um rascunho por sessão de edição, deixe o editor alterar esse rascunho e ofereça um único caminho explícito para aceitar. Com esse limite definido, o cancelamento permanece previsível mesmo à medida que a superfície de edição cresce.
Fontes
- 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