Registre operações de desfazer em um armazenamento de modelos de longa duração e permita que as visualizações enviem edições por meio dos métodos do armazenamento. Em um editor de formas para Mac, mantenha as prévias do arrasto locais, confirme uma única vez ao soltar e deixe o armazenamento registrar o inverso que também possibilita refazer.

Este tutorial cria um pequeno app macOS em SwiftUI que contém um retângulo sintético. Você pode movê-lo, ocultar seu editor, desfazer o movimento e mostrar o editor novamente. A tarefa concreta é preservar o comportamento de desfazer quando uma visualização de edição desaparece enquanto seu modelo continua ativo. Tudo permanece na memória.

Por que o armazenamento deve receber a operação de desfazer?

O método de registro baseado em closures da Apple exige um alvo do tipo referência. A documentação também explica que o gerenciador de desfazer mantém uma referência não proprietária a esse alvo. Portanto, registrar uma ação não determina o tempo de vida do alvo. A Apple recomenda usar o parâmetro de alvo do handler em vez de capturar outra referência ao mesmo alvo. Documentação da Apple sobre o registro de operações de desfazer.

Um armazenamento que possui o estado editado é um alvo adequado. Um proprietário estável mantém esse armazenamento ativo; visualizações temporárias o exibem e solicitam alterações. Aqui, a visualização raiz da janela possui o armazenamento com @StateObject, enquanto a tela removível o observa. O SwiftUI preserva um objeto de estado durante o tempo de vida do contêiner que o declara, mas mudar a identidade do contêiner pode criar uma nova instância. Documentação da Apple sobre StateObject.

“ Inversos pertencentes ao armazenamento” descreve onde fica a lógica de reversão. O gerenciador de desfazer continua registrando e agendando os callbacks. O armazenamento fornece o valor anterior e o método que o restaura. Essa divisão segue a arquitetura documentada pela Apple, que permite que o destinatário do desfazer seja o objeto alterado ou seu proprietário. Guia da Apple sobre o Undo Manager.

Responsabilidade Responsável neste exemplo
Posição confirmada do retângulo Armazenamento de modelos
Registro do inverso e mutação Método do armazenamento de modelos
Tempo de vida do armazenamento Raiz estável da janela
Translação temporária do arrasto Estado do gesto da tela
Histórico e execução de desfazer Gerenciador de desfazer do ambiente
Seleção do gerenciador aplicável Visualização no limite da interação

Implemente uma mutação reversível no armazenamento

Crie um app macOS em SwiftUI com destino de implantação macOS 13 ou posterior e um SDK atual da Apple. Os três arquivos a seguir formam o exemplo completo; substitua o ponto de entrada e a visualização de conteúdo gerados para o app, em vez de manter uma segunda declaração @main. O compilador Swift e os frameworks do macOS não estão disponíveis neste ambiente de pesquisa, então este exemplo foi inspecionado, mas não compilado aqui.

Crie ShapeStore.swift. O armazenamento tem uma posição confirmada e um método de mutação. Capturar a posição anterior antes da atribuição fornece ao método as informações necessárias para reverter a edição.

import Foundation
import Combine

@MainActor
final class ShapeStore: ObservableObject {
    @Published private(set) var position = CGPoint(x: 120, y: 100)

    func move(to newPosition: CGPoint, undoManager: UndoManager?) {
        let previousPosition = position
        guard newPosition != previousPosition else { return }

        if let undoManager {
            undoManager.registerUndo(withTarget: self) {
                [weak undoManager] store in
                store.move(
                    to: previousPosition,
                    undoManager: undoManager
                )
            }
            undoManager.setActionName("Move Rectangle")
        }

        position = newPosition
    }
}

O inverso chama o mesmo método. Suponha que a posição mude de (120, 100) para (160, 100). O callback registrado solicita (120, 100). Quando o desfazer executa esse callback, o método vê (160, 100) como seu valor anterior e registra o caminho de volta. A Apple documenta que os registros feitos durante o desfazer se tornam operações de refazer. Ignorar o registro sempre que isUndoing for verdadeiro quebraria esse padrão. Guia da Apple sobre o Undo Manager.

A closure opera sobre seu parâmetro store e captura o gerenciador de forma fraca. Isso evita uma referência forte da closure armazenada pelo gerenciador de volta ao próprio gerenciador. A documentação de contagem de referências do Swift explica que referências fracas não mantêm ativa a instância referenciada e se tornam nil quando ela é desalocada. A Linguagem de Programação Swift: Contagem Automática de Referências.

O armazenamento e seu método de mutação são isolados no ator principal. Isso corresponde à documentação atual da Apple: UndoManager e o handler de registro são isolados no ator principal. O método executa o registro e a mutação de forma síncrona, mantendo essa pequena edição dentro da interação que o chamou. Documentação da Apple sobre UndoManager.

Mantenha o armazenamento acima do editor removível

Crie StoreUndoDemoApp.swift. A raiz possui o armazenamento independentemente de a tela estar visível. Seus botões consultam o gerenciador atual do ambiente quando uma ação é executada. A tela recebe esse mesmo gerenciador para suas edições.

import SwiftUI

@main
struct StoreUndoDemoApp: App {
    var body: some Scene {
        WindowGroup {
            EditorRoot()
        }
    }
}

@MainActor
struct EditorRoot: View {
    @StateObject private var store = ShapeStore()
    @State private var showsEditor = true
    @Environment(\.undoManager) private var undoManager

    var body: some View {
        VStack(spacing: 16) {
            HStack {
                Button("Move Right") {
                    store.move(
                        to: CGPoint(
                            x: store.position.x + 20,
                            y: store.position.y
                        ),
                        undoManager: undoManager
                    )
                }

                Button("Undo") {
                    if let undoManager, undoManager.canUndo {
                        undoManager.undo()
                    }
                }

                Button("Redo") {
                    if let undoManager, undoManager.canRedo {
                        undoManager.redo()
                    }
                }

                Toggle("Show Editor", isOn: $showsEditor)
            }

            Text(
                "Position: \(Int(store.position.x)), " +
                "\(Int(store.position.y))"
            )

            if showsEditor {
                ShapeCanvas(store: store, undoManager: undoManager)
            } else {
                Text("Editor hidden")
                    .frame(width: 400, height: 260)
            }
        }
        .padding(20)
        .frame(width: 620, height: 380)
    }
}

O gerenciador de desfazer do ambiente do SwiftUI é opcional. A Apple permite explicitamente ignorar o registro de desfazer quando o ambiente fornece nil. Neste exemplo, o retângulo ainda se move nessa situação, mas nenhuma entrada de desfazer é criada. Se uma operação de desfazer esperada estiver ausente, verifique se a interação recebeu um gerenciador antes de alterar a lógica do inverso. Documentação da Apple sobre undoManager em EnvironmentValues.

Passar o gerenciador para cada mutação também mantém explícito o contexto da janela. O armazenamento não retém o gerenciador de janela que apareceu primeiro. Neste exemplo, cada raiz de janela possui seu próprio armazenamento. Se várias janelas passarem a editar um modelo compartilhado, decida qual histórico será responsável por essas edições antes de reutilizar essa organização entre janelas.

Confirme um arrasto sem estender um grupo de desfazer entre eventos

Crie ShapeCanvas.swift. Durante o arrasto, a posição exibida do retângulo inclui uma translação temporária. A posição confirmada no armazenamento só muda quando o gesto termina com sucesso. Conclua um arrasto antes de invocar outra edição ou um comando de desfazer; este exemplo compacto pressupõe interações sequenciais e não arbitra comandos simultâneos durante um arrasto.

import SwiftUI

@MainActor
struct ShapeCanvas: View {
    @ObservedObject var store: ShapeStore
    let undoManager: UndoManager?

    @GestureState private var translation: CGSize = .zero

    var body: some View {
        ZStack(alignment: .topLeading) {
            Color.gray.opacity(0.12)

            Rectangle()
                .fill(Color.blue)
                .frame(width: 70, height: 50)
                .position(
                    x: store.position.x + translation.width,
                    y: store.position.y + translation.height
                )
                .gesture(
                    DragGesture()
                        .updating($translation) { value, state, _ in
                            state = value.translation
                        }
                        .onEnded { value in
                            store.move(
                                to: CGPoint(
                                    x: store.position.x
                                        + value.translation.width,
                                    y: store.position.y
                                        + value.translation.height
                                ),
                                undoManager: undoManager
                            )
                        }
                )
        }
        .frame(width: 400, height: 260)
        .clipped()
    }
}

O gesto usa sua translação final para confirmar o movimento. Ele não depende do momento em que a propriedade de prévia é redefinida. A Apple documenta que updating(_:body:) redefine o estado do gesto quando ele termina ou é cancelado, enquanto onEnded(_:) só é executado após uma conclusão bem-sucedida. Assim, um gesto cancelado neste projeto não deixa movimento confirmado nem cria uma nova ação de desfazer. Documentação da Apple sobre a atualização de gestos, documentação da Apple sobre a conclusão de gestos.

Isso preserva três limites distintos: uma atualização da prévia altera o estado transitório da exibição; a conclusão bem-sucedida solicita uma edição do modelo; o armazenamento registra o inverso dessa edição. Chamar a mutação do armazenamento em cada atualização do arrasto registraria, em vez disso, posições intermediárias ao longo do gesto.

O agrupamento padrão de eventos da Apple reúne registros em torno de passagens do loop de execução. Um arrasto pode produzir atualizações em várias passagens, portanto o agrupamento de eventos, por si só, não expressa a transação pretendida para o arrasto inteiro. Aqui, confirmar uma única vez evita esse problema sem alterar a configuração do gerenciador compartilhado. Documentação da Apple sobre groupsByEvent.

Para um comando futuro que altere vários itens de forma síncrona, um grupo de desfazer explícito e equilibrado pode reunir os inversos. Mantenha seu início e fim dentro desse comando. Se for necessário alterar o modelo ao vivo durante o arrasto, projete operações separadas de início, atualização, confirmação e cancelamento, incluindo como outros comandos interagem com a edição ativa. A abordagem baseada apenas em prévias usada aqui mantém deliberadamente essa infraestrutura adicional de transação fora do armazenamento.

Verifique separadamente a propriedade, o refazer e o roteamento entre janelas

Siga esta sequência com o app em execução. As coordenadas são valores esperados ilustrativos com base no estado inicial fornecido e no incremento do botão, não resultados medidos.

Ação Posição confirmada ou resultado esperado
Abrir uma nova janela (120, 100)
Clicar em Move Right (140, 100)
Desativar Show Editor A tela desaparece; a posição permanece em (140, 100)
Clicar em Undo A posição passa a (120, 100) enquanto a tela continua ausente
Clicar em Redo A posição passa a (140, 100)
Ativar Show Editor O retângulo aparece na posição refeita
Concluir um arrasto e clicar em Undo O retângulo retorna à posição anterior ao arrasto

A verificação com o editor oculto distingue a propriedade do modelo do tempo de vida da apresentação. Se o desfazer só funcionar enquanto um painel específico existir, inspecione o alvo registrado no gerenciador e o objeto que o mantém ativo. Se o desfazer funcionar, mas o refazer não, verifique se o inverso registra a operação de retorno. Se um arrasto exigir muitos desfazeres, verifique se as atualizações da prévia estão chegando à mutação passível de desfazer do armazenamento.

Os botões explícitos facilitam a inspeção do caminho da mutação. Para o comportamento normal dos menus do Mac, teste também o menu padrão Edit com a janela pretendida ativa. O responder base do AppKit obtém seu gerenciador de desfazer do próximo responder. Quando uma janela solicita um gerenciador ao seu delegado, windowWillReturnUndoManager(_:) é o ponto de personalização documentado; sem essa implementação, a janela cria seu próprio gerenciador. Registrar em um gerenciador sem relação não estabelece o roteamento pretendido pela cadeia de responders. Documentação da Apple sobre undoManager em NSResponder, documentação da Apple sobre o delegado do gerenciador de desfazer da janela.

O armazenamento também precisa de um limite de encerramento deliberado. Se você substituir um armazenamento enquanto o gerenciador continua existindo, remova os registros desse armazenamento antes de liberá-lo, usando removeAllActions(withTarget:) em cada gerenciador que contém suas ações. Não limpe o histórico só porque a tela desaparece: manter disponíveis as edições do armazenamento durante esse desaparecimento é o comportamento que está sendo implementado. Documentação da Apple sobre a limpeza de desfazer específica de um alvo.

Este exemplo tem um retângulo fixo e nenhum caminho de exclusão. Ao estendê-lo para uma coleção, capture identificadores estáveis dos itens e os valores necessários para reverter cada operação. A exclusão precisa de um inverso que restaure o item e a ordem pretendida; um índice antigo da matriz, sozinho, não basta se a coleção puder mudar.

O projeto concluído atribui à raiz a responsabilidade pelo tempo de vida, à tela a responsabilidade pelo estado temporário da interação e ao armazenamento a responsabilidade pelas edições reversíveis. Ocultar a tela mantém essas edições disponíveis porque seus métodos inversos operam sobre o armazenamento ainda ativo.

Fontes