[iOS | SwiftUI] Undo And Redo

someng·2025년 8월 21일
0

iOS

목록 보기
37/39

SwiftUI 매거진을 보다가 발견한 Undo Redo 기능인데 나중에 유용하게 사용할까봐 정리한 번역본이다.
출처: SwiftUI: Undo And Redo


시작하며

저는 직관적으로 undoredo 기능을 정말 좋아하는데요, 아마도 항상 ‘되돌릴 수 있는 선택지’를 갖고 싶기 때문인 것 같아요!
이번 글에서는 SwiftUI 앱에서 UndoManager 클래스를 사용해서 어떻게 undoredo 기능을 구현할 수 있는지 살펴보겠습니다.


설정하기 (Set Up)

간단한 카운터 예제를 사용해 설명해 보겠습니다.

import SwiftUI   

struct UndoRedoDemo: View {  
    @Environment(\.undoManager) private var undoManager  

    @State private var count: Int = 0  

    var body: some View {  
        VStack(spacing: 36) {  
            HStack(spacing: 36) {  

                Text("\(count)")  

                HStack {  
                    Button(action: {  
                        count += 1  
                    }, label: {  
                        Image(systemName: "plus.circle.fill")  
                    })  

                    Button(action: {  
                        count -= 1  
                    }, label: {  
                        Image(systemName: "minus.circle.fill")  
                    })  
                }  

                HStack(spacing: 24) {  
                    Button(action: {  
                        undoManager?.undo()  
                    }, label: {  
                        Text("Undo")  
                    })  

                    Button(action: {  
                        undoManager?.redo()  
                    }, label: {  
                        Text("Redo")  
                    })  
                }  
            }  
        }  
    }  
}

여기서는 SwiftUI의 Environment 값인 \.undoManager를 사용하여 뷰의 undo 작업을 등록 및 수행할 수 있는 UndoManager 인스턴스를 가져옵니다. 물론 직접 UndoManager()를 생성해 사용할 수도 있지만, 그 경우 자식 뷰에 수동으로 전달해야 한다는 점을 유의하세요.

하지만 이렇게만 해서는, Undo 또는 Redo 버튼을 눌러도 아무런 동작이 발생하지 않습니다—왜냐하면 아직 등록할 작업이 없기 때문이죠.


Undo 등록하기 (Register Undos)

사용자의 동작은 자동으로 undo 대상으로 등록되지 않습니다. undo 가능하게 만들려면 다음 메서드를 사용해야 합니다:

  • registerUndo(withTarget:handler:)
  • 또는 registerUndo(withTarget:selector:object:)

여기서는 첫 번째 메서드를 사용합니다. 이 메서드는 다음 두 매개변수를 받습니다:

  • target: undo 작업의 대상. UndoManager는 retain cycle을 피하기 위해 이 객체에 대해 unowned 참조를 유지합니다.
  • handler: undo될 때 실행될 클로저. 인자로 target이 전달됩니다.

대상 클래스 만들기 (Create Target Class)

targetclass 타입이어야 하므로, count를 감싸는 간단한 Observable 클래스를 만들어 줍니다.

@Observable   
private class Counter {  
    var count = 0  
}

이제 이 Counter 클래스에 undo 동작을 등록할 수 있습니다.


Undo 등록 구현

Counter 클래스를 사용하고, 버튼 액션에서 undo를 등록해 보겠습니다.

@State private var counter = Counter()

Button(action: {
    let current = counter.count
    undoManager?.registerUndo(withTarget: counter, handler: { target in
        counter.count = current
    })
    counter.count += 1
}, label: {
    Image(systemName: "plus.circle.fill")
})

Button(action: {
    let current = counter.count
    undoManager?.registerUndo(withTarget: counter, handler: { target in
        counter.count = current
    })
    counter.count -= 1
}, label: {
    Image(systemName: "minus")
})

이렇게 하면 Undo는 정상적으로 작동하지만—아쉽게도 Redo는 작동하지 않습니다. 왜냐하면 Redo를 위해서는 다시 registerUndo를 호출해야하기 때문입니다!


Redo 구현하기 (Redos)

Apple 문서에는 이런 설명이 있습니다:

“undo 작업을 등록한 후, undo()를 호출하여 마지막 작업 상태로 되돌릴 수 있습니다. 동시에 UndoManager는 되돌린 작업을 다시 redo()할 수 있도록 자동으로 저장합니다.”

그런데 실제로는 자동으로 되지 않습니다! redo()가 작동하려면, undo 클로저 내부에서 다시 registerUndo를 호출해줘야 합니다. 그래서 undoredoundo가 반복 작동 가능하게 만들기 위해선, 아래 방식처럼 구현해야 합니다.

@Observable   
private class Counter {  
    var count = 0 {  
        didSet {  
            self.undoManager?.registerUndo(withTarget: self) { target in  
                target.count = oldValue  
            }  
        }  
    }  

    var undoManager: UndoManager?  
}

이제 뷰에서는 counter.count += 1 또는 -= 1 처럼 간단히 동작하도록 구현하고, onAppear에서 undoManager를 연결해 줍니다:

.onAppear {  
    counter.undoManager = undoManager  
}

이렇게 하면 undoredo가 원하는 대로 작동하게 됩니다.


버전 2: 직접 제어 방식 (Another Version)

didSet 방식은 상태 변경이 있을 때마다 자동으로 undo 작업이 등록됩니다. 하지만 때로는 사용자 인터랙션에 의해서만 undo되도록 하고 싶을 수도 있습니다. 이럴 땐 아래와 같이 제어할 수 있습니다:

@Observable   
private class Counter {  
    var count = 0  
    var undoManager: UndoManager?  

    func setCount(_ newValue: Int)  {  
        let oldValue = count  
        undoManager?.registerUndo(withTarget: self) { target in  
            target.setCount(oldValue)  
        }  
        count = newValue  
    }  
}

그리고 버튼에서는 setCount()를 호출해 명시적으로 undo 동작을 등록하거나, 그렇지 않을 땐 count를 직접 설정합니다:

Button(action: {
    counter.setCount(counter.count + 1)
}, label: {
    Image(systemName: "plus.circle.fill")
})

이 방법은 UndoManagerSendable이 아닌 이유로, 함수 매개변수로 전달하지 않는 방식이기도 합니다.


+0.1: 버튼 활성화/비활성화 (Disable Buttons)

Undo 또는 Redo가 가능한 경우에만 버튼이 활성화되도록 만들려면, 다음처럼 .disabled(...)를 추가할 수 있습니다:

.disabled(undoManager?.canUndo == false)
.disabled(undoManager?.canRedo == false)

이렇게 하면, 현재 실행 가능한 상태에 따라 버튼이 적절히 활성화됩니다.


+0.2: 그룹핑 제어 (Grouping)

UndoManager는 기본적으로 실행 루프 이벤트마다 자동으로 undo 그룹을 생성합니다. 즉, 여러 액션이 하나의 그룹으로 묶여 한 번에 undo되는 경우가 생길 수 있습니다. 이 동작은 예측하기 어려울 수 있습니다.

원하지 않을 경우, 직접 그룹을 관리할 수 있습니다:

  • undoManager.groupsByEvent = false
  • registerUndo 전에 undoManager.beginUndoGrouping()
  • 등록 후 undoManager.endUndoGrouping()

주의! 반드시 registerUndo 전에 그룹을 시작하고, undo() 호출 전에 그룹을 닫아야 예외가 발생하지 않습니다.


최종 코드 (Final Code)

Version 1

import SwiftUI  

@Observable   
private class Counter {  
    var count = 0 {  
        didSet {  
            self.undoManager?.registerUndo(withTarget: self) { target in  
                target.count = oldValue  
            }  
        }  
    }  
    var undoManager: UndoManager?  
}  

struct UndoRedoDemo: View {  
    @Environment(\.undoManager) private var undoManager  
    @State private var counter = Counter()  

    var body: some View {  
        VStack(spacing: 36) {  
            HStack(spacing: 36) {  

                Text("\(counter.count)")  

                HStack {  
                    Button(action: {  
                        counter.count += 1  
                    }, label: {  
                        Image(systemName: "plus.circle.fill")  
                    })  

                    Button(action: {  
                        counter.count -= 1  
                    }, label: {  
                        Image(systemName: "minus.circle.fill")  
                    })  
                }  
                
                HStack(spacing: 24) {  
                    Button("Undo") {  
                        undoManager?.undo()  
                    }  
                    .disabled(undoManager?.canUndo == false)  

                    Button("Redo") {  
                        undoManager?.redo()  
                    }  
                    .disabled(undoManager?.canRedo == false)  
                }  
            }  
        }  
        .onAppear {  
            counter.undoManager = undoManager  
        }  
    }  
}

Version2

import SwiftUI

@Observable
private class Counter {
    var count = 0
    var undoManager: UndoManager?

    func setCount(_ newValue: Int)  {
        let oldValue = count
        undoManager?.registerUndo(withTarget: self) { target in
            target.setCount(oldValue)
        }
        count = newValue
    }
}

struct UndoRedoDemo: View {
    @Environment(\.undoManager) private var undoManager
    @State private var counter = Counter()
    
    var body: some View {
        VStack(spacing: 36) {
            HStack(spacing: 36) {

                Text("\(counter.count)")
                        
                HStack {
                    Button(action: {
                        counter.setCount(counter.count + 1)
                    }, label: {
                        Image(systemName: "plus.circle.fill")
                    })
                    
                    Button(action: {
                        counter.setCount(counter.count - 1)
                    }, label: {
                        Image(systemName: "minus.circle.fill")
                    })
                }
            }
            
            HStack(spacing: 24) {
                Button(action: {
                    undoManager?.undo()
                }, label: {
                    Text("Undo")
                })
                .disabled(undoManager?.canUndo == false)
                
                Button(action: {
                    undoManager?.redo()
                }, label: {
                    Text("Redo")
                })
                .disabled(undoManager?.canRedo == false)

            }

        }
        .onAppear {
            counter.undoManager = undoManager
        }
    }
}

profile
👩🏻‍💻 iOS Developer

0개의 댓글