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:

2 Likes
Terms of Service

Privacy Policy

Cookie Policy