Mac 앱 안에서 도형을 드래그할 때 제스처 중에는 움직임을 미리 보여 주고, 최종 위치는 하나의 짧은 실행 취소 그룹으로 커밋하세요. 모델과 실행 취소 관리자를 하나의 소유자 아래 두고, 모든 그룹 열기에 대응하는 닫기를 수행하며, 반대 방향 이동도 등록해 다시 실행도 가능하게 하세요.

이 튜토리얼에서는 메모리에만 존재하는 합성 원 하나가 있는 작은 SwiftUI 캔버스를 만듭니다. 구체적인 목표는 이동 업데이트가 몇 번 도착하든 드래그를 마친 뒤 실행 취소를 정확히 한 번만 하면 되게 하는 것입니다. 같은 경계를 AppKit 뷰에서 유지하는 방법도 설명합니다.

드래그 중 실행 취소 그룹을 열어 두면 안 되는 이유

드래그는 시간이 지나면서 진행되는 상호작용입니다. 실행 취소 그룹은 되돌릴 수 있는 작업을 모읍니다. 두 경계의 수명은 같을 필요가 없습니다. Apple 문서에 따르면 groupsByEvent의 기본값은 true이며, 런 루프의 각 패스에 맞춰 작업을 그룹화합니다. 이 동작만으로는 완전한 드래그 트랜잭션이 정의되지 않습니다. 명시적으로 연 그룹도 기존 그룹 안에 중첩될 수 있습니다. Apple 문서의 자동 이벤트 그룹화와 실행 취소 그룹 시작하기를 참고하세요.

SwiftUI의 첫 이동 콜백에서 그룹을 열고 onEnded에서 닫으면 취소 시점에 빈틈이 생깁니다. Apple은 onEnded가 제스처가 성공적으로 끝날 때만 실행된다고 명시합니다. 반면 updating을 통해 유지되는 상태는 제스처가 끝나거나 취소될 때 재설정됩니다. 짝이 맞지 않는 endUndoGrouping()은 예외를 발생시키므로 콜백이 그룹을 열었는지 추측하는 방식도 적절하지 않습니다. onEnded(_:), updating(_:body:), endUndoGrouping()을 참고하세요.

이 캔버스에는 다음 설계를 사용하세요. 이는 문서화된 경계에서 도출한 구현 방식입니다.

상호작용 단계 표시 상태 커밋된 모델 실행 취소 그룹화
드래그 전 저장된 중심 원래 중심 닫힘
이동 업데이트 원래 중심에 임시 이동량을 더한 값 변경 없음 닫힘
성공적으로 완료 최종 중심 한 번 업데이트 열고 동기적으로 닫음
취소 미리 보기 재설정 변경 없음 열지 않음
실행 취소 또는 다시 실행 복원된 중심 되돌릴 수 있는 setter를 통해 업데이트 UndoManager에서 관리

이 방식에서는 미리 보기 프레임마다 영구 편집으로 처리하지 않고 원을 연속적으로 움직일 수 있습니다. 드래그를 마치면 이름이 지정된 모델 작업 하나가 됩니다.

실행 취소 기록의 소유자 정하기

이 예제에서는 각 캔버스가 자체 모델과 비공개 실행 취소 관리자를 가집니다. 이 소유자가 모든 등록을 제어하므로 초기화 중 groupsByEvent = false을 한 번 설정하고, 커밋된 이동마다 명시적으로 그룹화합니다. 자동 그룹화가 비활성화된 경우 실행 취소 전에 그룹을 명시적으로 닫아야 합니다. 예제에서는 커밋 메서드가 반환되기 전에 그룹을 닫습니다. groupsByEvent를 참고하세요.

기존 문서 편집기에는 이미 문서 실행 취소 관리자가 있을 수 있습니다. SwiftUI는 @Environment(\.undoManager)을 통해 컨텍스트에 해당하는 관리자를 제공하며, 컨텍스트가 실행 취소를 지원하지 않으면 값이 nil일 수 있다고 Apple 문서에 나와 있습니다. 이런 구조에서는 계속 문서 관리자를 사용하고 이미 정해진 그룹화 정책을 따르세요. 이 예제의 비공개 관리자 설정을 공유 관리자에 적용하지 마세요. EnvironmentValues.undoManager를 참고하세요.

자동 그룹화가 활성화된 공유 관리자에서는 완료 시 반대 방향 이동 하나를 등록해도 이동마다 기록 항목이 생기는 것을 막을 수 있습니다. 명시적 하위 그룹으로 이동을 묶을 수 있지만, 이를 통해 바깥 그룹과 분리되는 것은 아닙니다. 다른 편집 내용이 같은 바깥 그룹을 공유하면 함께 실행 취소될 수 있습니다. 독립된 최상위 트랜잭션을 보장하려면 공유 기록의 소유자가 조율해야 합니다. 하위 뷰에서 그룹을 닫는 것으로 해결할 수 없습니다.

완전한 SwiftUI 예제 만들기

배포 대상이 macOS 14 이상인 macOS SwiftUI 앱 프로젝트를 만드세요. 생성된 앱 선언과 콘텐츠 뷰를 아래 두 파일로 교체하세요. @main 선언은 하나만 유지하세요. 소유 관계를 명확히 보여 주기 위해 예제에는 로컬 Undo 및 Redo 버튼을 사용합니다. 앱 메뉴와 responder chain 연동은 별도의 통합 단계입니다.

모델은 되돌릴 수 있는 setter를 사용합니다. 현재 위치를 저장하고, 위치를 복원하는 클로저를 등록한 다음 교체할 위치를 적용합니다. 실행 취소 중 클로저가 실행되면 같은 setter가 반대 작업을 다시 실행으로 등록합니다. Apple은 실행 취소 작업 등록하기에서 이 상호 등록 패턴을 보여 줍니다. 최신 문서에서는 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는 조기 반환이나 오류 발생을 포함해 해당 범위가 끝날 때 지연된 작업을 실행합니다. 서로 별도로 호출되는 제스처 콜백 사이에 걸쳐 실행될 수는 없습니다. 이렇게 짝을 맞추면 일반적인 제어 흐름에서 모델의 그룹이 균형을 유지합니다. 제어 흐름 — 지연된 작업을 참고하세요.

실행 취소 클로저는 self을 캡처하는 대신 target 매개변수를 사용합니다. 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)으로 바뀝니다.

실행 취소는 등록된 setter를 (120, 100)와 함께 호출합니다. setter는 이 위치를 복원하기 전에 (160, 120)을 저장하고, 그 위치를 다시 실행으로 등록합니다. 실행 취소 콜백 안에서 등록을 억제하지 마세요. 이 반대 작업 등록 덕분에 다시 실행이 유지됩니다. Apple은 실행 취소 작업 등록하기에서 이 동작을 설명합니다.

코드는 되돌릴 수 있는 setter 안에 추가 명시 그룹을 만들지 않습니다. 기록을 재생하는 동안 그룹화는 UndoManager가 제어합니다. Apple 문서에 따르면 redo()은 재생된 작업을 실행 취소 스택에 하나의 그룹으로 기록합니다. 작업 이름은 합성 레이블 Move Shape를 사용해 setActionName로 지정합니다. redo()와 setActionName(_:)를 참고하세요.

Mac 앱에서 다음 동작을 확인하세요.

  1. 방향을 여러 번 바꾸며 드래그를 한 번 완료합니다. Undo를 한 번 실행하면 시작 위치로 돌아가야 합니다.
  2. Redo를 선택합니다. 도형이 완료된 드래그의 최종 위치로 돌아가야 합니다.
  3. 드래그를 두 번 따로 완료합니다. 실행 취소 두 번으로 두 이전 위치가 역순으로 복원되어야 합니다.
  4. 인식된 드래그를 시작 위치에서 마칩니다. 변경 없음 검사가 기록 항목을 만들지 않아야 합니다.
  5. 상위 앱에서 취소 또는 뷰 제거 경로를 실행합니다. 커밋된 이동 없이 미리 보기가 사라져야 합니다.
  6. 이동을 실행 취소한 다음 다른 이동을 완료합니다. 이전 다시 실행 분기는 사라져야 합니다. Apple 문서에 따르면 새로운 실행 취소 등록은 다시 실행 스택을 비웁니다.

마지막 동작은 Apple의 Undo Manager 아키텍처에 따른 것입니다. 위 좌표 계산은 작은 결정적 Python 계산으로 확인했습니다. 이 환경에는 Swift 컴파일러나 macOS SDK가 없어 Swift 소스를 검토했지만 컴파일하거나 실행하지는 않았습니다.

AppKit 캔버스에도 같은 경계 적용하기

기존 NSView에서는 AppKit의 이벤트 순서를 유지하세요. Apple의 최신 참조 문서에서는 mouseDragged(with:)을 마우스 왼쪽 버튼을 누른 상태에서의 이동으로, mouseUp(with:)을 해당 버튼을 놓는 동작으로 정의합니다. 보관된 이벤트 안내서에서는 커스텀 뷰에 필요한 세 메서드의 순서와 좌표 변환을 설명합니다. mouseDragged(with:), mouseUp(with:), 마우스 이벤트 처리하기를 참고하세요.

같은 설계를 다음 메서드에 적용하세요.

  • mouseDown(with:)에서 합성 도형의 히트 테스트를 하고, 시작 중심과 캔버스 좌표로 변환한 포인터 위치를 저장합니다.
  • mouseDragged(with:)에서 저장된 포인터 위치와의 차이를 계산하고 임시 미리 보기를 다시 그립니다.
  • mouseUp(with:)에서 최종 이동량을 계산하고 추적 상태를 지운 다음 모델의 동기 커밋 메서드를 한 번 호출합니다.
  • 캔버스 소유자의 취소 또는 해제 경로에서 미리 보기를 버리고 커밋 없이 추적 상태를 지웁니다.

상호작용을 처리하는 이벤트 소유자는 하나로 정하세요. SwiftUI가 AppKit 캔버스를 포함한다면 SwiftUI 제스처와 AppKit 마우스 처리기 중 하나를 골라 이동과 커밋을 담당하게 하세요. 두 곳에서 같은 이동을 등록하면 작업이 중복됩니다. 뒤집힌 좌표계나 캔버스 변환을 포함해 그리기 좌표를 일관되게 유지하세요.

Apple의 보관된 안내서는 세 메서드로 추적하는 동안 키보드 명령도 전달될 수 있다고 설명합니다. 따라서 메뉴 연동도 로컬 버튼과 같은 활성 드래그 정책을 따라야 합니다. 문서 기록은 문서 소유자에게 두고, 메뉴 동작이 해당 소유자의 관리자를 사용하도록 하세요.

다른 호출자가 소유한 그룹을 닫지 않고 그룹화 문제 진단하기

드래그가 실행 취소 단계를 많이 만들면 이동 업데이트 중 모델 setter가 등록하는지 확인하세요. 해당 등록을 최종 커밋으로 옮기세요. 여러 드래그가 함께 실행 취소되면 관리자의 자동 그룹화 정책과 소유자가 만든 바깥 그룹을 살펴보세요. 실행 취소 후 다시 실행을 사용할 수 없다면 복원 경로에서 반대 작업을 등록했는지 확인하세요.

그룹화 중 예외가 발생하면 닫기 호출 횟수를 바꾸기 전에 소유 관계부터 확인하세요. Apple 문서에 따르면 groupingLevel == 0은 열린 그룹이 없다는 뜻이고, undo()는 둘 이상의 그룹이 열린 상태에서 예외를 발생시킵니다. 그룹화 수준이 0이 될 때까지 닫는 반복문은 다른 호출자의 그룹까지 닫을 수 있습니다. 그룹화 수준은 진단에 활용하고 소유자의 계약을 지키세요. groupingLevel과 undo()를 참고하세요.

이 메모리 내 캔버스에서 유효한 경계는 커밋된 이동입니다. 자유롭게 미리 보고, 한 번 커밋하고, 되돌릴 수 있는 변경을 등록한 다음 반환 전에 그룹을 닫으세요. 그러면 취소, 이벤트 전달, 기록 소유권을 명확히 유지하면서 완료된 드래그마다 실행 취소 단계를 하나씩 만들 수 있습니다.

출처