4
votes

I'm trying to implement undo in a SwiftUI app for iOS, but I haven't been able to get the undo gestures to work. Here's a sample that demonstrates the problem:

class Model: ObservableObject {
    @Published var active = false

    func registerUndo(_ newValue: Bool, in undoManager: UndoManager?) {
        let oldValue = active
        undoManager?.registerUndo(withTarget: self) { target in
            target.active = oldValue
        }
        active = newValue
    }
}

struct UndoTest: View {
    @ObservedObject private var model = Model()
    @Environment(\.undoManager) var undoManager

    var body: some View {
        VStack {
            Toggle(isOn: Binding<Bool>(
                get: { self.model.active },
                set: { self.model.registerUndo($0, in: self.undoManager) }
            )) {
                Text("Toggle")
            }
            .frame(width: 120)
            Button(action: {
                self.undoManager?.undo()
            }, label: {
                Text("Undo")
                    .foregroundColor(.white)
                    .padding()
                    .background(self.undoManager?.canUndo == true ? Color.blue : Color.gray)
            })
        }
    }
}

Switching the toggle around then tapping the undo button works fine. Using the three-finger undo gesture or shaking to undo does nothing. How do you tie in to the system gesture?

1

1 Answers

5
votes

It appears that the editing gestures require the window to have first responder, and that SwiftUI doesn't set up anything that the UIWindow wants to pick as first responder by default.

If you subclass UIHostingController, and in your subclass, you override canBecomeFirstResponder to return true, then the UIWindow will set your controller as first responder by default, which appears sufficient to enable the editing gestures.

I tested the following code on my iPad Pro running iPadOS 13.1 beta 2 (17A5831c). It mostly works. I believe there is an iOS bug, perhaps fixed in a newer beta: when the undo stack is empty, the gestures sometimes don't work (even when a redo action is possible). Switching to the home screen and then back to the test app (without killing the test app) seems to make the editing gestures work again.

import UIKit
import SwiftUI

class MyHostingController<Content: View>: UIHostingController<Content> {
    override var canBecomeFirstResponder: Bool { true }
}

class Model: ObservableObject {
    init(undoManager: UndoManager) {
        self.undoManager = undoManager
    }

    let undoManager: UndoManager

    @Published var active = false {
        willSet {
            let oldValue = active
            undoManager.registerUndo(withTarget: self) { me in
                me.active = oldValue
            }
        }
    }
}

struct ContentView: View {
    @ObservedObject var model: Model
    @Environment(\.undoManager) var undoManager

    var body: some View {
        VStack {
            Toggle("Active", isOn: $model.active)
                .frame(width: 120)
            HStack {
                Button("Undo") {
                    withAnimation {
                        self.undoManager?.undo()
                    }
                }.disabled(!(undoManager?.canUndo ?? false))
                Button("Redo") {
                    withAnimation {
                        self.undoManager?.redo()
                    }
                }.disabled(!(undoManager?.canRedo ?? false))
            }
        }
    }
}

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let scene = scene as? UIWindowScene else { return }

        let window = UIWindow(windowScene: scene)
        let model = Model(undoManager: window.undoManager!)
        let contentView = ContentView(model: model)

        window.rootViewController = MyHostingController(rootView: contentView)
        self.window = window
        window.makeKeyAndVisible()
    }
}