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

В этом руководстве создаётся небольшой холст SwiftUI с одним искусственным кругом в памяти. Конкретная задача — добиться того, чтобы после завершения перетаскивания требовалась ровно одна отмена независимо от числа полученных обновлений перемещения. Также объясняется, как сохранить те же границы в представлении AppKit.

Почему группу отмены не следует оставлять открытой на время перетаскивания

Перетаскивание — это взаимодействие, разворачивающееся во времени. Группа отмены объединяет обратимые операции. Эти границы не обязаны совпадать по длительности. Apple указывает, что groupsByEvent по умолчанию имеет значение true и группирует операции по циклам цикла выполнения; такое поведение не задаёт транзакцию целого перетаскивания. Явные группы также могут вкладываться в уже существующие. См. документацию Apple об автоматической группировке событий и начале групп отмены.

Открытие группы в первом обратном вызове перемещения SwiftUI и её закрытие в onEnded оставляет промежуток, в котором жест может быть отменён. Apple прямо указывает, что onEnded выполняется только при успешном завершении жеста. Для сравнения, состояние, поддерживаемое через updating, сбрасывается при завершении или отмене жеста. Несоответствующий вызов endUndoGrouping() вызывает исключение, поэтому также не подходит попытка угадать, открывал ли обратный вызов группу. См. onEnded(_:), updating(_:body:) и endUndoGrouping().

Для этого холста используйте следующую схему. Это вариант реализации, выбранный на основе описанных в документации границ:

Этап взаимодействия Отображаемое состояние Зафиксированная модель Группировка отмены
До перетаскивания Сохранённый центр Исходный центр Закрыта
Обновления перемещения Исходный центр плюс временное смещение Без изменений Закрыта
Успешное завершение Итоговый центр Обновлена один раз Открывается и закрывается синхронно
Отмена Предпросмотр сбрасывается Без изменений Не открывалась
Отмена или повтор Восстановленный центр Обновлена через обратимый метод установки Управляется UndoManager

Так круг может непрерывно двигаться, не превращая каждый кадр предпросмотра в постоянное изменение. Завершённое перетаскивание становится одной именованной операцией модели.

Выберите владельца истории отмены

В примере у каждого холста есть собственная модель и закрытый менеджер отмены. Поскольку этот владелец контролирует каждую регистрацию, он один раз задаёт groupsByEvent = false при инициализации и явно группирует каждое зафиксированное перемещение. Apple требует явно закрывать группы перед отменой, если автоматическая группировка отключена. В примере группа закрывается до возврата из метода фиксации. См. groupsByEvent.

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

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

Создайте полный пример на SwiftUI

Создайте проект приложения SwiftUI для macOS с целевой версией macOS 14 или новее. Замените сгенерированные объявление приложения и представление содержимого двумя файлами ниже. Оставьте только одно объявление @main. В примере используются локальные кнопки Undo и Redo, чтобы явно показать владение; интеграция с меню приложения и цепочкой обработчиков — отдельный этап.

В модели используется обратимый метод установки: сохраните текущую позицию, зарегистрируйте замыкание для её восстановления, затем примените новое значение. Когда это замыкание выполняется при отмене, тот же метод установки регистрирует обратную операцию для повтора. Apple показывает такой шаблон взаимной регистрации в статье Registering Undo Operations. В актуальной документации также указано, что UndoManager и его обработчик регистрации изолированы главным актором, поэтому модель в примере помечена @MainActor. См. UndoManager.

Поместите следующую полную модель в ShapeModel.swift:

import Foundation
import Combine

@MainActor
final class ShapeModel: ObservableObject {
    @Published private(set) var center = CGPoint(x: 120, y: 100)
    @Published private(set) var canUndo = false
    @Published private(set) var canRedo = false

    private let history: UndoManager

    init() {
        let manager = UndoManager()
        manager.groupsByEvent = false
        history = manager
    }

    func commitDrag(translation: CGSize) {
        let destination = CGPoint(
            x: center.x + translation.width,
            y: center.y + translation.height
        )
        guard destination != center else { return }

        // This owner expects no caller-owned group.
        precondition(history.groupingLevel == 0)
        history.beginUndoGrouping()
        defer {
            history.endUndoGrouping()
            refreshAvailability()
        }

        replaceCenter(with: destination)
    }

    private func replaceCenter(with destination: CGPoint) {
        let previous = center
        guard previous != destination else { return }

        history.registerUndo(withTarget: self) { target in
            target.replaceCenter(with: previous)
        }
        history.setActionName("Move Shape")
        center = destination
    }

    func undo() {
        precondition(history.groupingLevel == 0)
        guard history.canUndo else { return }
        history.undo()
        refreshAvailability()
    }

    func redo() {
        precondition(history.groupingLevel == 0)
        guard history.canRedo else { return }
        history.redo()
        refreshAvailability()
    }

    private func refreshAvailability() {
        canUndo = history.canUndo
        canRedo = history.canRedo
    }
}

defer намеренно находится в той же синхронной области видимости, что и beginUndoGrouping(). Swift выполняет отложенные действия при выходе из этой области, в том числе при досрочном возврате или выброшенной ошибке. Такое действие не может охватывать отдельно вызываемые обратные вызовы жеста. Благодаря этой паре группа модели закрывается при обычном выходе из потока управления. См. Поток управления — отложенные действия.

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

Поместите приложение и холст в ShapeUndoApp.swift:

import SwiftUI

@main
@MainActor
struct ShapeUndoApp: App {
    var body: some Scene {
        WindowGroup {
            ShapeCanvas()
        }
    }
}

@MainActor
struct ShapeCanvas: View {
    @StateObject private var model = ShapeModel()
    @GestureState private var previewTranslation: CGSize? = nil

    private var drag: some Gesture {
        DragGesture(
            minimumDistance: 1,
            coordinateSpace: .named("board")
        )
        .updating($previewTranslation) { value, state, _ in
            state = value.translation
        }
        .onEnded { value in
            model.commitDrag(translation: value.translation)
        }
    }

    var body: some View {
        let translation = previewTranslation ?? .zero
        let isDragging = previewTranslation != nil

        VStack(spacing: 16) {
            ZStack(alignment: .topLeading) {
                Color.clear

                Circle()
                    .fill(Color.blue)
                    .frame(width: 44, height: 44)
                    .position(
                        x: model.center.x + translation.width,
                        y: model.center.y + translation.height
                    )
                    .gesture(drag)
            }
            .frame(width: 420, height: 300)
            .background(Color.gray.opacity(0.12))
            .coordinateSpace(name: "board")
            .clipped()

            HStack {
                Button("Undo") {
                    guard previewTranslation == nil else { return }
                    model.undo()
                }
                .disabled(isDragging || !model.canUndo)

                Button("Redo") {
                    guard previewTranslation == nil else { return }
                    model.redo()
                }
                .disabled(isDragging || !model.canRedo)
            }
        }
        .padding(20)
    }
}

Жест использует именованную систему координат холста, поэтому смещение измеряется относительно доски, а не движущегося круга. Apple определяет translation как полное смещение от начала жеста. Присваивайте его предпросмотру; не прибавляйте его снова и снова к модели во время движения. См. DragGesture и translation.

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

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

Проверьте, что одно перетаскивание даёт одну отмену и работает повтор

Посмотрим на арифметику на условных координатах. При начальной позиции (120, 100) накопленные смещения (10, 5), (25, 12) и (40, 20) дают центры предпросмотра (130, 105), (145, 112) и (160, 120). Зафиксированный центр остаётся равным (120, 100) до завершения, после чего один раз изменяется на (160, 120).

Отмена вызывает зарегистрированный метод установки с аргументом (120, 100). Перед восстановлением метод установки сохраняет (160, 120) и регистрирует эту позицию для повтора. Не отключайте регистрацию внутри обратного вызова отмены: именно эта взаимная регистрация сохраняет возможность повтора. Apple описывает это поведение в статье Registering Undo Operations.

Код не создаёт дополнительную явную группу внутри обратимого метода установки. Во время воспроизведения истории группировкой управляет UndoManager; Apple указывает, что redo() записывает воспроизведённые операции в стек отмены одной группой. Имя действия задаётся через setActionName с условной меткой Move Shape. См. redo() и setActionName(_:).

Проверьте в приложении для Mac следующее:

  1. Завершите одно перетаскивание с несколькими изменениями направления. Одна операция Undo должна вернуть фигуру в исходную позицию.
  2. Выберите Redo. Фигура должна вернуться в конечную позицию после перетаскивания.
  3. Завершите два отдельных перетаскивания. Две операции отмены должны восстановить две предыдущие позиции в обратном порядке.
  4. Завершите распознанное перетаскивание в исходной позиции. Проверка отсутствия изменений не должна создавать запись истории.
  5. Проверьте сценарий отмены или удаления представления из содержащего его приложения. Предпросмотр должен исчезнуть без зафиксированного перемещения.
  6. Отмените перемещение, затем выполните другое. Предыдущая ветка повтора должна исчезнуть: Apple указывает, что новая регистрация отмены очищает стек повтора.

Последнее поведение следует из описанной Apple архитектуры менеджера отмены. Расчёт координат выше был проверен небольшим детерминированным вычислением на Python. Исходный код Swift был изучен, но не компилировался и не запускался: в этой среде нет компилятора Swift и SDK macOS.

Примените те же границы к холсту AppKit

Для существующего NSView сохраните последовательность событий AppKit. В актуальном справочнике Apple mouseDragged(with:) определяется как перемещение при нажатой левой кнопке мыши, а mouseUp(with:) — как её отпускание. В архивном руководстве по событиям описана последовательность из трёх методов и преобразование координат, необходимое для пользовательского представления. См. mouseDragged(with:), mouseUp(with:) и Обработка событий мыши.

Соотнесите эту схему с указанными методами:

  • В mouseDown(with:) найдите искусственную фигуру при проверке попадания и сохраните её исходный центр и положение указателя, преобразованное в координаты холста.
  • В mouseDragged(with:) вычисляйте смещение относительно сохранённого положения указателя и перерисовывайте временный предпросмотр.
  • В mouseUp(with:) вычислите итоговое смещение, очистите состояние отслеживания и один раз вызовите синхронный метод фиксации модели.
  • В обработчике отмены или завершения работы владельца холста отбросьте предпросмотр и очистите состояние отслеживания, не фиксируя изменение.

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

В архивном руководстве Apple также объясняется, что при обработке событий тремя методами могут выполняться команды клавиатуры. Поэтому интеграция меню должна учитывать ту же политику активного перетаскивания, что и локальные кнопки. Историю документа храните у его владельца и следите за тем, чтобы команды меню обращались к менеджеру этого владельца.

Диагностируйте ошибки группировки, не закрывая чужую группу

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

Если группировка вызывает исключение, прежде чем менять количество вызовов закрытия, проверьте владельца. Apple указывает, что groupingLevel == 0 означает отсутствие открытых групп, а undo() вызывает исключение, если остаётся открытой более чем одна группа. Цикл, закрывающий группы до нулевого уровня, может закрыть группы, принадлежащие другому вызывающему коду. Используйте уровень для диагностики и соблюдайте контракт владельца. См. groupingLevel и undo().

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

Источники