简体   繁体   中英

How do I register UndoManager in Swift?

How do I use UndoManager (previously NSUndoManager ) in Swift?

Here's an Objective-C example I've tried to replicate:

[[undoManager prepareWithInvocationTarget:self] myArgumentlessMethod];

Swift, however, seems to not have NSInvocation , which (seemingly) means I can't call methods on the undoManager that it doesn't implement.

I've tried the object-based version in Swift, but it seems to crash my Playground:

undoManager.registerUndoWithTarget(self, selector: Selector("myMethod"), object: nil)

However it seems to crash, even with my object accepts an argument of type AnyObject?

What's the best way to do this in Swift? Is there a way to avoid sending an unnecessary object with the object-based registration?

OS X 10.11+ / iOS 9+ Update

(Works the same in Swift 3 as well)

OS X 10.11 and iOS 9 introduce a new NSUndoManager function:

public func registerUndoWithTarget<TargetType>(target: TargetType, handler: TargetType -> ())

Example

Imagine a view controller ( self in this example, of type MyViewController ) and a Person model object with a stored property name .

func setName(name: String, forPerson person: Person) {

    // Register undo
    undoManager?.registerUndoWithTarget(self, handler: { [oldName = person.name] (MyViewController) -> (target) in

        target.setName(oldName, forPerson: person)

    })

    // Perform change
    person.name = name

    // ...

}

Caveat

If you're finding your undo isn't (ie, it executes but nothing appears to have happened, as if the undo operation ran but it's still showing the value you wanted to undo from ), consider carefully what the value (the old name in the example above) actually is at the time the undo handler closure is executed.

Any old values to which you want to revert (like oldName in this example) must be captured as such in a capture list. That is, if the closure's single line in the example above were instead:

target.setName(person.name, forPerson: person)

...undo wouldn't work because by the time the undo handler closure is executed, person.name is set to the new name, which means when the user performs an undo, your app (in the simple case above) appears to do nothing since it's setting the name to its current value , which of course isn't undoing anything.

The capture list ( [oldName = person.name] ) ahead of the signature ( (MyViewController) -> () ) declares oldName to reference person.name as it is when the closure is declared , not when it's executed.

More Information About Capture Lists

For more information about capture lists, there's a great article by Erica Sadun titled Swift: Capturing references in closures . It's also worth paying attention to the retain cycle issues she mentions. Also, though she doesn't mention it in her article, inline declaration in the capture list as I use it above comes from the Expressions section of the Swift Programming Language book for Swift 2.0.

Other Ways

Of course, a more verbose way to do it would be to let oldName = person.name ahead of your call to registerUndoWithTarget(_:handler:) , then oldName is automatically captured in scope. I find the capture list approach easier to read, since it's right there with the handler.

I also completely failed to get registerWithInvocationTarget() to play nice with non- NSObject types (like a Swift enum ) as arguments. In the latter case, remember that not only should the invocation target inherit from NSObject , but the arguments to the function you call on that invocation target should as well. Or at least be types that bridge to Cocoa types (like String and NSString or Int and NSNumber , etc.). But there were also problems with the invocation target not being retained that I just couldn't solve. Besides, using a closure as a completion handler is far more Swiftly.

In Closing (Get it?)

Figuring all this out took me several hours of barely-controlled rage (and probably some concern on the part of my Apple Watch about my heart rate - " tap-tap! dude... been listening to your heart and you might want to meditate or something"). I hope my pain and sacrifice helps. :-)

Update 2: Swift in Xcode 6.1 has made undoManager an optional so you call prepareWithInvocationTarget() like this:

undoManager?.prepareWithInvocationTarget(myTarget).insertSomething(someObject, atIndex: index)

Update: Swift in Xcode6 beta5 simplified use of undo manager's prepareWithInvocationTarget().

undoManager.prepareWithInvocationTarget(myTarget).insertSomething(someObject, atIndex: index)

Below was what was needed in beta4:


The NSInvocation based undo manager API can still be used, although it wasn't obvious at first how to call it. I worked out how to call it successfully using the following:

let undoTarget = undoManager.prepareWithInvocationTarget(myTarget) as MyTargetClass?
undoTarget?.insertSomething(someObject, atIndex: index)

Specifically, you need to cast the result of prepareWithInvocationTarget() to the target type, although remember to make it optional or you get a crash (on beta4 anyway). Then you can call your typed optional with the invocation you want to record on the undo stack.

Also make sure your invocation target type inherits from NSObject .

I tried this in a Playground and it works flawlessly:

class UndoResponder: NSObject {
    @objc func myMethod() {
        print("Undone")
    }
}

var undoResponder = UndoResponder()
var undoManager = UndoManager()
undoManager.registerUndo(withTarget: undoResponder, selector: #selector(UndoResponder.myMethod), object: nil)
undoManager.undo()

I tried for 2 days to get Joshua Nozzi's answer to work in Swift 3, but no matter what I did the values were not captured. See: NSUndoManager: capturing reference types possible?

I gave up and just managed it myself by keeping track of changes in undo and redo stacks. So, given a person object I would do something like

protocol Undoable {
     func undo()
     func redo()
}

class Person: Undoable {

    var name: String {
        willSet {
             self.undoStack.append(self.name)
        }
    }
    var undoStack: [String] = []
    var redoStack: [String] = []

    init(name: String) {
        self.name = name
    }

    func undo() {
        if self.undoStack.isEmpty { return }
        self.redoStack.append(self.name)
        self.name = self.undoStack.removeLast()
    }

    func redo() {
        if self.redoStack.isEmpty { return }
        self.undoStack.append(self.name)
        self.name = self.redoStack.removeLast()
    }
}

Then to call it, I don't worry about passing arguments or capturing values since the undo/redo state is managed by the object itself. So say you have a ViewController that is managing your Person objects, you just call registerUndo and pass nil

undoManager?.registerUndo(withTarget: self, selector:#selector(undo), object: nil)

I think it would be Swiftiest if NSUndoManager accepted a closure as an undo registration. This extension will help:

private class SwiftUndoPerformer: NSObject {
    let closure: Void -> Void

    init(closure: Void -> Void) {
        self.closure = closure
    }

    @objc func performWithSelf(retainedSelf: SwiftUndoPerformer) {
        closure()
    }
}

extension NSUndoManager {
    func registerUndo(closure: Void -> Void) {
        let performer = SwiftUndoPerformer(closure: closure)
        registerUndoWithTarget(performer, selector: Selector("performWithSelf:"), object: performer)
        //(Passes unnecessary object to get undo manager to retain SwiftUndoPerformer)
    }
}

Then you can Swift-ly register any closure:

undoManager.registerUndo {
    self.myMethod()
}

setValue forKey does the trick for me on OS X if one needs to support 10.10. I couldn't set it directly cause prepareWithInvocationTarget returns a proxy object.

@objc
enum ImageScaling : Int, CustomStringConvertible {
    case FitInSquare
    case None

    var description : String {
        switch self {
        case .FitInSquare: return "FitInSquare"
        case .None: return "None"
        }
    }
}
private var _scaling : ImageScaling = .FitInSquare
dynamic var scaling : ImageScaling {
    get {
        return _scaling
    }
    set(newValue) {
        guard (_scaling != newValue) else { return }
        undoManager?.prepareWithInvocationTarget(self).setValue(_scaling.rawValue, forKey: "scaling")
        undoManager?.setActionName("Change Scaling")
        document?.beginChanges()
        _scaling = newValue
        document?.endChanges()
    }
}

I too have done a bit of reading and came up with the following: I have 2 tableViews, source by a dictionary and array controller for playlists and its items respectively, which I'm adding to the Helium 3 project on GitHub (not there yet); here's a preview:

dynamic var playlists = Dictionary<String, Any>()
dynamic var playCache = Dictionary<String, Any>()

//  MARK:- Undo keys to watch for undo: dictionary(list) and play item
var listIvars : [String] {
    get {
        return ["key", "value"]
    }
}
var itemIvars : [String] {
    get {
        return ["name", "temp", "time", "rank", "rect", "label", "hover", "alpha", "trans"]
    }
}

internal func observe(_ item: AnyObject, keyArray keys: [String], observing state: Bool) {
    switch state {
    case true:
        for keyPath in keys {
            item.addObserver(self, forKeyPath: keyPath, options: [.old,.new], context: nil)
        }
        break
    case false:
        for keyPath in keys {
            item.removeObserver(self, forKeyPath: keyPath)
        }
    }
}

//  Start or forget observing any changes
internal func observing(_ state: Bool) {
    for dict in playlists {
        let items: [PlayItem] = dict.value as! [PlayItem]
        self.observe(dict as AnyObject, keyArray: listIvars, observing: state)
        for item in items {
            self.observe(item, keyArray: itemIvars, observing: state)
        }
    }
}

override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
    if let undo = self.undoManager {
        let oldValue = change?[NSKeyValueChangeKey(rawValue: "old")]
        let newValue = change?[NSKeyValueChangeKey(rawValue: "new")]

        undo.registerUndo(withTarget: self, handler: {[oldVals = ["key": keyPath!, "old": oldValue as Any] as [String : Any]] (PlaylistViewController) -> () in

            (object as AnyObject).setValue(oldVals["old"], forKey: oldVals["key"] as! String)
            if !undo.isUndoing {
                undo.setActionName(String.init(format: "Edit %@", keyPath!))
            }
        })
        Swift.print(String.init(format: "%@ %@ -> %@", keyPath!, oldValue as! CVarArg, newValue as! CVarArg))
    }
}
override func viewWillAppear() {
    //  Start observing any changes
    observing(true)
}

override func viewDidDisappear() {
    //  Stop observing any changes
    observing(false)
}

//  "List" items are controller objects - NSDictionaryControllerKeyValuePair
internal func addList(_ item: NSDictionaryControllerKeyValuePair, atIndex index: Int) {
    if let undo = self.undoManager {
        undo.registerUndo(withTarget: self, handler: {[oldVals = ["item": item, "index": index] as [String : Any]] (PlaylistViewController) -> () in
            self.removeList(oldVals["item"] as! NSDictionaryControllerKeyValuePair, atIndex: oldVals["index"] as! Int)
            if !undo.isUndoing {
                undo.setActionName("Add PlayList")
            }
        })
    }
    observe(item, keyArray: listIvars, observing: true)
    playlistArrayController.insert(item, atArrangedObjectIndex: index)


    DispatchQueue.main.async {
        self.playlistTableView.scrollRowToVisible(index)
    }
}
internal func removeList(_ item: NSDictionaryControllerKeyValuePair, atIndex index: Int) {
    if let undo = self.undoManager {
        undo.prepare(withInvocationTarget: self.addList(item, atIndex: index))
        if !undo.isUndoing {
            undo.setActionName("Remove PlayList")
        }
    }
    if let undo = self.undoManager {
        undo.registerUndo(withTarget: self, handler: {[oldVals = ["item": item, "index": index] as [String : Any]] (PlaylistViewController) -> () in
            self.addList(oldVals["item"] as! NSDictionaryControllerKeyValuePair, atIndex: oldVals["index"] as! Int)
            if !undo.isUndoing {
                undo.setActionName("Remove PlayList")
            }
        })
    }
    observe(item, keyArray: listIvars, observing: false)
    playlistArrayController.removeObject(item)

    DispatchQueue.main.async {
        self.playlistTableView.scrollRowToVisible(index)
    }
}

"List" items are NSDictionaryControllerKeyValuePair for the NSDictionaryController.

The "item" handling is a bit more complicated but this should get you going. Each time a list or item is added/removed the proper the add|remove method is called. Then you start observing as new items appear and forget as they're removed, this also observes each object's ivars for changes.

Enjoy.

My current take on this:


protocol Undoable {
   func inverted() -> Self 
}

class Store<State, Action : Undoable> {

   let undoManager : UndoManager
   let state : State 
   let reducer : (inout State, Action) -> Void 

   //...init...

   func send(_ action: Action) {
      reducer(&state, action)
      undoManager.registerUndo(withTarget: self){target in 
         target.send(action.inverted())
      }
   }

}


Works great if you're able to get the correct UndoManager. In SwiftUI, this seems to be tricky though, the one in the Environment does not seem to always be the one associated with command+z or Edit -> Undo (I even tried passing it as an argument to send from each View!), and even making it a computed property like below didn't solve my problem:

var undoManager : UndoManager? {
   NSApplication.shared.keyWindow.undoManager
}

Edit: my bad, passing it as a function argument works just fine. Just not from sheets apparently, because they're in their own NSWindow ... One has to pass the proper UndoManager down then. If the sheet has a deeply nested view hierarchy, one should pass it through a custom EnvironmentValue .

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM