Hi @Alazel, you wrote:
The undoManager is nil during onAppear of the ContentView, so I can't set it into my view model before running some user-initiated action on the view itself.
The value starts out nil and becomes valid once the view is attached to a window's undo context, and SwiftUI re-runs body when that happens. So you'd want to read it in the view body.
For example, start by defining an object that bundles together everything your menu command needs.
@main
struct MyApp: App {
@State private var viewModel = MyViewModel()
var body: some Scene {
WindowGroup {
ContentView()
.environment(viewModel)
}
.commands {
MyCommands()
}
}
}
@Observable
final class Record: Identifiable {
let id = UUID()
var name: String
init(name: String = "New Record") {
self.name = name
}
}
struct RecordActions {
let viewModel: MyViewModel
let undoManager: UndoManager?
let selectedID: UUID?
func add() { viewModel.addRecord(undoManager: undoManager) }
func delete() { viewModel.deleteRecord(id: selectedID, undoManager: undoManager) }
}
extension FocusedValues {
@Entry var recordActions: RecordActions?
}
struct MyCommands: Commands {
@FocusedValue(\.recordActions) private var actions
var body: some Commands {
CommandGroup(after: .newItem) {
Button("Add Record") { actions?.add() }
.keyboardShortcut("n", modifiers: .command)
.disabled(actions == nil)
Button("Delete Record") { actions?.delete() }
.disabled(actions?.selectedID == nil)
}
}
}
struct ContentView: View {
@Environment(MyViewModel.self) private var viewModel
@Environment(\.undoManager) private var undoManager
var body: some View {
@Bindable var viewModel = viewModel
Table(viewModel.records, selection: $viewModel.selectedID) {
TableColumn("Name") { record in
@Bindable var record = record
TextField("", text: $record.name)
}
}
.focusedSceneValue(\.recordActions, RecordActions(
viewModel: viewModel,
undoManager: undoManager,
selectedID: viewModel.selectedID
))
.frame(minWidth: 400, minHeight: 300)
}
}
On the very first pass the undoManager may still be nil, SwiftUI will run body again once the undo context exists, at that point the focused value is updated with a valid manager. Because you rebuild the structure on every pass, the value will be updated if the window's manager is ever replaced.
@Observable
final class MyViewModel {
var records: [Record] = [Record(name: "First")]
var selectedID: UUID?
@MainActor
func addRecord(undoManager: UndoManager?) {
let record = Record()
records.append(record)
selectedID = record.id
undoManager?.registerUndo(withTarget: self) { vm in
vm.removeRecord(record, undoManager: undoManager)
}
undoManager?.setActionName("Add Record")
}
@MainActor
func removeRecord(_ record: Record, undoManager: UndoManager?) {
guard let index = records.firstIndex(where: { $0.id == record.id }) else { return }
records.remove(at: index)
if selectedID == record.id { selectedID = nil }
undoManager?.registerUndo(withTarget: self) { vm in
vm.insertRecord(record, at: index, undoManager: undoManager)
}
undoManager?.setActionName("Delete Record")
}
@MainActor
func deleteRecord(id: UUID?, undoManager: UndoManager?) {
guard let id, let record = records.first(where: { $0.id == id }) else { return }
removeRecord(record, undoManager: undoManager)
}
@MainActor
func insertRecord(_ record: Record, at index: Int, undoManager: UndoManager?) {
let clampedIndex = min(index, records.count)
records.insert(record, at: clampedIndex)
selectedID = record.id
undoManager?.registerUndo(withTarget: self) { vm in
vm.removeRecord(record, undoManager: undoManager)
}
undoManager?.setActionName("Add Record")
}
}
When registering undo, each operation should register its own inverse. When undo is triggered, your inverse operation runs, and because that inverse also registers an undo, the system now has a redo available.