How to interact with a (NS)UndoManager?

Hello!
In my mixed UIKit/SwiftUI app, I would like to interact with some regular UIKit/AppKit UndoManager, but I'm not sure about the way to setup things.

I guess interacting with the UndoManager is a side effect, so let put it in the environment

struct State: Equatable {
  var value: String = ""
}
enum Action {
  case changeString(String)
}
struct Environment {
  var undoManager: UndoManager
}

The classical way to support undo/redo with the UndoManager with blocks is

func changeString(_ string: String) {
  let currentValue = state.value
  undoManager.registerUndo(withTarget: self) { target in
    target.changeString(currentValue)
  }
  undoManager.setActionName("change string")
  state.value = string
}

In this way, each time it's undoing, it's registering itself for redoing, and reciprocally.
I'm trying to reproduce the same with SCA. I would like to continue using a regular UndoManager because it is automatically linked in several places by UIKit/AppKit.

The issue is that UndoManager needs to act on the behalf of the user and send a .changeString(String) to some ViewStore or Store when it's undoing. UndoManager doesn't seem to retain the target, so I need to retain it explicitly in case it went away when we are undoing.

So I'm extending UndoManager:

extension UndoManager {
  func registerUndo<State, Action>(action: Action,
                                     store: Store<State, Action>,
                                     name: String? = nil) 
                                       where State: Equatable {
    registerUndo(withTarget: store) { [store] _ in
            ViewStore(store).send(action)
    }
    name.map(setActionName)
  }
}

I'm capturing explicitly the store (it should be automatic, but I'm marking that's intentional). I'm also creating a single use ViewStore to send the action.

I also need to extend Action

enum Action {
  case changeString(String, Store<State, Action>)
}

and the reducer for this action becomes

let reducer = Reducer<State, Action, Environment> {
    state, action, environment in
  
  switch action {
  case let .changeString(string, store):
    let current = state.string
    state.string = string
    return .fireAndForget { 
      environment.registerUndo(action: .string1(current, store), 
                                store: store, 
                                 name: "change string") 
    }
  }
}

And when I'm making a change, I'm sending .changeString("A", store) to the ViewStore.

It seems to work OK. I don't see retain cycles, and captured stores are released after the undo block evaluates. I don't have very much experience with SCA, and maybe I'm doing something completely illegal, or I'm missing something.

Does anybody have any comment or alternative way to achieve this?
Thanks!

I am wrestling with this too - my thinking is that if we want to hook into the platform UndoManager there isn't a way around having to pass it a store since it needs to send an action back to an object, although it doesn't seem that nice to pass stores as parameters to actions...

I was hoping there was some higher order way of solving this - I am thinking there might be a way to write a pullback function like the forEach functions which will pass the store as a parameter for you like this.

  counterReducer.passingStore(
    state: \CounterListState.counters,
    action: /CounterListAction.counter(store:action:),
    environment: { _ in CounterEnvironment() }
  )

Conceptually I think an action is just a redo, and undo is the opposite of that. So the undo stack is just a FIFO array of Effects. I wonder if we could write a class to wrap the undo manager which maintains its own list of effects and registered uniform target / selector?

It would be great if the Composable Architecture authors could weigh in here :slight_smile:

3 Likes

Well, especially the recent .form enhancements make it even unclearer to me how an undo/redo mechanism could be properly realized. Any feedback of the TCA authors would be highly appreciated here as well...

Hey all! We had a similar need and came up with the following approach. You can set up the state in a similar way to the following:

struct State: Equatable {
  var value: String = ""
}
enum Action {
  case onDidFinishLaunching // Called when you open your application window for the first time
  case closeApp             // Called when you close your application
  case changeString(String)
}
struct Environment {
  var undoClient: TCAUndoClient
}

Where the TCAUndoClient is a struct that provides the following public interface:

struct TCAUndoClient {
    var undoEffects: Effect<Action, Never>
    func register(_ undoAction: Action, _ actionName: String) -> Void
}

Now, you can implement this struct with something like the following (passing in your UndoManager)

struct TCAUndoClient {
    private var undos: PassthroughSubject<Action, Never> = PassthroughSubject()
    var undoEffects: Effect<Action, Never> {
        return undos.eraseToEffect()
    }
    init(undoManager: UndoManager) {
        self.undoManager = undoManager
    }
    weak var undoManager: UndoManager?
    func register(undoAction: Action, actionName: String) {
        undoManager?.registerUndo(withTarget: undos) { undos in
            undos.send(undoAction)
        }
        undoManager?.setActionName(actionName)
    }
}

Now, in the environment, we have access to a stream of Actions from the TCAUndoManager, which we can subscribe to when the app launches and unsubscribe from when the app closes. Your reducer would look like this:

let reducer = Reducer<State, Action, Environment> { state, action, environment in
  struct UndoEffectsCancellable: Hashable { }
  switch action {
  case .onDidFinishLaunching:
    // Subscribe to the stream of actions
    return undoManager.undoEffects
                    .map { $0 }
                    .cancellable(id: UndoEffectsCancellable(), cancelInFlight: true)
  case .closeApp:
    // Unsubscribe 
    return Effect.cancel(id: UndoEffectsCancellable())
  case let .changeString(string, store):
    let originalValue = state.string
    state.string = string
    return .fireAndForget { 
      environment.register(.changeString(originalValue), "Change String") 
    }
  }
}

And if you wanted to extract the undo logic entirely from your reducer, you could do something like this instead:

let reducer = Reducer<State, Action, Environment> { state, action, environment in
  struct UndoEffectsCancellable: Hashable { }
  switch action {
  case .onDidFinishLaunching:
    // Subscribe to the stream of actions
    return undoManager.undoEffects
                    .map { $0 }
                    .cancellable(id: UndoEffectsCancellable(), cancelInFlight: true)
  case .closeApp:
    // Unsubscribe 
    return Effect.cancel(id: UndoEffectsCancellable())
  case let .changeString(string, store):
    state.string = string
    return .none
  }
}.undoReducer()
extension Reducer {
    func undoReducer() -> Self {
        .init { (state, action, env) -> Effect<Action, Never> in
            switch action {
            case .changeString:
                let originalValue = state.string
                let effects = self.run(&state, action, env)
                environment.register(.changeString(originalValue), "Change String")                 
                return effects
            default:
                return self.run(&state, action, env)
            }
        }
    }
}
2 Likes

Thanks! This is indeed a good idea, as the PassthroughSubject is not "leaking" the store or a ViewStore.
The only downside it that a Store must be observing the subject at all time to maintain coherence, but I guess it's not an issue at all if this store is the root store or very close.
I'm working on a higher order reducer to manage undo/redo for any state that I'm planning to open-source. Currently, its UndoManager is read-only, but I'll experiment with this idea to see if I can reinstall read/write functions in the UndoManager.

Terms of Service

Privacy Policy

Cookie Policy