Registra las operaciones de deshacer en un almacén de modelos de larga duración y permite que las vistas envíen ediciones mediante métodos del almacén. En un editor de formas para Mac, mantén las vistas previas del arrastre en el ámbito local, confirma una sola vez al soltar y haz que el almacén registre la inversa que también permite rehacer.
Este tutorial crea una pequeña app SwiftUI para macOS que contiene un rectángulo sintético. Puedes moverlo, ocultar su editor, deshacer el movimiento y volver a mostrar el editor. El objetivo concreto es conservar la capacidad de deshacer cuando desaparece una vista de edición mientras su modelo sigue activo. Todo permanece en memoria.
¿Por qué debe recibir el almacén la operación de deshacer?
El método de registro basado en cierres de Apple requiere un destino de tipo referencia. También documenta que el gestor de deshacer mantiene una referencia no retenida a ese destino. Por lo tanto, registrar una acción no determina la duración de vida del destino. Apple recomienda usar el parámetro de destino del controlador en lugar de capturar otra referencia al mismo destino. Documentación de Apple sobre el registro de deshacer.
Un almacén que posee el estado editado es un destino adecuado. Un propietario estable mantiene activo ese almacén; las vistas temporales lo muestran y solicitan cambios. En este caso, la vista raíz de la ventana posee el almacén mediante @StateObject, mientras que el lienzo que se puede quitar lo observa. SwiftUI conserva un objeto de estado durante la vida útil del contenedor que lo declara, pero cambiar la identidad del contenedor puede crear una instancia nueva. Documentación de Apple sobre StateObject.
«Inversas propiedad del almacén» describe dónde reside la lógica de reversión. El gestor de deshacer sigue registrando y programando las llamadas. El almacén proporciona el valor anterior y el método que lo restaura. Esta división sigue la arquitectura documentada por Apple, que permite que el destinatario de deshacer sea el objeto modificado o su propietario. Guía de Apple sobre Undo Manager.
| Responsabilidad | Propietario en este ejemplo |
|---|---|
| Posición confirmada del rectángulo | Almacén de modelos |
| Registro de inversas y mutación | Método del almacén de modelos |
| Duración de vida del almacén | Vista raíz estable de la ventana |
| Traslación temporal durante el arrastre | Estado del gesto del lienzo |
| Historial y ejecución de deshacer | Gestor de deshacer del entorno |
| Selección del gestor aplicable | Vista en el límite de interacción |
Implementa una mutación reversible del almacén
Crea una app SwiftUI para macOS con un objetivo de implementación de macOS 13 o posterior y un SDK actual de Apple. Los siguientes tres archivos forman el ejemplo completo; reemplaza el punto de entrada de la app y la vista de contenido generados, en vez de conservar una segunda declaración @main. El compilador de Swift y los frameworks de macOS no están disponibles en este entorno de investigación, así que el ejemplo se ha revisado, pero no se ha compilado aquí.
Crea ShapeStore.swift. El almacén tiene una posición confirmada y un método de mutación. Capturar la posición anterior a la asignación proporciona al método la información necesaria para revertir la edición.
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
}
}
La inversa llama al mismo método. Supongamos que la posición cambia de (120, 100) a (160, 100). La llamada registrada solicita (120, 100). Cuando el deshacer ejecuta esa llamada, el método ve (160, 100) como su valor anterior y registra el viaje de vuelta. Apple documenta que los registros realizados durante la operación de deshacer se convierten en operaciones de rehacer. Omitir el registro siempre que isUndoing sea true rompería este patrón. Guía de Apple sobre Undo Manager.
El cierre opera sobre su parámetro store y captura el gestor débilmente. Así se evita que el cierre almacenado por un gestor mantenga una referencia fuerte a ese mismo gestor. La documentación de Swift sobre el conteo automático de referencias explica que las referencias débiles no mantienen activa la instancia referenciada y pasan a ser nil cuando se desasigna. The Swift Programming Language: conteo automático de referencias.
El almacén y su método de mutación están aislados al actor principal. Esto concuerda con la documentación actual de Apple: UndoManager y el controlador de registro están aislados al actor principal. El método realiza el registro y la mutación de forma sincrónica, de modo que esta pequeña edición queda dentro de la interacción que la invoca. Documentación de Apple sobre UndoManager.
Mantén el almacén por encima del editor que se puede quitar
Crea StoreUndoDemoApp.swift. La raíz posee el almacén tanto si el lienzo está visible como si no. Sus botones consultan el gestor actual del entorno cuando se ejecuta una acción. El lienzo recibe ese mismo gestor para sus ediciones.
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)
}
}
El gestor de deshacer del entorno de SwiftUI es opcional. Apple permite explícitamente omitir el registro de deshacer cuando el entorno proporciona nil. En ese caso, este ejemplo sigue moviendo el rectángulo, pero no crea ninguna entrada de deshacer. Si falta una operación de deshacer esperada, comprueba si la interacción recibió un gestor antes de cambiar la lógica de la inversa. Documentación de Apple sobre undoManager en Environment.
Pasar el gestor a cada mutación también deja explícito el contexto de la ventana. El almacén no conserva el gestor de la primera ventana que aparezca. En este ejemplo, cada raíz de ventana posee su propio almacén. Si más adelante varias ventanas editan un mismo modelo compartido, decide qué historial será propietario de esas ediciones antes de reutilizar esta estructura entre ventanas.
Confirma un solo arrastre sin prolongar un grupo de deshacer entre eventos
Crea ShapeCanvas.swift. Mientras arrastras, la posición mostrada del rectángulo incluye una traslación temporal. La posición confirmada del almacén solo cambia cuando el gesto termina correctamente. Completa un arrastre antes de invocar otra edición o comando de deshacer; este ejemplo compacto supone interacciones secuenciales y no arbitra comandos simultáneos durante un arrastre.
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()
}
}
El gesto usa su traslación final para confirmar el movimiento. No depende del momento en que se restablezca la propiedad de vista previa. Apple documenta que updating(_:body:) restablece el estado del gesto cuando este termina o se cancela, mientras que onEnded(_:) solo se ejecuta después de que finaliza correctamente. Por consiguiente, en este diseño un gesto cancelado no deja un movimiento confirmado ni una nueva acción de deshacer. Documentación de Apple sobre la actualización de gestos, Documentación de Apple sobre la finalización de gestos.
Esto conserva tres límites distintos: una actualización de la vista previa cambia el estado de visualización transitorio; la finalización correcta solicita una edición del modelo; el almacén registra la inversa de esa edición. Si se llamara a la mutación del almacén en cada actualización del arrastre, se registrarían posiciones intermedias durante todo el gesto.
La agrupación de eventos predeterminada de Apple recoge los registros alrededor de las iteraciones del bucle de ejecución. Un arrastre puede generar actualizaciones en varias iteraciones, por lo que la agrupación por eventos por sí sola no representa la transacción prevista para todo el arrastre. Aquí, confirmar una sola vez evita ese problema sin cambiar la configuración del gestor compartido. Documentación de Apple sobre groupsByEvent.
Para un comando futuro que cambie varios elementos sincrónicamente, un grupo de deshacer explícito y equilibrado puede reunir sus inversas. Mantén su inicio y fin dentro de ese comando. Si se vuelve necesario mutar el modelo en tiempo real durante el arrastre, diseña operaciones separadas de inicio, actualización, confirmación y cancelación, incluida la forma en que otros comandos interactúan con la edición activa. El enfoque basado solo en vistas previas de este ejemplo mantiene deliberadamente esa complejidad transaccional fuera del almacén.
Verifica por separado la propiedad, la repetición y el enrutamiento entre ventanas
Sigue esta secuencia con la app en ejecución. Las coordenadas son valores ilustrativos esperados a partir del estado inicial proporcionado y el incremento del botón; no son resultados medidos.
| Acción | Posición confirmada o resultado esperado |
|---|---|
| Abre una ventana nueva | (120, 100) |
Haz clic en Move Right |
(140, 100) |
Desactiva Show Editor |
El lienzo desaparece; la posición sigue siendo (140, 100) |
Haz clic en Undo |
La posición pasa a ser (120, 100) mientras el lienzo sigue oculto |
Haz clic en Redo |
La posición pasa a ser (140, 100) |
Activa Show Editor |
El rectángulo aparece en la posición repetida |
Completa un arrastre y luego haz clic en Undo |
El rectángulo vuelve a su posición anterior al arrastre |
La comprobación con el editor oculto distingue la propiedad del modelo de la duración de vida de la presentación. Si deshacer funciona solo mientras existe un panel concreto, revisa el destino registrado con el gestor y el objeto que lo mantiene activo. Si deshacer funciona pero rehacer no hace nada, revisa si la inversa registra la operación de vuelta. Si un arrastre requiere muchos deshacer, comprueba si las actualizaciones de vista previa llegan a la mutación del almacén que admite deshacer.
Los botones explícitos facilitan inspeccionar la ruta de mutación. Para el comportamiento habitual de los menús de Mac, prueba también el menú estándar Edit con la ventana deseada activa. El respondedor base de AppKit obtiene su gestor de deshacer del siguiente respondedor. Cuando una ventana solicita un gestor a su delegado, windowWillReturnUndoManager(_:) es el punto de personalización documentado; sin esa implementación, la ventana crea su propio gestor. Registrar en un gestor no relacionado no establece el enrutamiento deseado por la cadena de respondedores. Documentación de Apple sobre undoManager en NSResponder, Documentación de Apple sobre el delegado del gestor de deshacer de la ventana.
El almacén también necesita un límite de retiro deliberado. Si reemplazas un almacén mientras su gestor sigue existiendo, elimina los registros de ese almacén antes de liberarlo, usando removeAllActions(withTarget:) en cada gestor que conserve sus acciones. No borres el historial solo porque desaparezca el lienzo: el comportamiento que se está implementando consiste precisamente en mantener disponibles las ediciones del almacén durante esa desaparición. Documentación de Apple sobre la limpieza de acciones de deshacer para un destino específico.
Este ejemplo tiene un rectángulo fijo y ninguna ruta para eliminarlo. Al ampliarlo a una colección, captura identificadores de elementos estables y los valores necesarios para revertir cada operación. La inversa de una eliminación debe poder restaurar el elemento y el orden previsto; un índice antiguo de la matriz no basta si la colección puede cambiar.
El diseño terminado asigna a la raíz la responsabilidad de la duración de vida, al lienzo la del estado temporal de interacción y al almacén la de las ediciones reversibles. Ocultar el lienzo mantiene disponibles esas ediciones porque sus métodos inversos operan sobre el almacén que sigue activo.
Fuentes
- registerUndo(withTarget:handler:) — Apple
- StateObject — Apple
- Undo Manager — Apple
- Automatic Reference Counting — Swift.org
- UndoManager — Apple
- undoManager — EnvironmentValues — Apple
- updating(_:body:) — Apple
- onEnded(_:) — Apple
- groupsByEvent — Apple
- undoManager — NSResponder — Apple
- windowWillReturnUndoManager(_:) — Apple
- removeAllActions(withTarget:) — Apple