Регистрируйте операции отмены для долгоживущего хранилища моделей, а изменения из представлений передавайте через методы хранилища. В редакторе фигур для Mac храните предпросмотр перетаскивания локально, фиксируйте изменение один раз при отпускании и поручите хранилищу зарегистрировать обратную операцию, которая также обеспечивает повтор.
В этом руководстве создаётся небольшое приложение SwiftUI для macOS с одним условным прямоугольником. Его можно перемещать, скрывать редактор, отменять перемещение и снова показывать редактор. Конкретная задача — сохранить возможность отмены, даже если представление редактора исчезает, пока его модель остаётся активной. Всё хранится в памяти.
Почему хранилище должно получать операцию отмены?
Метод регистрации на основе замыкания от Apple требует целевой объект ссылочного типа. В документации также сказано, что менеджер отмены хранит на этот объект не владеющую ссылку. Поэтому регистрация действия сама по себе не продлевает срок жизни целевого объекта. Apple рекомендует использовать параметр целевого объекта обработчика вместо захвата другой ссылки на тот же объект. Документация Apple по регистрации отмены.
Хранилище, которому принадлежит изменяемое состояние, подходит на роль целевого объекта. Стабильный владелец поддерживает жизнь хранилища, а временные представления отображают его и запрашивают изменения. Здесь корневое представление окна владеет хранилищем через @StateObject, а удаляемый холст наблюдает за ним. SwiftUI сохраняет объект состояния в течение срока жизни контейнера, в котором он объявлен, но изменение идентификатора контейнера может создать новый экземпляр. Документация Apple по StateObject.
«Обратные операции, принадлежащие хранилищу» описывают, где находится логика отмены. Менеджер отмены по-прежнему записывает и запускает обратные вызовы. Хранилище предоставляет старое значение и метод, восстанавливающий его. Такое разделение соответствует задокументированной Apple архитектуре, в которой получателем отмены может быть как изменённый объект, так и его владелец. Руководство Apple по Undo Manager.
| Обязанность | Владелец в этом примере |
|---|---|
| Зафиксированное положение прямоугольника | Хранилище моделей |
| Регистрация обратной операции и изменение | Метод хранилища моделей |
| Срок жизни хранилища | Стабильный корень окна |
| Временное смещение при перетаскивании | Состояние жеста холста |
| История отмены и выполнение операций | Менеджер отмены из окружения |
| Выбор нужного менеджера | Представление на границе взаимодействия |
Реализуйте обратимое изменение в хранилище
Создайте приложение SwiftUI для macOS с целевой версией macOS 13 или новее и актуальным SDK Apple. Следующие три файла составляют полный пример; замените сгенерированные точку входа приложения и представление содержимого, не оставляя второе объявление @main. В этой среде для исследования недоступны компилятор Swift и фреймворки macOS, поэтому пример был изучен, но не скомпилирован.
Создайте файл ShapeStore.swift. В хранилище есть одно зафиксированное положение и один метод изменения. Если сохранить предыдущее положение до присваивания, метод получит данные, необходимые для отмены изменения.
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
}
}
Обратная операция вызывает тот же метод. Предположим, положение меняется с (120, 100) на (160, 100). Зарегистрированный обратный вызов запрашивает (120, 100). Когда отмена выполняет этот обратный вызов, метод видит (160, 100) как предыдущее значение и регистрирует обратный переход. Apple указывает, что регистрации, сделанные во время отмены, становятся операциями повтора. Пропуск регистрации, когда isUndoing имеет значение true, нарушит этот шаблон. Руководство Apple по Undo Manager.
Замыкание работает с параметром store и слабо захватывает менеджер. Это предотвращает сильную ссылку из сохранённого в менеджере замыкания обратно на сам менеджер. В документации Swift по подсчёту ссылок поясняется, что слабые ссылки не поддерживают жизнь объекта и становятся nil после его освобождения. Язык программирования Swift: автоматическое управление памятью.
Хранилище и его метод изменения изолированы главным актором. Это соответствует актуальной документации Apple: UndoManager и обработчик регистрации изолированы главным актором. Метод синхронно выполняет регистрацию и изменение, поэтому это небольшое изменение происходит в рамках вызвавшего его взаимодействия. Документация Apple по UndoManager.
Держите хранилище выше удаляемого редактора
Создайте файл StoreUndoDemoApp.swift. Корневое представление владеет хранилищем независимо от того, виден ли холст. Кнопки читают текущий менеджер из окружения в момент выполнения действия. Холст получает тот же менеджер для своих изменений.
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)
}
}
Менеджер отмены из окружения SwiftUI может отсутствовать. Apple прямо допускает пропуск регистрации отмены, если окружение предоставляет nil. В такой ситуации этот пример всё равно перемещает прямоугольник, но не создаёт запись отмены. Если ожидаемая операция отмены отсутствует, перед изменением логики обратных операций проверьте, получил ли компонент взаимодействия менеджер. Документация Apple по undoManager из окружения.
Передача менеджера каждому изменению также позволяет явно задать контекст окна. Хранилище не удерживает менеджер того окна, которое случайно появилось первым. В этом примере у каждого корневого представления окна своё хранилище. Если позднее несколько окон будут изменять одну общую модель, прежде чем использовать эту схему в нескольких окнах, решите, какая история будет владеть этими изменениями.
Зафиксируйте одно перетаскивание, не растягивая группу отмены на несколько событий
Создайте файл ShapeCanvas.swift. Во время перетаскивания отображаемое положение прямоугольника включает временное смещение. Зафиксированное положение в хранилище меняется только после успешного завершения жеста. Завершите перетаскивание, прежде чем выполнять другое изменение или отмену; этот компактный пример предполагает последовательные взаимодействия и не координирует одновременные команды во время перетаскивания.
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()
}
}
Для фиксации перемещения жест использует итоговое смещение. Он не зависит от того, когда сбрасывается свойство предпросмотра. Apple указывает, что updating(_:body:) сбрасывает состояние жеста, когда жест завершается или отменяется, а onEnded(_:) выполняется только после успешного завершения. Поэтому в этой схеме отменённый жест не фиксирует перемещение и не создаёт новую операцию отмены. Документация Apple по обновлению жеста, Документация Apple по завершению жеста.
Так сохраняются три отдельные границы: обновление предпросмотра меняет временное состояние отображения; успешное завершение запрашивает одно изменение модели; хранилище регистрирует обратную операцию для этого изменения. Если вызывать метод изменения хранилища при каждом обновлении перетаскивания, в течение жеста вместо этого будут регистрироваться промежуточные положения.
Группировка событий по умолчанию в Apple собирает регистрации за проходы цикла обработки событий. Перетаскивание может порождать обновления в нескольких проходах, поэтому одной группировки событий недостаточно, чтобы выразить намерение считать всё перетаскивание одной транзакцией. Здесь фиксация за один раз решает эту проблему без изменения конфигурации общего менеджера. Документация Apple по groupsByEvent.
Для будущей команды, которая синхронно изменяет несколько элементов, можно использовать явную сбалансированную группу отмены, чтобы собрать обратные операции. Начало и конец группы должны находиться внутри этой команды. Если понадобится менять модель непосредственно во время перетаскивания, спроектируйте отдельные операции начала, обновления, фиксации и отмены, предусмотрев взаимодействие с другими командами во время активного изменения. В этом примере используется только предпросмотр, поэтому дополнительная логика транзакций намеренно не помещена в хранилище.
Отдельно проверьте владение, повтор и маршрутизацию по окнам
Выполните следующую последовательность в запущенном приложении. Координаты указаны как пример ожидаемых значений на основе заданного начального состояния и шага кнопки, а не как результаты измерений.
| Действие | Ожидаемое зафиксированное положение или результат |
|---|---|
| Запустить новое окно | (120, 100) |
Нажать Move Right |
(140, 100) |
Выключить Show Editor |
Холст исчезает; положение остаётся (140, 100) |
Нажать Undo |
Положение становится (120, 100), пока холст остаётся скрытым |
Нажать Redo |
Положение становится (140, 100) |
Включить Show Editor |
Прямоугольник появляется в положении после повтора |
Завершить перетаскивание, затем нажать Undo |
Прямоугольник возвращается в положение, которое было до перетаскивания |
Проверка со скрытым редактором позволяет отличить владение моделью от срока жизни представления. Если отмена работает только пока существует определённая панель, проверьте целевой объект, зарегистрированный в менеджере, и объект, который его удерживает. Если отмена работает, а повтор — нет, проверьте, регистрирует ли обратная операция обратное изменение. Если для отмены одного перетаскивания требуется много действий, проверьте, не передаются ли обновления предпросмотра в изменение с отменой в хранилище.
Явные кнопки упрощают проверку пути изменения. Чтобы проверить обычное поведение меню Mac, также протестируйте стандартное меню Edit, когда нужное окно активно. Базовый класс AppKit NSResponder получает свой менеджер отмены от следующего ответчика. Если окно запрашивает менеджер у своего делегата, задокументированной точкой настройки служит windowWillReturnUndoManager(_:); без этой реализации окно создаёт собственный менеджер. Регистрация в не связанном с окном менеджере не обеспечивает нужную маршрутизацию по цепочке ответчиков. Документация Apple по undoManager в NSResponder, Документация Apple по делегату менеджера отмены окна.
Для хранилища также нужно определить чёткую границу его уничтожения. Если вы заменяете хранилище, пока его менеджер продолжает существовать, удалите регистрации этого хранилища, прежде чем освободить его, вызвав removeAllActions(withTarget:) для каждого менеджера, содержащего его действия. Не очищайте историю только потому, что исчез холст: именно здесь реализовано поведение, при котором изменения остаются доступными для отмены после его исчезновения. Документация Apple по очистке действий отмены для конкретного целевого объекта.
В этом примере есть один неподвижный прямоугольник и нет пути удаления. При расширении до коллекции захватывайте стабильные идентификаторы элементов и значения, необходимые для отмены каждой операции. Для отмены удаления нужно восстановить элемент и его предполагаемое положение в порядке; одного старого индекса массива недостаточно, если коллекция может измениться.
В завершённой архитектуре корневое представление отвечает за срок жизни, холст — за временное состояние взаимодействия, а хранилище — за обратимые изменения. Если скрыть холст, эти изменения останутся доступными, потому что методы обратных операций работают с хранилищем, которое продолжает существовать.
Источники
- 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