Para um editor de formas pequeno e em memória para Mac, comece com snapshots de valores do estado. Use comandos inversos para edições frequentes e de escopo restrito quando a redução do volume de dados justificar a lógica adicional. Ambas as abordagens precisam de um único responsável estável e de um limite de confirmação claro para cada ação do usuário.
Este guia aborda uma tarefa: implementar desfazer e refazer previsíveis para mover, excluir e reorganizar formas sintéticas no seu próprio editor Swift. A estrutura de decisão abaixo é uma recomendação de engenharia derivada das APIs da Apple; a Apple não prescreve uma escolha universal entre snapshots e comandos.
O que cada operação de desfazer deve guardar?
Um snapshot registra o estado editável anterior, como um array ordenado de formas. Desfazer restaura esse array. Um comando inverso registra a operação necessária para reverter uma edição específica, como definir novamente o centro de uma forma para suas coordenadas anteriores.
Ambas as representações funcionam com o UndoManager do Foundation. Ele registra operações em relação a um objeto ou ao seu responsável; não observa automaticamente atribuições arbitrárias ao seu modelo. Seus métodos de mutação precisam registrar o trabalho de restauração apropriado. UndoManager
Defina o estado editável antes de escolher a representação do histórico. Para este editor, ele inclui IDs das formas, coordenadas e ordem no array. Mantenha os indicadores de foco e as prévias de arrasto fora dele. Decida explicitamente se a seleção pertence ao histórico: restaurá-la pode facilitar a compreensão de uma exclusão, mas também significa que desfazer altera a seleção atual.
Use esta tabela de decisão para cada operação, em vez de exigir que o editor inteiro use uma única representação:
| Operação ou condição | Representação sugerida | Informações a reter |
|---|---|---|
| Tela pequena; várias propriedades mudam juntas | Snapshot | Valor editável anterior completo |
| Mover uma forma | Definidor inverso | ID estável da forma e centro anterior |
| Excluir uma forma | Snapshot inicialmente; comando inverso se necessário | Para um comando inverso: forma removida completa e posição original |
| Reorganizar várias formas | Snapshot, a menos que o inverso seja simples | Ordenação anterior e quaisquer propriedades afetadas |
| Custo medido de memória do histórico excessivo | Comandos inversos direcionados | Tudo o que for necessário para restauração exata |
A pergunta útil é: você consegue enumerar tudo o que o inverso precisa restaurar? Se excluir uma forma também altera a seleção e a ordenação, lembrar apenas o ID excluído não basta. Um snapshot costuma ser mais fácil de auditar quando a operação afeta vários valores relacionados.
Quando um snapshot é realmente independente?
Estruturas e arrays em Swift têm semântica de valor. Portanto, um array de formas composto inteiramente por tipos de valor pode representar um estado anterior de forma independente de mutações posteriores. No entanto, copiar um array de instâncias de classes preserva referências a essas instâncias; alterar uma instância também pode mudar o que o suposto snapshot observa. Envolver essas referências em uma estrutura não elimina esse problema. Value And Reference Types In Swift
Para um editor pequeno, use dados de valor simples no histórico: identificadores, coordenadas, dimensões e outros atributos editáveis. Mantenha as views e os objetos de renderização mutáveis fora do snapshot. Se o modelo ativo usar classes, construa uma representação de valor separada que contenha todos os atributos necessários para restaurá-lo.
Não deduza um custo fixo de memória apenas pela palavra “snapshot”. Meça a memória retida pelo histórico e a latência das edições com telas sintéticas representativas. Como cálculo ilustrativo de volume de dados, 100 entradas que retêm 1.000 registros de formas cada representam 100.000 ocorrências de registros; 100 entradas inversas de uma única forma retêm apenas 100 centros anteriores, além dos identificadores. Essas contagens não correspondem a bytes medidos nem afirmam nada sobre a alocação de memória do Swift.
Defina deliberadamente a profundidade do histórico. A Apple documenta que levelsOfUndo limita os grupos de desfazer de nível superior, e que o valor padrão, zero, significa grupos ilimitados. Um limite de grupos é útil, mas não impõe um orçamento de bytes: um snapshot em lote e uma edição de uma única coordenada podem reter quantidades de dados muito diferentes. levelsOfUndo
Como snapshots e comandos inversos preservam o refazer?
O método de restauração deve registrar a restauração oposta antes de alterar o modelo. Quando desfazer executa esse método, o registro cria a possibilidade de refazer. Para um snapshot, capture o estado instalado no momento antes de restaurar o estado anterior. Para um comando de posição, capture o centro atual antes de instalar o centro salvo.
O guia arquivado da Apple sobre a arquitetura de desfazer explica explicitamente esse padrão de registro recíproco: um definidor chamado durante o desfazer registra o valor necessário para refazer. Os exemplos usam Objective-C; o exemplo completo em Swift abaixo usa a API atual baseada em closures. Registering Undo Operations
Não suprima o registro apenas porque isUndoing é verdadeiro. Neste projeto, isso removeria o registro que cria o refazer. Evite também reaplicar um novo movimento relativo ao restaurar uma posição: armazenar o centro anterior exato torna explícita a restauração pretendida.
Um exemplo completo no nível do modelo
Este programa independente para macOS combina uma exclusão baseada em snapshot com um definidor inverso de posição. Usa apenas valores sintéticos em memória e Foundation, sem dependências de persistência ou interface. Salve-o como EditorUndo.swift e compile com uma versão atual do toolchain Swift 6 e do SDK do macOS:
swiftc -parse-as-library EditorUndo.swift -o EditorUndo
./EditorUndo
O programa usa intencionalmente um gerenciador dedicado e desativa o agrupamento por evento para que suas asserções não dependam de um loop de eventos de aplicativo em execução. Cada edição inicial recebe um grupo explícito. Os métodos de restauração não abrem grupos adicionais durante o desfazer ou o refazer. A Apple exige que os grupos sejam fechados explicitamente antes de desfazer quando o agrupamento automático está desativado. groupsByEvent
import Foundation
struct Center: Equatable {
var x: Double
var y: Double
}
struct Item: Equatable {
let id: Int
var center: Center
}
@MainActor
final class Editor {
private(set) var items: [Item]
let history: UndoManager
init(items: [Item], history: UndoManager) {
precondition(Set(items.map(\.id)).count == items.count)
self.items = items
self.history = history
}
func replaceItems(_ next: [Item], name: String) {
precondition(Set(next.map(\.id)).count == next.count)
guard next != items else { return }
let previous = items
history.registerUndo(withTarget: self) { target in
target.replaceItems(previous, name: name)
}
items = next
history.setActionName(name)
}
func setCenter(id: Int, to next: Center) {
guard let index = items.firstIndex(where: { $0.id == id }) else {
preconditionFailure("History refers to a missing item")
}
let previous = items[index].center
guard previous != next else { return }
history.registerUndo(withTarget: self) { target in
target.setCenter(id: id, to: previous)
}
items[index].center = next
history.setActionName("Move Shape")
}
}
@main
struct Demo {
@MainActor
static func main() {
let history = UndoManager()
history.groupsByEvent = false
history.levelsOfUndo = 100
let original = [
Item(id: 1, center: Center(x: 20, y: 30)),
Item(id: 2, center: Center(x: 80, y: 30))
]
let editor = Editor(items: original, history: history)
func edit(_ operation: () -> Void) {
history.beginUndoGrouping()
defer { history.endUndoGrouping() }
operation()
}
edit {
editor.setCenter(id: 1, to: Center(x: 50, y: 40))
}
let moved = editor.items
edit {
editor.replaceItems(
editor.items.filter { $0.id != 2 },
name: "Delete Shape"
)
}
let deleted = editor.items
history.undo()
precondition(editor.items == moved)
history.undo()
precondition(editor.items == original)
history.redo()
precondition(editor.items == moved)
history.redo()
precondition(editor.items == deleted)
print("Undo and redo checks passed")
}
}
A saída esperada é Undo and redo checks passed. O exemplo foi inspecionado, mas não executado aqui porque não havia um compilador Swift disponível no ambiente de pesquisa. Ele demonstra o núcleo do histórico; uma interface em SwiftUI também precisa de um modelo observável ou adaptador para atualizar suas views.
A closure usa seu parâmetro target em vez de capturar diretamente o editor. A Apple documenta que o destino do registro precisa ser um tipo de referência, que o gerenciador mantém uma referência não proprietária a ele e que usar o parâmetro da closure evita um ciclo de retenção por meio de um destino capturado. registerUndo(withTarget:handler:)
As precondições expõem suposições incorretas do modelo em vez de descartar silenciosamente uma operação de desfazer. Um limite de comando em produção deve rejeitar uma edição inválida antes do registro e apresentar uma falha adequada no nível do aplicativo. IDs ausentes durante a reprodução do histórico indicam que algo alterou o modelo fora da sequência de histórico esperada.
Como transformar um arrasto em uma única ação que pode ser desfeita?
Um arrasto produz muitas atualizações, enquanto o usuário espera que um movimento concluído possa ser desfeito como uma única ação. O agrupamento automático da Apple acompanha as passagens do loop de eventos, portanto não pode ser tratado como a definição de um gesto inteiro. Grupos explícitos podem combinar registros, mas seus limites ainda precisam ser gerenciados deliberadamente. groupsByEvent, beginUndoGrouping()
Para este editor, use uma sequência de prévia e confirmação:
- Quando o arrasto começar, retenha o ID do alvo e o centro inicial confirmado como referência do gesto.
- Use
@GestureStateeupdating(_:body:)para a translação temporária. Renderize a forma na posição de referência mais essa translação, sem alterar os itens confirmados. - Em
onEnded(_:)após a conclusão bem-sucedida, calcule o centro final a partir do valor final do gesto e chame o definidor anulável do modelo uma vez. - Não registre nada se o centro resultante for igual ao centro confirmado.
- Em caso de cancelamento, descarte a prévia e mantenha o modelo confirmado.
Essa sequência segue a distinção documentada pelo SwiftUI: o estado do gesto é redefinido quando um gesto termina ou é cancelado, enquanto onEnded(_:) é executado apenas quando a conclusão ocorre com sucesso. Portanto, a limpeza que também precisa ocorrer após um cancelamento não pode depender apenas de onEnded(_:). Adding interactivity with gestures
Mantenha estável o estado confirmado do editor durante essa prévia. Uma política simples é desativar outras mutações da tela e o desfazer nela enquanto um arrasto estiver ativo, reativando-os após a conclusão ou o cancelamento. Caso contrário, defina como uma edição intermediária invalida a referência salva antes de aceitar a confirmação do arrasto.
Se outra parte do editor precisar observar continuamente as coordenadas confirmadas atualizadas, a renderização apenas da prévia pode não ser adequada. Nesse caso, implemente uma transação explícita do gesto: salve o estado inicial, aplique atualizações intermediárias sem entradas de histórico separadas e registre uma restauração quando o gesto for concluído com sucesso. O cancelamento deve restaurar o estado inicial. Evite deixar um grupo de desfazer compartilhado aberto entre callbacks arbitrários ou trabalho assíncrono.
Quem deve ser responsável pelo modelo e pelo gerenciador de desfazer?
Mantenha um responsável estável pelo editor para cada tela editável independente. Ele é responsável pelos itens confirmados e é o alvo das operações do histórico. Mantenha as mutações do modelo e as interações com o desfazer no ator principal; a documentação atual da Apple marca UndoManager e seu manipulador de closure como isolados no ator principal. UndoManager
No SwiftUI, leia o gerenciador da view usando @Environment(\.undoManager). Passe esse gerenciador por uma camada estável do editor ou de comandos, em vez de criar um novo gerenciador durante cada avaliação da view. O valor do ambiente é opcional: a Apple documenta que contextos sem suporte retornam nil e permite ignorar o registro de desfazer nesses casos. A edição ainda pode continuar, mas esse caminho não deve indicar que o desfazer está disponível. EnvironmentValues.undoManager
Para uma tela AppKit, obtenha o gerenciador pelo responder adequado. A implementação de base de NSResponder.undoManager consulta o próximo responder. Portanto, um gerenciador criado privadamente precisa ser integrado explicitamente ao contexto de responders da tela se a expectativa é que as ações de menu padrão o alcancem. Guardá-lo apenas no modelo não estabelece essa conexão. NSResponder.undoManager
A configuração de agrupamento do exemplo independente pertence ao gerenciador dedicado. Não aplique essa configuração a um gerenciador fornecido pelo ambiente sem controlar a política de agrupamento de todos os participantes. Em uma interface, registre a edição concluída da tela usando a política de eventos existente no contexto; agrupe várias suboperações síncronas quando elas constituírem uma única ação.
Antes de descartar o alvo do editor, remova os registros pendentes em um limite controlado do ciclo de vida. A Apple fornece removeAllActions(withTarget:) para remover operações de desfazer e refazer endereçadas a esse alvo. Limpar o gerenciador inteiro também afetaria outros participantes se ele for compartilhado. removeAllActions(withTarget:)
O que você deve verificar antes de escolher o projeto final?
Teste uma sequência curta que atravesse as representações: mova uma forma, exclua outra, desfaça ambas e depois refaça ambas. Verifique os valores, IDs e a ordem após cada etapa. Inclua um arrasto cancelado, um arrasto que termina na posição inicial e duas janelas de editor independentes. Cada caso testa um limite diferente: dados de restauração, conclusão do gesto, tratamento de operações sem efeito ou responsabilidade.
Trate uma substituição em lote da tela como uma operação explícita. Torne-a anulável com dados de restauração suficientes ou estabeleça uma nova referência após limpar intencionalmente o histórico obsoleto. Um snapshot que omite campos editáveis e um comando inverso que não consegue encontrar seu alvo são implementações incompletas.
Comece com snapshots para uma tela pequena baseada em valores, mantenha um responsável estável e confirme cada gesto uma vez. Introduza comandos inversos quando os dados de restauração forem claros e as medições justificarem a lógica adicional. Assim, a representação do histórico pode mudar sem alterar o significado de uma ação do usuário.
Fontes
- UndoManager — Apple
- registerUndo(withTarget:handler:) — Apple
- groupsByEvent — Apple
- beginUndoGrouping() — Apple
- levelsOfUndo — Apple
- EnvironmentValues.undoManager — Apple
- NSResponder.undoManager — Apple
- Adding interactivity with gestures — Apple
- removeAllActions(withTarget:) — Apple
- Registering Undo Operations — Apple, Documentation Archive
- Value And Reference Types In Swift — Swift.org