3

Admittedly this is a broad question, but is it possible to undo or redo text input (via iOS's UndoManager?) when using a SwiftUI TextEditor control? I've looked everywhere and was unable to find any resource focusing on this workflow combination (SwiftUI + TextEditor + UndoManager). I'm wondering given the relative immaturity of TextEditor that either this isn't possible at all, or requires some plumbing work to facilitate. Any guidance will be greatly appreciated!

Barrrdi
  • 902
  • 13
  • 33
  • 1
    For now (SwiftUI 2.0) it is not possible to do directly, because UndoManager available in context via SwiftUI environment is not that UndoManager, which is used by TextEditor's UITextView backend. So if you need undo/redo - use own representable of UITextView, then you will have access to its undoManager property. – Asperi Oct 30 '20 at 16:51
  • Thanks Asperi. I had a feeling this would be the case. So, I've managed to do this, thanks to https://www.appcoda.com/swiftui-textview-uiviewrepresentable/ – the TextView is displaying/behaving fine in general. But given my layout is in SwiftUI, how do I actually access its undoManager? For example, there will be a button to undo and redo that when called needs to be able to refer to it. Additionally, does every keypress need to be registered for an undo in order for there to be something to undo? Or would it be handled automatically? – Barrrdi Nov 03 '20 at 10:50

2 Answers2

2

Admittedly, this is a bit of a hack and non very SwiftUI-y, but it does work. Basically declare a binding in your UITextView:UIViewRepresentable to an UndoManager. Your UIViewRepresentable will set that binding to the UndoManager provided by the UITextView. Then your parent View has access to the internal UndoManager. Here's some sample code. Redo works as well although not shown here.

struct MyTextView: UIViewRepresentable {

    /// The underlying UITextView. This is a binding so that a parent view can access it. You do not assign this value. It is created automatically.
    @Binding var undoManager: UndoManager?

    func makeUIView(context: Context) -> UITextView {
        let uiTextView = UITextView()

        // Expose the UndoManager to the caller. This is performed asynchronously to avoid modifying the view at an inappropriate time.
        DispatchQueue.main.async {
            undoManager = uiTextView.undoManager
        }

        return uiTextView
    }

    func updateUIView(_ uiView: UITextView, context: Context) {
    }

}

struct ContentView: View {

    /// The underlying UndoManager. Even though it looks like we are creating one here, ultimately, MyTextView will set it to its internal UndoManager.
    @State private var undoManager: UndoManager? = UndoManager()

    var body: some View {
        NavigationView {
            MyTextView(undoManager: $undoManager)
            .toolbar {
                ToolbarItemGroup(placement: .navigationBarTrailing) {
                    Button {
                        undoManager?.undo()
                    } label: {
                        Image(systemName: "arrow.uturn.left.circle")
                    }
                    Button {
                        undoManager?.redo()
                    } label: {
                        Image(systemName: "arrow.uturn.right.circle")
                    }
                }
            }
        }
    }
}
Mark Krenek
  • 4,889
  • 3
  • 25
  • 17
  • I’m marking this as a correct answer as it works perfectly for undo-ing which is arguably the more important of the two actions. And I’m assuming the reason redo isn’t working (as https://stackoverflow.com/a/66201454/698971 suggests) is out of our control. That said, you seem to indicate it should work too, so if that is true, would be greatly appreciated if you can come back to this one. Thanks for your time, and sorry for the delayed acknowledgement. – Barrrdi Mar 04 '22 at 10:06
  • I updated my code above to add a redo button and it works fine. This is a very boiled down version of what I use in my app. It is missing things like a Coordinator and responding to UITextView textViewDidChange, but still demonstrates undo and redo working. In regard to the other answer that uses its own undo manager and calls registerUndo - I had tried something similar early on and found that is was just problematic all around. That led me to my solution that exposes UITextView's internal UndoManager to the Swift code. – Mark Krenek Mar 06 '22 at 02:43
0

In respect to using UIViewRepresentable as a TextView or TextField…. this approach works for undo, but not for redo it seems.

The redo button condition undoManager.canRedo seems to change appropriately. However, it doesn’t return any undone text into either the textfield or TextView

I’m now wondering is this a bug or something I’m missing in the logic?

 
import SwiftUI
import PlaygroundSupport

class Model: ObservableObject {
    @Published var active = ""
    
    func registerUndo(_ newValue: String, in undoManager: UndoManager?) {
        let oldValue = active
        undoManager?.registerUndo(withTarget: self) { target in
            target.active = oldValue
        }
        active = newValue
    }
}

struct TextView: UIViewRepresentable {
    
    @Binding var text: String
    
    func makeUIView(context: Context) -> UITextView {
        
        
        let textView = UITextView()
        textView.autocapitalizationType = .sentences
        textView.isSelectable = true
        textView.isUserInteractionEnabled = true
        
        return textView
    }
    
    func updateUIView(_ uiView: UITextView, context: Context) {
        uiView.text = text
    }
}


struct ContentView: View {
    
    @ObservedObject private var model = Model()
    @Environment(\.undoManager) var undoManager
    @State var text: String = ""
    
    
    var body: some View {
        ZStack (alignment: .bottomTrailing) {
            // Testing TextView for undo & redo functionality
            TextView(text: Binding<String>(
                        get: { self.model.active },
                        set: { self.model.registerUndo($0, in: self.undoManager) }))
            HStack{ 
                // Testing TextField for undo & redo functionality
                TextField("Enter Text...", text: Binding<String>(
                            get: { self.model.active },
                            set: { self.model.registerUndo($0, in: self.undoManager) })).padding()
                Button("Undo") {
                    withAnimation {
                        self.undoManager?.undo()
                    }
                }.disabled(!(undoManager?.canUndo ?? false)).padding()
                Button("Redo") {
                    withAnimation {
                        self.undoManager?.redo()
                    }
                }.disabled(!(undoManager?.canRedo ?? false)).padding()
            }.background(Color(UIColor.init(displayP3Red: 0.1, green: 0.3, blue: 0.3, alpha: 0.3)))
        }.frame(width: 400, height: 400, alignment: .center).border(Color.black)
    }
}

PlaygroundPage.current.setLiveView(ContentView())

Clay
  • 1,721
  • 2
  • 10
  • 18