수명이 긴 모델 스토어에 실행 취소 작업을 등록하고, 뷰는 스토어 메서드를 통해 편집을 요청하도록 합니다. Mac 도형 편집기에서는 드래그 미리보기를 로컬에 유지하고, 놓을 때 한 번만 변경을 적용하며, 다시 실행도 가능하도록 스토어가 역연산을 등록하게 합니다.

이 튜토리얼에서는 가상의 직사각형 하나가 들어 있는 작은 macOS SwiftUI 앱을 만듭니다. 직사각형을 움직이고, 편집기를 숨기고, 이동을 실행 취소한 다음, 편집기를 다시 표시할 수 있습니다. 구체적인 목표는 편집 뷰가 사라져도 모델이 살아 있는 동안 실행 취소 동작을 유지하는 것입니다. 모든 상태는 메모리에만 저장됩니다.

스토어가 실행 취소 작업을 받아야 하는 이유는 무엇인가요?

Apple의 클로저 기반 등록 메서드는 참조 타입의 대상을 요구합니다. 또한 실행 취소 관리자는 그 대상에 대한 미소유 참조를 유지한다고 문서에 나와 있습니다. 따라서 작업을 등록해도 대상의 수명이 보장되지는 않습니다. Apple은 같은 대상에 대한 참조를 별도로 캡처하는 대신 핸들러의 대상 매개변수를 사용하도록 권장합니다. Apple의 실행 취소 등록 문서.

편집 중인 상태를 소유하는 스토어는 적절한 대상입니다. 안정적인 소유자가 스토어를 계속 살아 있게 하고, 임시 뷰는 스토어를 표시하고 변경을 요청합니다. 여기서는 창의 루트 뷰가 @StateObject로 스토어를 소유하고, 제거 가능한 캔버스는 스토어를 관찰합니다. SwiftUI는 상태 객체를 선언한 컨테이너의 수명 동안 유지하지만, 컨테이너의 정체성이 바뀌면 새 인스턴스가 만들어질 수 있습니다. Apple의 StateObject 문서.

“스토어 소유 역연산”은 되돌리기 로직이 어디에 있는지를 설명합니다. 실행 취소 관리자가 여전히 콜백을 기록하고 실행을 예약합니다. 스토어는 이전 값과 이를 복원하는 메서드를 제공합니다. 이 구분은 실행 취소를 받는 객체가 변경된 객체일 수도 있고 그 소유자일 수도 있다는 Apple의 문서상 아키텍처를 따릅니다. Apple의 Undo Manager 안내서.

책임 이 예시의 담당자
확정된 직사각형 위치 모델 스토어
역연산 등록 및 변경 모델 스토어 메서드
스토어 수명 안정적인 창 루트
임시 드래그 이동량 캔버스 제스처 상태
실행 취소 기록 및 실행 환경의 실행 취소 관리자
적용할 관리자 선택 상호작용 경계의 뷰

되돌릴 수 있는 스토어 변경 구현하기

배포 대상이 macOS 13 이상이고 최신 Apple SDK를 사용하는 macOS SwiftUI 앱을 만듭니다. 다음 세 파일이 완전한 예시를 구성합니다. 생성된 앱 진입점과 콘텐츠 뷰를 교체하고, 두 번째 @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이 된다고 설명합니다. The Swift Programming Language: Automatic Reference Counting.

스토어와 변경 메서드는 메인 액터에서 격리됩니다. 이는 현재 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의 제스처 updating 문서, Apple의 제스처 completion 문서.

이렇게 하면 세 가지 경계가 분리됩니다. 미리보기를 갱신하면 일시적인 표시 상태가 바뀌고, 성공적으로 완료되면 모델 변경을 한 번 요청하며, 스토어는 해당 변경의 역연산을 등록합니다. 드래그 업데이트마다 스토어 변경 메서드를 호출하면 제스처가 진행되는 동안 중간 위치들이 계속 등록됩니다.

Apple의 기본 이벤트 그룹화는 런 루프가 한 차례 실행되는 동안 등록된 작업을 모읍니다. 드래그 업데이트는 여러 차례에 걸쳐 발생할 수 있으므로, 이벤트 그룹화만으로는 드래그 전체를 하나의 트랜잭션으로 처리하려는 의도를 표현할 수 없습니다. 여기서는 한 번만 확정하므로 공유 관리자의 설정을 바꾸지 않고도 이 문제를 피합니다. Apple의 groupsByEvent 문서.

나중에 여러 항목을 동기적으로 바꾸는 명령을 추가한다면, 명시적이고 짝이 맞는 실행 취소 그룹으로 역연산을 모을 수 있습니다. 그룹의 시작과 끝을 해당 명령 안에서 처리하세요. 드래그 중 실시간 모델 변경이 필요해지면 begin, update, commit, cancel을 별도 작업으로 설계하고, 진행 중인 편집과 다른 명령이 어떻게 상호작용할지도 정해야 합니다. 여기서는 이 추가 트랜잭션 로직이 스토어에 들어가지 않도록 의도적으로 미리보기 방식만 사용합니다.

소유권, 다시 실행, 창별 전달을 각각 확인하기

실행 중인 앱에서 다음 순서를 따릅니다. 좌표는 제공된 초기 상태와 버튼 증가량을 바탕으로 예상한 예시이며, 측정 결과는 아닙니다.

동작 예상되는 확정 위치 또는 결과
새 창 실행 (120, 100)
Move Right 클릭 (140, 100)
Show Editor 끄기 캔버스가 사라지고 위치는 (140, 100)로 유지됨
Undo 클릭 캔버스가 보이지 않는 상태에서 위치가 (120, 100)이 됨
Redo 클릭 위치가 (140, 100)가 됨
Show Editor 켜기 다시 실행된 위치에 직사각형이 나타남
드래그를 완료한 뒤 Undo 클릭 직사각형이 드래그 전 위치로 돌아감

편집기 숨기기 확인은 모델 소유권과 화면 표시 수명을 구분합니다. 특정 패널이 있을 때만 실행 취소가 된다면 관리자에 등록된 대상과 그 대상을 유지하는 객체를 확인하세요. 실행 취소는 되지만 다시 실행이 되지 않는다면 역연산이 원래 상태로 돌아가는 작업을 등록하는지 확인하세요. 드래그 한 번에 실행 취소를 여러 번 해야 한다면 미리보기 업데이트가 스토어의 실행 취소 가능 변경 메서드에 전달되는지 확인하세요.

명시적인 버튼을 사용하면 변경 경로를 쉽게 살펴볼 수 있습니다. 일반적인 Mac 메뉴 동작을 위해 의도한 창이 활성화된 상태에서 표준 Edit 메뉴도 테스트하세요. AppKit의 기본 responder는 다음 responder에서 실행 취소 관리자를 가져옵니다. 창이 대리자에게 관리자를 요청하는 경우 windowWillReturnUndoManager(_:)가 문서상 사용자화 지점입니다. 이 구현이 없으면 창이 자체 관리자를 만듭니다. 관련 없는 관리자에 등록해도 의도한 responder 체인 전달은 보장되지 않습니다. Apple의 NSResponder undoManager 문서, Apple의 창 실행 취소 관리자 대리자 문서.

스토어에는 명확한 폐기 경계도 필요합니다. 관리자가 계속 존재하는 동안 스토어를 교체한다면 해당 스토어의 작업을 가진 각 관리자에서 removeAllActions(withTarget:)을 사용해 등록을 제거한 뒤 스토어를 해제하세요. 캔버스가 사라졌다는 이유만으로 기록을 지우지 마세요. 캔버스가 사라져도 스토어의 편집을 계속 사용할 수 있게 하는 것이 이 예시의 목표입니다. Apple의 대상별 실행 취소 정리 문서.

이 예시에는 고정된 직사각형 하나만 있으며 삭제 경로는 없습니다. 이를 컬렉션으로 확장할 때는 안정적인 항목 식별자와 각 작업을 되돌리는 데 필요한 값을 캡처하세요. 삭제를 되돌리려면 항목과 의도한 순서를 복원할 수 있어야 합니다. 컬렉션이 바뀔 수 있다면 이전 배열 인덱스만으로는 충분하지 않습니다.

완성된 설계에서는 루트가 수명을 관리하고, 캔버스가 임시 상호작용 상태를 관리하며, 스토어가 되돌릴 수 있는 편집을 관리합니다. 편집기가 숨겨져도 역연산 메서드가 계속 살아 있는 스토어에서 작동하므로 해당 편집을 계속 사용할 수 있습니다.

출처