Для небольшого редактора фигур на Mac, работающего в памяти, начните со снимков состояния, представленного значениями. Используйте обратные команды для частых и узконаправленных правок, если экономия на объёме данных оправдывает дополнительную логику. Оба подхода требуют одного стабильного владельца и чёткой границы фиксации для каждого действия пользователя.

Это руководство посвящено одной задаче: реализации предсказуемой отмены и повтора перемещения, удаления и перестановки синтетических фигур в собственном редакторе на Swift. Приведённая ниже система принятия решений — инженерная рекомендация, основанная на API Apple; Apple не предписывает универсальный выбор между снимками и командами.

Что должна запоминать каждая операция отмены?

Снимок сохраняет предыдущее редактируемое состояние, например упорядоченный массив фигур. Отмена восстанавливает этот массив. Обратная команда сохраняет операцию, необходимую для отмены конкретной правки, например возвращение центра одной фигуры к прежним координатам.

Оба представления работают через UndoManager из Foundation. Он записывает операции для объекта или его владельца, но автоматически не отслеживает произвольные присваивания в вашей модели. Методы изменения данных должны регистрировать соответствующие операции восстановления. UndoManager

Определите редактируемое состояние до выбора формата истории. Для этого редактора оно включает идентификаторы фигур, координаты и порядок элементов массива. Индикаторы наведения и предпросмотр перетаскивания храните отдельно. Явно решите, должна ли история включать выделение: восстановление выделения может помочь понять удаление, но при этом отмена изменит текущее выделение.

Выбирайте представление для каждой операции по этой таблице, а не требуйте, чтобы весь редактор использовал только одно:

Операция или условие Рекомендуемое представление Что сохранить
Небольшой холст; одновременно меняются несколько свойств Снимок Полное предыдущее редактируемое значение
Перемещение одной фигуры Обратный сеттер Стабильный идентификатор фигуры и прежний центр
Удаление одной фигуры Сначала снимок; при необходимости — обратная команда Для обратной команды: все данные удалённой фигуры и её исходная позиция
Перестановка нескольких фигур Снимок, если обратная операция не очевидна Предыдущий порядок и все затронутые свойства
Измеренные затраты памяти на историю слишком велики Точечные обратные команды Всё необходимое для точного восстановления

Полезный вопрос: можете ли вы перечислить всё, что должна восстановить обратная команда? Если при удалении фигуры меняются также выделение и порядок, одного удалённого идентификатора недостаточно. Снимок часто проще проверять, когда операция затрагивает несколько связанных значений.

Когда снимок действительно независим?

Структуры и массивы Swift имеют семантику значений. Поэтому массив фигур, состоящий только из типов-значений, может представлять прежнее состояние независимо от последующих изменений. Однако при копировании массива экземпляров классов сохраняются ссылки на эти экземпляры; изменение экземпляра может изменить и то, что наблюдает ваш предполагаемый снимок. Обёртывание таких ссылок в структуру эту проблему не устраняет. Value And Reference Types In Swift

Для небольшого редактора храните историю в виде простых данных-значений: идентификаторов, координат, размеров и других редактируемых атрибутов. Представления и изменяемые объекты рендеринга оставьте за пределами снимка. Если в рабочей модели используются классы, создайте отдельное представление-значение со всеми атрибутами, необходимыми для восстановления.

Не делайте вывод о фиксированной стоимости памяти только по слову «снимок». Измеряйте объём хранимой истории и задержку правок на типичных синтетических холстах. Для наглядной оценки объёма данных: 100 записей, каждая из которых хранит 1 000 записей фигур, соответствуют 100 000 вхождений записей; 100 обратных записей для одной фигуры хранят только 100 прежних координат центра и идентификаторов. Эти количества не являются измерением в байтах и ничего не утверждают о распределении памяти в Swift.

Задайте глубину истории осознанно. Apple указывает, что levelsOfUndo ограничивает число групп отмены верхнего уровня, а значение по умолчанию, равное нулю, означает отсутствие ограничения. Лимит групп полезен, но он не задаёт предел в байтах: пакетный снимок и правка одной координаты могут удерживать совершенно разный объём данных. levelsOfUndo

Как снимки и обратные команды обеспечивают повтор?

Метод восстановления должен зарегистрировать обратное восстановление до изменения модели. Когда отмена вызовет этот метод, его регистрация обеспечит повтор. Для снимка перед восстановлением прежнего состояния сохраните текущее установленное состояние. Для команды перемещения перед установкой сохранённого центра сохраните текущий центр.

В архивном руководстве Apple по архитектуре отмены явно описан этот принцип взаимной регистрации: сеттер, вызванный при отмене, регистрирует значение, необходимое для повтора. В примерах используется Objective-C; полный пример на Swift ниже использует актуальный API с замыканиями. Registering Undo Operations

Не подавляйте регистрацию только потому, что isUndoing имеет значение true. В этой схеме так можно удалить регистрацию, создающую повтор. Также не выполняйте новое относительное перемещение при восстановлении позиции: сохранение точного прежнего центра ясно задаёт, что именно нужно восстановить.

Полный пример на уровне модели

Эта самостоятельная программа для macOS сочетает удаление на основе снимка с обратным сеттером позиции. Она использует только синтетические значения в памяти и Foundation, без зависимостей от хранения данных или пользовательского интерфейса. Сохраните её как EditorUndo.swift и скомпилируйте с помощью актуальных инструментов Swift 6 и SDK macOS:

swiftc -parse-as-library EditorUndo.swift -o EditorUndo
./EditorUndo

В программе намеренно используется отдельный менеджер, а группировка по событиям отключена, чтобы утверждения не зависели от работающего цикла событий приложения. Для каждой исходной правки задаётся явная группа. Методы восстановления не открывают дополнительные группы при отмене или повторе. Apple требует явно закрывать группы перед отменой, если автоматическая группировка отключена. 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")
    }
}

Ожидаемый вывод: Undo and redo checks passed. Пример был просмотрен, но здесь не запускался, поскольку в исследовательской среде не было доступно компилятора Swift. Он показывает основу истории изменений; интерфейсу на SwiftUI также нужна наблюдаемая модель или адаптер для обновления представлений.

В замыкании используется параметр target вместо прямого захвата редактора. Apple указывает, что цель регистрации должна быть ссылочным типом, менеджер хранит на неё не владеющую ссылку, а использование параметра замыкания позволяет избежать цикла удержания через захваченную цель. registerUndo(withTarget:handler:)

Предусловия показывают нарушения предположений модели вместо молчаливого пропуска операции отмены. На уровне обработки команд следует отклонять недопустимую правку до регистрации и сообщать о сбое средствами приложения. Отсутствие идентификаторов при воспроизведении истории означает, что модель была изменена в обход ожидаемой последовательности истории.

Как превратить перетаскивание в одно отменяемое действие?

Перетаскивание создаёт множество обновлений, тогда как пользователь ожидает отмены одного завершённого перемещения. Автоматическая группировка Apple следует за проходами цикла событий, поэтому её нельзя считать границей всего жеста. Явные группы могут объединить регистрации, но их границы всё равно нужно задавать осознанно. groupsByEvent, beginUndoGrouping()

Для этого редактора используйте последовательность «предпросмотр и фиксация»:

  1. В начале перетаскивания сохраните идентификатор цели и зафиксированный исходный центр как базовое состояние жеста.
  2. Используйте @GestureState и updating(_:body:) для временного смещения. Отображайте фигуру в точке, полученной прибавлением этого смещения к базовой позиции, не изменяя зафиксированные элементы.
  3. При успешном onEnded(_:) вычислите конечный центр по последнему значению жеста и один раз вызовите отменяемый сеттер модели.
  4. Не регистрируйте операцию, если конечный центр совпадает с зафиксированным.
  5. При отмене отбросьте предпросмотр и оставьте зафиксированную модель без изменений.

Эта последовательность следует документированному в SwiftUI различию: состояние жеста сбрасывается при завершении или отмене жеста, а onEnded(_:) выполняется только при успешном завершении. Поэтому действия очистки, которые должны выполняться и после отмены, нельзя поручать только onEnded(_:). Adding interactivity with gestures

Во время предпросмотра оставляйте зафиксированное состояние редактора неизменным. Простая политика — отключать другие изменения холста и отмену действий на нём, пока активно перетаскивание, а затем снова включать их после завершения или отмены. В противном случае определите, как промежуточная правка аннулирует сохранённое базовое состояние, прежде чем принимать фиксацию перетаскивания.

Если другой части редактора нужно постоянно видеть обновляемые зафиксированные координаты, режим только для предпросмотра может не подойти. Тогда реализуйте явную транзакцию жеста: сохраните исходное состояние, применяйте промежуточные обновления без отдельных записей истории и регистрируйте одно восстановление при успехе. При отмене нужно восстановить исходное состояние. Не оставляйте общую группу отмены открытой на время произвольных обратных вызовов или асинхронной работы.

Кто должен владеть моделью и менеджером отмены?

Для каждого независимо редактируемого холста используйте одного стабильного владельца редактора. Он хранит зафиксированные элементы и служит целью операций истории. Выполняйте изменения модели и взаимодействия с менеджером отмены на главном акторе: в актуальной документации Apple UndoManager и обработчик-замыкание помечены как изолированные главным актором. UndoManager

В SwiftUI получайте менеджер представления через @Environment(\.undoManager). Передавайте его стабильному слою редактора или команд, а не создавайте новый менеджер при каждом вычислении представления. Значение среды может отсутствовать: Apple указывает, что в неподдерживаемых контекстах возвращается nil, и допускает пропуск регистрации отмены в таком случае. Редактирование по-прежнему возможно, но интерфейс не должен сообщать, что отмена доступна. EnvironmentValues.undoManager

Для холста AppKit получайте менеджер через соответствующий ответчик цепочки событий. Базовая реализация NSResponder.undoManager обращается к следующему ответчику. Поэтому, если вы создаёте менеджер отдельно, его нужно явно встроить в контекст цепочки ответчиков холста, чтобы стандартные команды меню могли до него добраться. Одного хранения менеджера в модели для этого недостаточно. NSResponder.undoManager

Настройки группировки из самостоятельного примера относятся к его отдельному менеджеру. Не переносите их на менеджер, полученный из среды, если вы не отвечаете за политику группировки всех его участников. В интерфейсе регистрируйте завершённую правку холста, соблюдая существующую политику событий контекста; объединяйте несколько синхронных подопераций, если они составляют одно действие.

Перед удалением цели редактора из памяти удалите ожидающие регистрации в контролируемой точке жизненного цикла. Apple предоставляет removeAllActions(withTarget:) для удаления операций отмены и повтора, адресованных этой цели. Очистка всего менеджера затронет и других участников, если менеджер общий. removeAllActions(withTarget:)

Что проверить перед окончательным выбором схемы?

Проверьте короткую последовательность, переходящую между представлениями: переместите фигуру, удалите другую, отмените обе операции, затем повторите обе. Проверяйте значения, идентификаторы и порядок после каждого шага. Добавьте отменённое перетаскивание, перетаскивание с возвратом в исходную позицию и два независимых окна редактора. Каждый случай проверяет отдельную границу: данные восстановления, завершение жеста, обработку пустой операции или владение.

Рассматривайте массовую замену холста как отдельную операцию. Либо сделайте её отменяемой, предусмотрев достаточно данных для восстановления, либо установите новую исходную точку после намеренной очистки устаревшей истории. Снимок, в котором отсутствуют редактируемые поля, и обратная команда, не находящая свою цель, — неполные реализации.

Для небольшого холста на основе типов-значений начните со снимков, оставьте одного стабильного владельца и фиксируйте каждый жест один раз. Вводите обратные команды там, где данные восстановления понятны и измерения оправдывают дополнительную логику. Так формат истории можно изменить, не меняя смысл одного действия пользователя.

Источники