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.

  1. Abra Edit Note, substitua a segunda linha por Arrange the green tiles in a row. e clique em Cancel. A nota aceita deve manter a segunda linha original.
  2. Reabra o editor. Ele deve conter o texto aceito original, sem nenhuma parte da revisão cancelada.
  3. Faça a mesma revisão e clique em Commit. A nota aceita deve exibir a linha revisada.
  4. Reabra, adicione uma quebra de linha e use Command-Return. O rascunho multilinha completo deve passar a ser o texto aceito.
  5. Reabra, faça uma alteração e teste Escape durante a edição. Verifique se o cancelamento mantém o texto aceito inalterado.
  6. 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