Editing and deleting a sync-up
Let’s implement two major pieces of functionality in the detail screen: editing the sync-up and deleting the sync-up. This will force us to face a number of common problems in app development all at once, including view reuse, navigation, alerts, and parent-child communication patterns.
Editing the sync-up
To edit the sync-up we will reuse the SyncUpFormView
that we previously used for adding a sync-up. This will allow us to see how easy it is to reuse features in the Composable Architecture, and it will allow us to explore how to communicate from the child feature back to the parent.
We want to present the SyncUpFormView
from the SyncUpDetailView
in a sheet, and so we will need to use the presentation tools of the library just as we did in the SyncUpListsView
.
The domains of the child and parent features are now fully integrated together. We can now implement the logic that causes the edit sheet to be presented.
That is the basics of presenting the “Edit sync-up” sheet from the sync-up detail view. There will be more to do in this reducer in a moment, but we can leave it here for now and concentrate on the view layer for a moment.
We have now finished integrating the SyncUpForm
and SyncUpDetail
at the view layer, and so all that is left is to finish integrating the features at the reducer level.
Go to the SyncUpDetail.swift file and update the
State
of the feature so that it holds on to an optional piece ofSyncUpForm.State
using thePresents
macro. When the state is non-nil
the sheet will be presented, and when it isnil
it will be dismissed.SyncUpDetail.swift
EditingAndDeletingSyncUp-01-code-0001.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @ObservableState struct State: Equatable { @Presents var editSyncUp: SyncUpForm.State? @Shared var syncUp: SyncUp } enum Action { case deleteButtonTapped case editButtonTapped } var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .deleteButtonTapped: return .none case .editButtonTapped: return .none } } } } struct SyncUpDetailView: View { // ... }
Add a case to the
Action
enum to represent thePresentationAction
of theSyncUpForm
.SyncUpDetail.swift
EditingAndDeletingSyncUp-01-code-0002.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @ObservableState struct State: Equatable { @Presents var editSyncUp: SyncUpForm.State? @Shared var syncUp: SyncUp } enum Action { case deleteButtonTapped case editButtonTapped case editSyncUp(PresentationAction<SyncUpForm.Action>) } var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .deleteButtonTapped: return .none case .editButtonTapped: return .none } } } } struct SyncUpDetailView: View { // ... }
Integrate the
SyncUpForm
reducer into theSyncUpDetail
reducer by using theComposableArchitecture/Reducer/ifLet(_:action:destination:fileID:filePath:line:column:)-4ub6q
operator.SyncUpDetail.swift
EditingAndDeletingSyncUp-01-code-0003.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @ObservableState struct State: Equatable { @Presents var editSyncUp: SyncUpForm.State? @Shared var syncUp: SyncUp } enum Action { case deleteButtonTapped case editButtonTapped case editSyncUp(PresentationAction<SyncUpForm.Action>) } var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .deleteButtonTapped: return .none case .editButtonTapped: return .none case .editSyncUp: return .none } } .ifLet(\.$editSyncUp, action: \.editSyncUp) { SyncUpForm() } } } struct SyncUpDetailView: View { // ... }
When the “Edit” button is tapped we can populate the
editSyncUp
state to represent the sheet should be presented. We can pass along the localsyncUp
value to theSyncUpForm
so that it has the freshest data to edit.SyncUpDetail.swift
EditingAndDeletingSyncUp-01-code-0004.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @ObservableState struct State: Equatable { @Presents var editSyncUp: SyncUpForm.State? @Shared var syncUp: SyncUp } enum Action { case deleteButtonTapped case editButtonTapped case editSyncUp(PresentationAction<SyncUpForm.Action>) } var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .deleteButtonTapped: return .none case .editButtonTapped: state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) return .none case .editSyncUp: return .none } } .ifLet(\.$editSyncUp, action: \.editSyncUp) { SyncUpForm() } } } struct SyncUpDetailView: View { // ... }
Update the
store
property in the view to be@Bindable
since we will need to derive bindings from the store for driving navigation from state.SyncUpDetail.swift
EditingAndDeletingSyncUp-01-code-0005.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { // ... } struct SyncUpDetailView: View { @Bindable var store: StoreOf<SyncUpDetail> var body: some View { Form { Section { NavigationLink { } label: { Label("Start Meeting", systemImage: "timer") .font(.headline) .foregroundStyle(Color.accentColor) } HStack { Label("Length", systemImage: "clock") Spacer() Text(store.syncUp.duration.formatted(.units())) } HStack { Label("Theme", systemImage: "paintpalette") Spacer() Text(store.syncUp.theme.name) .padding(4) .foregroundStyle(store.syncUp.theme.accentColor) .background(store.syncUp.theme.mainColor) .clipShape(.rect(cornerRadius: 4)) } } header: { Text("Sync-up Info") } if !store.syncUp.meetings.isEmpty { Section { ForEach(store.syncUp.meetings) { meeting in Button { } label: { HStack { Image(systemName: "calendar") Text(meeting.date, style: .date) Text(meeting.date, style: .time) } } } } header: { Text("Past meetings") } } Section { ForEach(store.syncUp.attendees) { attendee in Label(attendee.name, systemImage: "person") } } header: { Text("Attendees") } Section { Button("Delete", role: .destructive) { store.send(.deleteButtonTapped) } .frame(maxWidth: .infinity) } } .navigationTitle(Text(store.syncUp.title)) .toolbar { Button("Edit") { store.send(.editButtonTapped) } } } } #Preview { NavigationStack { SyncUpDetailView( store: Store( initialState: SyncUpDetail.State( syncUp: Shared(value: .mock) ) ) { SyncUpDetail() } ) } }
At the very bottom of the view use the
sheet(item:)
modifier by deriving a binding to theSyncUpForm
domain usingSwiftUI/Binding/scope(state:action:fileID:filePath:line:column:)
.SyncUpDetail.swift
EditingAndDeletingSyncUp-01-code-0006.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { // ... } struct SyncUpDetailView: View { @Bindable var store: StoreOf<SyncUpDetail> var body: some View { Form { Section { NavigationLink { } label: { Label("Start Meeting", systemImage: "timer") .font(.headline) .foregroundStyle(Color.accentColor) } HStack { Label("Length", systemImage: "clock") Spacer() Text(store.syncUp.duration.formatted(.units())) } HStack { Label("Theme", systemImage: "paintpalette") Spacer() Text(store.syncUp.theme.name) .padding(4) .foregroundStyle(store.syncUp.theme.accentColor) .background(store.syncUp.theme.mainColor) .clipShape(.rect(cornerRadius: 4)) } } header: { Text("Sync-up Info") } if !store.syncUp.meetings.isEmpty { Section { ForEach(store.syncUp.meetings) { meeting in Button { } label: { HStack { Image(systemName: "calendar") Text(meeting.date, style: .date) Text(meeting.date, style: .time) } } } } header: { Text("Past meetings") } } Section { ForEach(store.syncUp.attendees) { attendee in Label(attendee.name, systemImage: "person") } } header: { Text("Attendees") } Section { Button("Delete", role: .destructive) { store.send(.deleteButtonTapped) } .frame(maxWidth: .infinity) } } .navigationTitle(Text(store.syncUp.title)) .toolbar { Button("Edit") { store.send(.editButtonTapped) } } .sheet(item: $store.scope(state: \.editSyncUp, action: \.editSyncUp)) { editSyncUpStore in NavigationStack { SyncUpFormView(store: editSyncUpStore) } } } } #Preview { NavigationStack { SyncUpDetailView( store: Store( initialState: SyncUpDetail.State( syncUp: Shared(value: .mock) ) ) { SyncUpDetail() } ) } }
Provide a navigation title and toolbar buttons to the sheet for saving and cancelling the edits made to the sync-up.
SyncUpDetail.swift
EditingAndDeletingSyncUp-01-code-0007.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { // ... } struct SyncUpDetailView: View { @Bindable var store: StoreOf<SyncUpDetail> var body: some View { Form { Section { NavigationLink { } label: { Label("Start Meeting", systemImage: "timer") .font(.headline) .foregroundStyle(Color.accentColor) } HStack { Label("Length", systemImage: "clock") Spacer() Text(store.syncUp.duration.formatted(.units())) } HStack { Label("Theme", systemImage: "paintpalette") Spacer() Text(store.syncUp.theme.name) .padding(4) .foregroundStyle(store.syncUp.theme.accentColor) .background(store.syncUp.theme.mainColor) .clipShape(.rect(cornerRadius: 4)) } } header: { Text("Sync-up Info") } if !store.syncUp.meetings.isEmpty { Section { ForEach(store.syncUp.meetings) { meeting in Button { } label: { HStack { Image(systemName: "calendar") Text(meeting.date, style: .date) Text(meeting.date, style: .time) } } } } header: { Text("Past meetings") } } Section { ForEach(store.syncUp.attendees) { attendee in Label(attendee.name, systemImage: "person") } } header: { Text("Attendees") } Section { Button("Delete", role: .destructive) { store.send(.deleteButtonTapped) } .frame(maxWidth: .infinity) } } .navigationTitle(Text(store.syncUp.title)) .toolbar { Button("Edit") { store.send(.editButtonTapped) } } .sheet(item: $store.scope(state: \.editSyncUp, action: \.editSyncUp)) { editSyncUpStore in NavigationStack { SyncUpFormView(store: editSyncUpStore) .navigationTitle(store.syncUp.title) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { } } ToolbarItem(placement: .confirmationAction) { Button("Done") { } } } } } } } #Preview { NavigationStack { SyncUpDetailView( store: Store( initialState: SyncUpDetail.State( syncUp: Shared(value: .mock) ) ) { SyncUpDetail() } ) } }
Send actions to the store when each of the toolbar buttons is tapped.
SyncUpDetail.swift
EditingAndDeletingSyncUp-01-code-0008.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { // ... } struct SyncUpDetailView: View { @Bindable var store: StoreOf<SyncUpDetail> var body: some View { Form { Section { NavigationLink { } label: { Label("Start Meeting", systemImage: "timer") .font(.headline) .foregroundStyle(Color.accentColor) } HStack { Label("Length", systemImage: "clock") Spacer() Text(store.syncUp.duration.formatted(.units())) } HStack { Label("Theme", systemImage: "paintpalette") Spacer() Text(store.syncUp.theme.name) .padding(4) .foregroundStyle(store.syncUp.theme.accentColor) .background(store.syncUp.theme.mainColor) .clipShape(.rect(cornerRadius: 4)) } } header: { Text("Sync-up Info") } if !store.syncUp.meetings.isEmpty { Section { ForEach(store.syncUp.meetings) { meeting in Button { } label: { HStack { Image(systemName: "calendar") Text(meeting.date, style: .date) Text(meeting.date, style: .time) } } } } header: { Text("Past meetings") } } Section { ForEach(store.syncUp.attendees) { attendee in Label(attendee.name, systemImage: "person") } } header: { Text("Attendees") } Section { Button("Delete", role: .destructive) { store.send(.deleteButtonTapped) } .frame(maxWidth: .infinity) } } .navigationTitle(Text(store.syncUp.title)) .toolbar { Button("Edit") { store.send(.editButtonTapped) } } .sheet(item: $store.scope(state: \.editSyncUp, action: \.editSyncUp)) { editSyncUpStore in NavigationStack { SyncUpFormView(store: editSyncUpStore) .navigationTitle(store.syncUp.title) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { store.send(.cancelEditButtonTapped) } } ToolbarItem(placement: .confirmationAction) { Button("Done") { store.send(.doneEditingButtonTapped) } } } } } } } #Preview { NavigationStack { SyncUpDetailView( store: Store( initialState: SyncUpDetail.State( syncUp: Shared(value: .mock) ) ) { SyncUpDetail() } ) } }
Add the new cases to the
Action
enum that are being sent from the view.SyncUpDetail.swift
EditingAndDeletingSyncUp-01-code-0009.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @ObservableState struct State: Equatable { @Presents var editSyncUp: SyncUpForm.State? @Shared var syncUp: SyncUp } enum Action { case cancelEditButtonTapped case deleteButtonTapped case doneEditingButtonTapped case editButtonTapped case editSyncUp(PresentationAction<SyncUpForm.Action>) } var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .deleteButtonTapped: return .none case .editButtonTapped: state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) return .none case .editSyncUp: return .none } } .ifLet(\.$editSyncUp, action: \.editSyncUp) { SyncUpForm() } } } struct SyncUpDetailView: View { // ... }
Implement the
cancelEditButtonTapped
action by simply clearing out theeditSyncUp
state. This will cause the view to dismiss the sheet.SyncUpDetail.swift
EditingAndDeletingSyncUp-01-code-0010.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @ObservableState struct State: Equatable { @Presents var editSyncUp: SyncUpForm.State? @Shared var syncUp: SyncUp } enum Action { case cancelEditButtonTapped case deleteButtonTapped case doneEditingButtonTapped case editButtonTapped case editSyncUp(PresentationAction<SyncUpForm.Action>) } var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .cancelEditButtonTapped: state.editSyncUp = nil return .none case .deleteButtonTapped: return .none case .editButtonTapped: state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) return .none case .editSyncUp: return .none } } .ifLet(\.$editSyncUp, action: \.editSyncUp) { SyncUpForm() } } } struct SyncUpDetailView: View { // ... }
Implement the
doneEditingButtonTapped
action by grabbing the latestsyncUp
value from theeditSyncUp
state, and replacingSyncUpDetail
’s state with that value.SyncUpDetail.swift
EditingAndDeletingSyncUp-01-code-0011.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @ObservableState struct State: Equatable { @Presents var editSyncUp: SyncUpForm.State? @Shared var syncUp: SyncUp } enum Action { case cancelEditButtonTapped case deleteButtonTapped case doneEditingButtonTapped case editButtonTapped case editSyncUp(PresentationAction<SyncUpForm.Action>) } var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .cancelEditButtonTapped: state.editSyncUp = nil return .none case .deleteButtonTapped: return .none case .doneEditingButtonTapped: guard let editedSyncUp = state.editSyncUp?.syncUp else { return .none } state.$syncUp.withLock { $0 = editedSyncUp } state.editSyncUp = nil return .none case .editButtonTapped: state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) return .none case .editSyncUp: return .none } } .ifLet(\.$editSyncUp, action: \.editSyncUp) { SyncUpForm() } } } struct SyncUpDetailView: View { // ... }
Run the preview to see that you can tap the “Edit” button to bring up the sync-up form sheet. Then make some changes to the sync-up, and see that tapping “Done” causes those changes to be committed to the sync-up in the detail screen.
Deleting the sync-up
Next we will implement the logic for deleting the sync-up from the detail screen. Because deletion is performed by a simple button, we want to protect from accidental deletions by asking the user for confirmation using an alert. This will give us an opportunity to explore how one can show alerts in the Composable Architecture.
Technically, alerts can be shown in Composable Architecture features in exactly the same way they are in vanilla SwiftUI by using the various alert
view modifiers. However, the library comes with some tools that can improve how alerts are shown, and can make the alerts more easily testable. We will use those tools.
Integrating the logic of an alert into a feature is a bit different from integrating the logic of a fully fledged feature. This is because alerts are immediately dismissed when a button is tapped and has no internal logic or behavior of its own. For this reason there is no reducer to specify in the trailing closure of ifLet
, and in fact it can be removed.
We now have the alert integrated into the SyncUpDetail
, but we aren’t yet populating the alert
state to actually present the alert. This is done by constructing a value of AlertState
, which is a data type that represents all of the properties of an alert, such as its title, message and buttons. Its primary purpose is to be Equatable
-friendly so that alerts can be tested.
That’s all it takes to create AlertState
. It contains all of the information for the view to display the alert, which we will do in a moment.
However, constructing AlertState
values can be quite long, and so sometimes you may want to extract their creation to an extension on AlertState
.
Next we need to handle the new alert actions in the reducer.
The .alert(.presented)
case is more subtle, though. This action is sent when the user confirms that they want to delete the sync-up, and so somehow we have to remove the syncUp
we have in state from the collection of sync-ups that is in the SyncUpsList
feature.
However, remember that in Persisting sync-ups we showed how to persist the collection of sync-ups using the @Shared
property wrapper with the fileStorage
persistence strategy. This gives the app global access to that state, and we can make edits to it from anywhere. We can even do it directly inline in the .confirmButtonTapped
action.
That’s all it takes to delete the sync-up from the persisted array of sync-ups. It may trouble you that we are reaching out to a seemingly global syncUps
variable and mutating it. However, this is no different than making an API request to delete data on some external server. Typically for that situation we use dependencies to make the API operation testable, but we don’t need to do that with @Shared
. It is testable by default.
If it truly bothers you to access the global syncUps
state from within the detail feature, then you can instead send a “delegate” action from the detail that the parent feature intercepts and implements the deleting logic. That will allow the parent to handle deletion while the child remains ignorant to those details.
There is one more addition we want to make to the deletion functionality before moving on. When the user confirms deletion of the sync-up we should dismiss the detail view from being presented, as it’s no longer relevant. After all, the sync-up is being deleted!
This allows us to demonstrate another superpower of the Composable Architecture. The library comes with a powerful dependency called DismissEffect
that allows a feature to dismiss itself if it is being presented, and if the feature was integrated into the parent using the library’s navigation tools. This can be powerful because it allows the child features to encapsulate as much of their logic as possible without having to explicitly communicate with the parent. It works similarly to SwiftUI’s @Environment(\.dismiss)
, but it is also distinct from it.
To dismiss a child feature with the DismissEffect
dependency you just have to invoke it as if it’s a function: dismiss()
. However, the dismiss effect is an asynchronous function, and therefore it cannot be invoked directly in the reducer. It can only be invoked inside an effect, and so we must use the run(priority:operation:catch:fileID:filePath:line:column:)
effect, which gives us an asynchronous context to execute the work in, as well as a handle on Send
for sending actions back into the system if needed.
Next we need to integrate the alert into the view layer so that an alert actually shows when the alert
state becomes non-nil
.
We have now finished the sync-up deletion functionality in the detail screen. Unfortunately we can’t yet test deletion because we still have no way to navigate to the detail screen from the sync-ups list screen.
But, before getting to that, let’s take a quick side excursion to clean up our domain modeling in this feature.
We start by modeling a new
Alert
enum nested inside theAction
enum that represents all the actions the user can take in the alert. The user can only confirm deletion or cancel deletion, and we do not have to model the cancel action. That is automatically taken care of by the library’s navigation tools.SyncUpDetail.swift
EditingAndDeletingSyncUp-02-code-0001.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @ObservableState struct State: Equatable { @Presents var editSyncUp: SyncUpForm.State? @Shared var syncUp: SyncUp } enum Action { case cancelEditButtonTapped case deleteButtonTapped case doneEditingButtonTapped case editButtonTapped case editSyncUp(PresentationAction<SyncUpForm.Action>) @CasePathable enum Alert { case confirmButtonTapped } } var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .cancelEditButtonTapped: state.editSyncUp = nil return .none case .deleteButtonTapped: return .none case .doneEditingButtonTapped: guard let editedSyncUp = state.editSyncUp?.syncUp else { return .none } state.$syncUp.withLock { $0 = editedSyncUp } state.editSyncUp = nil return .none case .editButtonTapped: state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) return .none case .editSyncUp: return .none } } .ifLet(\.$editSyncUp, action: \.editSyncUp) { SyncUpForm() } } } struct SyncUpDetailView: View { // ... }
Add another piece of optional state using the
Presents
macro, this time representing whether or not an alert is shown.SyncUpDetail.swift
EditingAndDeletingSyncUp-02-code-0002.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @ObservableState struct State: Equatable { @Presents var alert: AlertState<Action.Alert>? @Presents var editSyncUp: SyncUpForm.State? @Shared var syncUp: SyncUp } enum Action { case cancelEditButtonTapped case deleteButtonTapped case doneEditingButtonTapped case editButtonTapped case editSyncUp(PresentationAction<SyncUpForm.Action>) @CasePathable enum Alert { case confirmButtonTapped } } var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .cancelEditButtonTapped: state.editSyncUp = nil return .none case .deleteButtonTapped: return .none case .doneEditingButtonTapped: guard let editedSyncUp = state.editSyncUp?.syncUp else { return .none } state.$syncUp.withLock { $0 = editedSyncUp } state.editSyncUp = nil return .none case .editButtonTapped: state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) return .none case .editSyncUp: return .none } } .ifLet(\.$editSyncUp, action: \.editSyncUp) { SyncUpForm() } } } struct SyncUpDetailView: View { // ... }
Add another
PresentationAction
case to theAction
enum that represents the actions the alert can send.SyncUpDetail.swift
EditingAndDeletingSyncUp-02-code-0003.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @ObservableState struct State: Equatable { @Presents var alert: AlertState<Action.Alert>? @Presents var editSyncUp: SyncUpForm.State? @Shared var syncUp: SyncUp } enum Action { case alert(PresentationAction<Alert>) case cancelEditButtonTapped case deleteButtonTapped case doneEditingButtonTapped case editButtonTapped case editSyncUp(PresentationAction<SyncUpForm.Action>) @CasePathable enum Alert { case confirmButtonTapped } } var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .cancelEditButtonTapped: state.editSyncUp = nil return .none case .deleteButtonTapped: return .none case .doneEditingButtonTapped: guard let editedSyncUp = state.editSyncUp?.syncUp else { return .none } state.$syncUp.withLock { $0 = editedSyncUp } state.editSyncUp = nil return .none case .editButtonTapped: state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) return .none case .editSyncUp: return .none } } .ifLet(\.$editSyncUp, action: \.editSyncUp) { SyncUpForm() } } } struct SyncUpDetailView: View { // ... }
Handle the
.alert
case in the reducer, and use theComposableArchitecture/Reducer/ifLet(_:action:then:fileID:line:)-7s8h2
operator again to integrate the alert’s logic into theSyncUpDetail
reducer.SyncUpDetail.swift
EditingAndDeletingSyncUp-02-code-0004.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @ObservableState struct State: Equatable { @Presents var alert: AlertState<Action.Alert>? @Presents var editSyncUp: SyncUpForm.State? @Shared var syncUp: SyncUp } enum Action { case alert(PresentationAction<Alert>) case cancelEditButtonTapped case deleteButtonTapped case doneEditingButtonTapped case editButtonTapped case editSyncUp(PresentationAction<SyncUpForm.Action>) @CasePathable enum Alert { case confirmButtonTapped } } var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .alert: return .none case .cancelEditButtonTapped: state.editSyncUp = nil return .none case .deleteButtonTapped: return .none case .doneEditingButtonTapped: guard let editedSyncUp = state.editSyncUp?.syncUp else { return .none } state.$syncUp.withLock { $0 = editedSyncUp } state.editSyncUp = nil return .none case .editButtonTapped: state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) return .none case .editSyncUp: return .none } } .ifLet(\.$editSyncUp, action: \.editSyncUp) { SyncUpForm() } .ifLet(\.$alert, action: \.alert) { } } } struct SyncUpDetailView: View { // ... }
Remove the trailing closure from the
ifLet
because the alert does not have any additional behavior to execute.SyncUpDetail.swift
EditingAndDeletingSyncUp-02-code-0005.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @ObservableState struct State: Equatable { @Presents var alert: AlertState<Action.Alert>? @Presents var editSyncUp: SyncUpForm.State? @Shared var syncUp: SyncUp } enum Action { case alert(PresentationAction<Alert>) case cancelEditButtonTapped case deleteButtonTapped case doneEditingButtonTapped case editButtonTapped case editSyncUp(PresentationAction<SyncUpForm.Action>) @CasePathable enum Alert { case confirmButtonTapped } } var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .alert: return .none case .cancelEditButtonTapped: state.editSyncUp = nil return .none case .deleteButtonTapped: return .none case .doneEditingButtonTapped: guard let editedSyncUp = state.editSyncUp?.syncUp else { return .none } state.$syncUp.withLock { $0 = editedSyncUp } state.editSyncUp = nil return .none case .editButtonTapped: state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) return .none case .editSyncUp: return .none } } .ifLet(\.$editSyncUp, action: \.editSyncUp) { SyncUpForm() } .ifLet(\.$alert, action: \.alert) } } struct SyncUpDetailView: View { // ... }
Populate the
alert
property by constructingAlertState
. This is done by providing three trailing closures: one for the title, one for the buttons to show in the alert, and another for the message in the alert.SyncUpDetail.swift
EditingAndDeletingSyncUp-02-code-0006.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @ObservableState struct State: Equatable { @Presents var alert: AlertState<Action.Alert>? @Presents var editSyncUp: SyncUpForm.State? @Shared var syncUp: SyncUp } enum Action { case alert(PresentationAction<Alert>) case cancelEditButtonTapped case deleteButtonTapped case doneEditingButtonTapped case editButtonTapped case editSyncUp(PresentationAction<SyncUpForm.Action>) @CasePathable enum Alert { case confirmButtonTapped } } var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .alert: return .none case .cancelEditButtonTapped: state.editSyncUp = nil return .none case .deleteButtonTapped: state.alert = AlertState { TextState("Delete?") } actions: { ButtonState(role: .destructive, action: .confirmButtonTapped) { TextState("Yes") } ButtonState(role: .cancel) { TextState("Nevermind") } } message: { TextState("Are you sure you want to delete this meeting?") } return .none case .doneEditingButtonTapped: guard let editedSyncUp = state.editSyncUp?.syncUp else { return .none } state.$syncUp.withLock { $0 = editedSyncUp } state.editSyncUp = nil return .none case .editButtonTapped: state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) return .none case .editSyncUp: return .none } } .ifLet(\.$editSyncUp, action: \.editSyncUp) { SyncUpForm() } .ifLet(\.$alert, action: \.alert) } } struct SyncUpDetailView: View { // ... }
Extract the alert state to an extension on
AlertState
.SyncUpDetail.swift
EditingAndDeletingSyncUp-02-code-0007.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @ObservableState struct State: Equatable { @Presents var alert: AlertState<Action.Alert>? @Presents var editSyncUp: SyncUpForm.State? @Shared var syncUp: SyncUp } enum Action { case alert(PresentationAction<Alert>) case cancelEditButtonTapped case deleteButtonTapped case doneEditingButtonTapped case editButtonTapped case editSyncUp(PresentationAction<SyncUpForm.Action>) @CasePathable enum Alert { case confirmButtonTapped } } var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .alert: return .none case .cancelEditButtonTapped: state.editSyncUp = nil return .none case .deleteButtonTapped: state.alert = .deleteSyncUp return .none case .doneEditingButtonTapped: guard let editedSyncUp = state.editSyncUp?.syncUp else { return .none } state.$syncUp.withLock { $0 = editedSyncUp } state.editSyncUp = nil return .none case .editButtonTapped: state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) return .none case .editSyncUp: return .none } } .ifLet(\.$editSyncUp, action: \.editSyncUp) { SyncUpForm() } .ifLet(\.$alert, action: \.alert) } } extension AlertState where Action == SyncUpDetail.Action.Alert { static let deleteSyncUp = Self { TextState("Delete?") } actions: { ButtonState(role: .destructive, action: .confirmButtonTapped) { TextState("Yes") } ButtonState(role: .cancel) { TextState("Nevermind") } } message: { TextState("Are you sure you want to delete this meeting?") } } struct SyncUpDetailView: View { // ... }
Destructure the new
.alert
actions in theswitch
statement of the coreReduce
.SyncUpDetail.swift
EditingAndDeletingSyncUp-02-code-0008.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @ObservableState struct State: Equatable { @Presents var alert: AlertState<Action.Alert>? @Presents var editSyncUp: SyncUpForm.State? @Shared var syncUp: SyncUp } enum Action { case alert(PresentationAction<Alert>) case cancelEditButtonTapped case deleteButtonTapped case doneEditingButtonTapped case editButtonTapped case editSyncUp(PresentationAction<SyncUpForm.Action>) @CasePathable enum Alert { case confirmButtonTapped } } var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .alert(.presented(.confirmButtonTapped)): case .alert(.dismiss): case .cancelEditButtonTapped: state.editSyncUp = nil return .none case .deleteButtonTapped: state.alert = .deleteSyncUp return .none case .doneEditingButtonTapped: guard let editedSyncUp = state.editSyncUp?.syncUp else { return .none } state.$syncUp.withLock { $0 = editedSyncUp } state.editSyncUp = nil return .none case .editButtonTapped: state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) return .none case .editSyncUp: return .none } } .ifLet(\.$editSyncUp, action: \.editSyncUp) { SyncUpForm() } .ifLet(\.$alert, action: \.alert) } } extension AlertState where Action == SyncUpDetail.Action.Alert { static let deleteSyncUp = Self { TextState("Delete?") } actions: { ButtonState(role: .destructive, action: .confirmButtonTapped) { TextState("Yes") } ButtonState(role: .cancel) { TextState("Nevermind") } } message: { TextState("Are you sure you want to delete this meeting?") } } struct SyncUpDetailView: View { // ... }
There is nothing to do in the
.alert(.dismiss)
case because theifLet
operator will automatically clean up the state for you.SyncUpDetail.swift
EditingAndDeletingSyncUp-02-code-0009.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @ObservableState struct State: Equatable { @Presents var alert: AlertState<Action.Alert>? @Presents var editSyncUp: SyncUpForm.State? @Shared var syncUp: SyncUp } enum Action { case alert(PresentationAction<Alert>) case cancelEditButtonTapped case deleteButtonTapped case doneEditingButtonTapped case editButtonTapped case editSyncUp(PresentationAction<SyncUpForm.Action>) @CasePathable enum Alert { case confirmButtonTapped } } var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .alert(.presented(.confirmButtonTapped)): case .alert(.dismiss): return .none case .cancelEditButtonTapped: state.editSyncUp = nil return .none case .deleteButtonTapped: state.alert = .deleteSyncUp return .none case .doneEditingButtonTapped: guard let editedSyncUp = state.editSyncUp?.syncUp else { return .none } state.$syncUp.withLock { $0 = editedSyncUp } state.editSyncUp = nil return .none case .editButtonTapped: state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) return .none case .editSyncUp: return .none } } .ifLet(\.$editSyncUp, action: \.editSyncUp) { SyncUpForm() } .ifLet(\.$alert, action: \.alert) } } extension AlertState where Action == SyncUpDetail.Action.Alert { static let deleteSyncUp = Self { TextState("Delete?") } actions: { ButtonState(role: .destructive, action: .confirmButtonTapped) { TextState("Yes") } ButtonState(role: .cancel) { TextState("Nevermind") } } message: { TextState("Are you sure you want to delete this meeting?") } } struct SyncUpDetailView: View { // ... }
Use the
@Shared
property wrapper with the.syncUps
key to get a reference to the sync-ups loaded from disk.SyncUpDetail.swift
EditingAndDeletingSyncUp-02-code-0010.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @ObservableState struct State: Equatable { @Presents var alert: AlertState<Action.Alert>? @Presents var editSyncUp: SyncUpForm.State? @Shared var syncUp: SyncUp } enum Action { case alert(PresentationAction<Alert>) case cancelEditButtonTapped case deleteButtonTapped case doneEditingButtonTapped case editButtonTapped case editSyncUp(PresentationAction<SyncUpForm.Action>) @CasePathable enum Alert { case confirmButtonTapped } } var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .alert(.presented(.confirmButtonTapped)): @Shared(.fileStorage(.syncUps)) var syncUps: IdentifiedArrayOf<SyncUp> = [] case .alert(.dismiss): return .none case .cancelEditButtonTapped: state.editSyncUp = nil return .none case .deleteButtonTapped: state.alert = .deleteSyncUp return .none case .doneEditingButtonTapped: guard let editedSyncUp = state.editSyncUp?.syncUp else { return .none } state.$syncUp.withLock { $0 = editedSyncUp } state.editSyncUp = nil return .none case .editButtonTapped: state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) return .none case .editSyncUp: return .none } } .ifLet(\.$editSyncUp, action: \.editSyncUp) { SyncUpForm() } .ifLet(\.$alert, action: \.alert) } } extension AlertState where Action == SyncUpDetail.Action.Alert { static let deleteSyncUp = Self { TextState("Delete?") } actions: { ButtonState(role: .destructive, action: .confirmButtonTapped) { TextState("Yes") } ButtonState(role: .cancel) { TextState("Nevermind") } } message: { TextState("Are you sure you want to delete this meeting?") } } struct SyncUpDetailView: View { // ... }
Use the
id
of the sync-up held in the detail’s state to remove it from thesyncUps
collection persisted to disk.SyncUpDetail.swift
EditingAndDeletingSyncUp-02-code-0011.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @ObservableState struct State: Equatable { @Presents var alert: AlertState<Action.Alert>? @Presents var editSyncUp: SyncUpForm.State? @Shared var syncUp: SyncUp } enum Action { case alert(PresentationAction<Alert>) case cancelEditButtonTapped case deleteButtonTapped case doneEditingButtonTapped case editButtonTapped case editSyncUp(PresentationAction<SyncUpForm.Action>) @CasePathable enum Alert { case confirmButtonTapped } } var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .alert(.presented(.confirmButtonTapped)): @Shared(.syncUps) var syncUps $syncUps.withLock { _ = $0.remove(id: state.syncUp.id) } return .none case .alert(.dismiss): return .none case .cancelEditButtonTapped: state.editSyncUp = nil return .none case .deleteButtonTapped: state.alert = .deleteSyncUp return .none case .doneEditingButtonTapped: guard let editedSyncUp = state.editSyncUp?.syncUp else { return .none } state.$syncUp.withLock { $0 = editedSyncUp } state.editSyncUp = nil return .none case .editButtonTapped: state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) return .none case .editSyncUp: return .none } } .ifLet(\.$editSyncUp, action: \.editSyncUp) { SyncUpForm() } .ifLet(\.$alert, action: \.alert) } } extension AlertState where Action == SyncUpDetail.Action.Alert { static let deleteSyncUp = Self { TextState("Delete?") } actions: { ButtonState(role: .destructive, action: .confirmButtonTapped) { TextState("Yes") } ButtonState(role: .cancel) { TextState("Nevermind") } } message: { TextState("Are you sure you want to delete this meeting?") } } struct SyncUpDetailView: View { // ... }
Add a dependency on
DismissEffect
to theSyncUpDetail
feature by using the@Dependency
property wrapper.SyncUpDetail.swift
EditingAndDeletingSyncUp-02-code-0012.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @ObservableState struct State: Equatable { @Presents var alert: AlertState<Action.Alert>? @Presents var editSyncUp: SyncUpForm.State? @Shared var syncUp: SyncUp } enum Action { case alert(PresentationAction<Alert>) case cancelEditButtonTapped case deleteButtonTapped case doneEditingButtonTapped case editButtonTapped case editSyncUp(PresentationAction<SyncUpForm.Action>) @CasePathable enum Alert { case confirmButtonTapped } } @Dependency(\.dismiss) var dismiss var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .alert(.presented(.confirmButtonTapped)): @Shared(.syncUps) var syncUps $syncUps.withLock { _ = $0.remove(id: state.syncUp.id) } return .none case .alert(.dismiss): return .none case .cancelEditButtonTapped: state.editSyncUp = nil return .none case .deleteButtonTapped: state.alert = .deleteSyncUp return .none case .doneEditingButtonTapped: guard let editedSyncUp = state.editSyncUp?.syncUp else { return .none } state.$syncUp.withLock { $0 = editedSyncUp } state.editSyncUp = nil return .none case .editButtonTapped: state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) return .none case .editSyncUp: return .none } } .ifLet(\.$editSyncUp, action: \.editSyncUp) { SyncUpForm() } .ifLet(\.$alert, action: \.alert) } } extension AlertState where Action == SyncUpDetail.Action.Alert { static let deleteSyncUp = Self { TextState("Delete?") } actions: { ButtonState(role: .destructive, action: .confirmButtonTapped) { TextState("Yes") } ButtonState(role: .cancel) { TextState("Nevermind") } } message: { TextState("Are you sure you want to delete this meeting?") } } struct SyncUpDetailView: View { // ... }
Return a
.run
effect to invoke thedismiss
effect.SyncUpDetail.swift
EditingAndDeletingSyncUp-02-code-0013.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @ObservableState struct State: Equatable { @Presents var alert: AlertState<Action.Alert>? @Presents var editSyncUp: SyncUpForm.State? @Shared var syncUp: SyncUp } enum Action { case alert(PresentationAction<Alert>) case cancelEditButtonTapped case deleteButtonTapped case doneEditingButtonTapped case editButtonTapped case editSyncUp(PresentationAction<SyncUpForm.Action>) @CasePathable enum Alert { case confirmButtonTapped } } @Dependency(\.dismiss) var dismiss var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .alert(.presented(.confirmButtonTapped)): @Shared(.syncUps) var syncUps $syncUps.withLock { _ = $0.remove(id: state.syncUp.id) } return .run { _ in await dismiss() } case .alert(.dismiss): return .none case .cancelEditButtonTapped: state.editSyncUp = nil return .none case .deleteButtonTapped: state.alert = .deleteSyncUp return .none case .doneEditingButtonTapped: guard let editedSyncUp = state.editSyncUp?.syncUp else { return .none } state.$syncUp.withLock { $0 = editedSyncUp } state.editSyncUp = nil return .none case .editButtonTapped: state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) return .none case .editSyncUp: return .none } } .ifLet(\.$editSyncUp, action: \.editSyncUp) { SyncUpForm() } .ifLet(\.$alert, action: \.alert) } } extension AlertState where Action == SyncUpDetail.Action.Alert { static let deleteSyncUp = Self { TextState("Delete?") } actions: { ButtonState(role: .destructive, action: .confirmButtonTapped) { TextState("Yes") } ButtonState(role: .cancel) { TextState("Nevermind") } } message: { TextState("Are you sure you want to delete this meeting?") } } struct SyncUpDetailView: View { // ... }
In the
SyncUpDetailView
, go to the bottom and use theSwiftUI/View/alert(_:)
view modifier that comes with the library. This will cause an alert to be presented when thealert
state is populated.SyncUpDetail.swift
EditingAndDeletingSyncUp-02-code-0014.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { // ... } struct SyncUpDetailView: View { @Bindable var store: StoreOf<SyncUpDetail> var body: some View { Form { Section { NavigationLink { } label: { Label("Start Meeting", systemImage: "timer") .font(.headline) .foregroundColor(.accentColor) } HStack { Label("Length", systemImage: "clock") Spacer() Text(store.syncUp.duration.formatted(.units())) } HStack { Label("Theme", systemImage: "paintpalette") Spacer() Text(store.syncUp.theme.name) .padding(4) .foregroundColor(store.syncUp.theme.accentColor) .background(store.syncUp.theme.mainColor) .cornerRadius(4) } } header: { Text("Sync-up Info") } if !store.syncUp.meetings.isEmpty { Section { ForEach(store.syncUp.meetings) { meeting in Button { } label: { HStack { Image(systemName: "calendar") Text(meeting.date, style: .date) Text(meeting.date, style: .time) } } } } header: { Text("Past meetings") } } Section { ForEach(store.syncUp.attendees) { attendee in Label(attendee.name, systemImage: "person") } } header: { Text("Attendees") } Section { Button("Delete") { store.send(.deleteButtonTapped) } .foregroundColor(.red) .frame(maxWidth: .infinity) } } .navigationTitle(Text(store.syncUp.title)) .toolbar { Button("Edit") { store.send(.editButtonTapped) } } .alert($store.scope(state: \.alert, action: \.alert)) .sheet(item: $store.scope(state: \.editSyncUp, action: \.editSyncUp)) { editSyncUpStore in NavigationStack { SyncUpFormView(store: editSyncUpStore) .navigationTitle(store.syncUp.title) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { store.send(.cancelEditButtonTapped) } } ToolbarItem(placement: .confirmationAction) { Button("Done") { store.send(.doneEditingButtonTapped) } } } } } } } #Preview { NavigationStack { SyncUpDetailView( store: Store( initialState: SyncUpDetail.State( syncUp: Shared(value: .mock) ) ) { SyncUpDetail() } ) } }
More concise domain modeling
The SyncUpDetail
works well enough right now, but there is something not ideal about how its domain is modeled. We are representing the alert and edit sheet using two independent optional values, which allows for invalid states. We only expect that either the alert or sheet is shown at one time, but it is possible for both optionals to be non-nil
at the same time.
And this problem gets bigger with the most destinations you can navigate to from a feature. For example, if there are 4 possible destinations one can navigate to, then modeling that with optionals leads to 2^4 = 16 possible states, only 5 of which are valid. Either all optionals should be nil
or exactly one should be non-nil
.
This kind of inconcise domain modeling can leak complexity into your features, and luckily there is a better way.
That is all it takes to define a dedicated Destination
reducer that encapsulates all of the logic and behavior of the features that can be navigated to. You can right click on the @Reducer
macro and select “Expand macro” in Xcode to see all of the code that is being written for you.
Next we need to integrate this new Destination
reducer into the main SyncUpDetail
reducer.
That is all it takes to integrate the Destination
reducer into the SyncUpDetail
reducer. Next we have to update the core Reduce
to handle the new destination state and actions properly.
That’s all it takes to fully integrate the Destination
reducer into the SyncUpDetail
reducer. Next we have to update the view so that we can drive the presentation of the alert and sheet from the destination enum.
That is all it takes to finish the refactor to use an enum to drive navigation rather than multiple optionals. Our domain is more concisely modeled, and we can know precisely when something is being presented. We merely have to check if destination != nil
.
Create a whole new reducer nested inside the
SyncUpDetail
calledDestination
. This reducer will represent all of the places one can navigate to from the detail screen, and that will give us a single piece of state to drive navigation rather than having multiple optional values.Further, the
Destination
type will be an enum. This is different than other reducers we have encountered so far. The@Reducer
has special behavior when applied to enums that allows one to compose multiple reducers into a single package.SyncUpDetail.swift
EditingAndDeletingSyncUp-03-code-0001.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @Reducer enum Destination { } // ... } extension SyncUpDetail.Destination.State: Equatable {} struct SyncUpDetailView: View { // ... }
Add a case for each destination that can be navigated to from this feature. Each case will hold onto the reducer type of the feature being navigated to, except for the
alert
case. It will simply hold ontoAlertState
.We will also move the
Alert
action enum from theSyncUpDetail.Action
type to be nested insideDestination
.SyncUpDetail.swift
EditingAndDeletingSyncUp-03-code-0002.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @Reducer enum Destination { case alert(AlertState<Alert>) case edit(SyncUpForm) @CasePathable enum Alert { case confirmButtonTapped } } // ... } extension SyncUpDetail.Destination.State: Equatable {} struct SyncUpDetailView: View { // ... }
Replace the two independent pieces of optional state in
SyncUpDetail.State
for a single piece of optionalDestination.State
.We now have just one single piece of optional state that determines if navigation is active and where we are being navigated to.
SyncUpDetail.swift
EditingAndDeletingSyncUp-03-code-0003.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @Reducer enum Destination { case alert(AlertState<Alert>) case edit(SyncUpForm) @CasePathable enum Alert { case confirmButtonTapped } } @ObservableState struct State: Equatable { // @Presents var alert: AlertState<Action.Alert>? // @Presents var editSyncUp: SyncUpForm.State? @Presents var destination: Destination.State? @Shared var syncUp: SyncUp } enum Action { case alert(PresentationAction<Alert>) case cancelEditButtonTapped case deleteButtonTapped case doneEditingButtonTapped case editButtonTapped case editSyncUp(PresentationAction<SyncUpForm.Action>) @CasePathable enum Alert { case confirmButtonTapped } } @Dependency(\.dismiss) var dismiss var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .alert(.presented(.confirmButtonTapped)): @Shared(.syncUps) var syncUps $syncUps.withLock { _ = $0.remove(id: state.syncUp.id) } return .run { _ in await dismiss() } case .alert(.dismiss): return .none case .cancelEditButtonTapped: state.editSyncUp = nil return .none case .delegate: return .none case .deleteButtonTapped: state.alert = .deleteSyncUp return .none case .doneEditingButtonTapped: guard let editedSyncUp = state.editSyncUp?.syncUp else { return .none } state.$syncUp.withLock { $0 = editedSyncUp } state.editSyncUp = nil return .none case .editButtonTapped: state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) return .none case .editSyncUp: return .none } } .ifLet(\.$editSyncUp, action: \.editSyncUp) { SyncUpForm() } .ifLet(\.$alert, action: \.alert) } } extension SyncUpDetail.Destination.State: Equatable {} extension AlertState where Action == SyncUpDetail.Action.Alert { static let deleteSyncUp = Self { TextState("Delete?") } actions: { ButtonState(role: .destructive, action: .confirmButtonTapped) { TextState("Yes") } ButtonState(role: .cancel) { TextState("Nevermind") } } message: { TextState("Are you sure you want to delete this meeting?") } } struct SyncUpDetailView: View { // ... }
Replace the two action cases for the alert and edit sheet with a single case that holds on to a
PresentationAction
ofDestination.Action
.SyncUpDetail.swift
EditingAndDeletingSyncUp-03-code-0004.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @Reducer enum Destination { case alert(AlertState<Alert>) case edit(SyncUpForm) @CasePathable enum Alert { case confirmButtonTapped } } @ObservableState struct State: Equatable { @Presents var destination: Destination.State? @Shared var syncUp: SyncUp } enum Action { // case alert(PresentationAction<Alert>) case cancelEditButtonTapped case deleteButtonTapped case destination(PresentationAction<Destination.Action>) case doneEditingButtonTapped case editButtonTapped // case editSyncUp(PresentationAction<SyncUpForm.Action>) // enum Alert { // case confirmButtonTapped // } } @Dependency(\.dismiss) var dismiss var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .alert(.presented(.confirmButtonTapped)): @Shared(.syncUps) var syncUps $syncUps.withLock { _ = $0.remove(id: state.syncUp.id) } return .run { _ in await dismiss() } case .alert(.dismiss): return .none case .cancelEditButtonTapped: state.editSyncUp = nil return .none case .delegate: return .none case .deleteButtonTapped: state.alert = .deleteSyncUp return .none case .doneEditingButtonTapped: guard let editedSyncUp = state.editSyncUp?.syncUp else { return .none } state.$syncUp.withLock { $0 = editedSyncUp } state.editSyncUp = nil return .none case .editButtonTapped: state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) return .none case .editSyncUp: return .none } } .ifLet(\.$editSyncUp, action: \.editSyncUp) { SyncUpForm() } .ifLet(\.$alert, action: \.alert) } } extension SyncUpDetail.Destination.State: Equatable {} extension AlertState where Action == SyncUpDetail.Action.Alert { static let deleteSyncUp = Self { TextState("Delete?") } actions: { ButtonState(role: .destructive, action: .confirmButtonTapped) { TextState("Yes") } ButtonState(role: .cancel) { TextState("Nevermind") } } message: { TextState("Are you sure you want to delete this meeting?") } } struct SyncUpDetailView: View { // ... }
Replace the two
ifLet
operators at the bottom ofSyncUpDetail
with a single one that composes theDestination
reducer.SyncUpDetail.swift
EditingAndDeletingSyncUp-03-code-0005.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @Reducer enum Destination { case alert(AlertState<Alert>) case edit(SyncUpForm) @CasePathable enum Alert { case confirmButtonTapped } } @ObservableState struct State: Equatable { @Presents var destination: Destination.State? @Shared var syncUp: SyncUp } enum Action { case cancelEditButtonTapped case deleteButtonTapped case destination(PresentationAction<Destination.Action>) case doneEditingButtonTapped case editButtonTapped } @Dependency(\.dismiss) var dismiss var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .alert(.presented(.confirmButtonTapped)): @Shared(.syncUps) var syncUps $syncUps.withLock { _ = $0.remove(id: state.syncUp.id) } return .run { _ in await dismiss() } case .alert(.dismiss): return .none case .cancelEditButtonTapped: state.editSyncUp = nil return .none case .delegate: return .none case .deleteButtonTapped: state.alert = .deleteSyncUp return .none case .destination: return .none case .doneEditingButtonTapped: guard let editedSyncUp = state.editSyncUp?.syncUp else { return .none } state.$syncUp.withLock { $0 = editedSyncUp } state.editSyncUp = nil return .none case .editButtonTapped: state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) return .none } } // .ifLet(\.$editSyncUp, action: \.editSyncUp) { // SyncUpForm() // } // .ifLet(\.$alert, action: \.alert) .ifLet(\.$destination, action: \.destination) } } extension SyncUpDetail.Destination.State: Equatable {} extension AlertState where Action == SyncUpDetail.Action.Alert { static let deleteSyncUp = Self { TextState("Delete?") } actions: { ButtonState(role: .destructive, action: .confirmButtonTapped) { TextState("Yes") } ButtonState(role: .cancel) { TextState("Nevermind") } } message: { TextState("Are you sure you want to delete this meeting?") } } struct SyncUpDetailView: View { // ... }
Update the pattern matching for the alert to go through the
.destination(.presented)
case.SyncUpDetail.swift
EditingAndDeletingSyncUp-03-code-0006.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @Reducer enum Destination { case alert(AlertState<Alert>) case edit(SyncUpForm) @CasePathable enum Alert { case confirmButtonTapped } } @ObservableState struct State: Equatable { @Presents var destination: Destination.State? @Shared var syncUp: SyncUp } enum Action { case cancelEditButtonTapped case deleteButtonTapped case destination(PresentationAction<Destination.Action>) case doneEditingButtonTapped case editButtonTapped } @Dependency(\.dismiss) var dismiss var body: some ReducerOf<Self> { Reduce { state, action in switch action { // case .alert(.presented(.confirmButtonTapped)): case .destination(.presented(.alert(.confirmButtonTapped))): @Shared(.syncUps) var syncUps $syncUps.withLock { _ = $0.remove(id: state.syncUp.id) } return .run { _ in await dismiss() } case .alert(.dismiss): return .none case .cancelEditButtonTapped: state.editSyncUp = nil return .none case .delegate: return .none case .deleteButtonTapped: state.alert = .deleteSyncUp return .none case .destination: return .none case .doneEditingButtonTapped: guard let editedSyncUp = state.editSyncUp?.syncUp else { return .none } state.$syncUp.withLock { $0 = editedSyncUp } state.editSyncUp = nil return .none case .editButtonTapped: state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) return .none } } .ifLet(\.$destination, action: \.destination) } } extension SyncUpDetail.Destination.State: Equatable {} extension AlertState where Action == SyncUpDetail.Action.Alert { static let deleteSyncUp = Self { TextState("Delete?") } actions: { ButtonState(role: .destructive, action: .confirmButtonTapped) { TextState("Yes") } ButtonState(role: .cancel) { TextState("Nevermind") } } message: { TextState("Are you sure you want to delete this meeting?") } } struct SyncUpDetailView: View { // ... }
Return
.none
for all other.destination
actions.SyncUpDetail.swift
EditingAndDeletingSyncUp-03-code-0007.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @Reducer enum Destination { case alert(AlertState<Alert>) case edit(SyncUpForm) @CasePathable enum Alert { case confirmButtonTapped } } @ObservableState struct State: Equatable { @Presents var destination: Destination.State? @Shared var syncUp: SyncUp } enum Action { case cancelEditButtonTapped case deleteButtonTapped case destination(PresentationAction<Destination.Action>) case doneEditingButtonTapped case editButtonTapped } @Dependency(\.dismiss) var dismiss var body: some ReducerOf<Self> { Reduce { state, action in switch action { // case .alert(.presented(.confirmButtonTapped)): case .destination(.presented(.alert(.confirmButtonTapped))): @Shared(.syncUps) var syncUps $syncUps.withLock { _ = $0.remove(id: state.syncUp.id) } return .run { _ in await dismiss() } case .destination: return .none case .cancelEditButtonTapped: state.editSyncUp = nil return .none case .delegate: return .none case .deleteButtonTapped: state.alert = .deleteSyncUp return .none case .doneEditingButtonTapped: guard let editedSyncUp = state.editSyncUp?.syncUp else { return .none } state.$syncUp.withLock { $0 = editedSyncUp } state.editSyncUp = nil return .none case .editButtonTapped: state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) return .none } } .ifLet(\.$destination, action: \.destination) } } extension SyncUpDetail.Destination.State: Equatable {} extension AlertState where Action == SyncUpDetail.Action.Alert { static let deleteSyncUp = Self { TextState("Delete?") } actions: { ButtonState(role: .destructive, action: .confirmButtonTapped) { TextState("Yes") } ButtonState(role: .cancel) { TextState("Nevermind") } } message: { TextState("Are you sure you want to delete this meeting?") } } struct SyncUpDetailView: View { // ... }
Update where we
nil
outeditSyncUp
state to insteadnil
out thedestination
state.SyncUpDetail.swift
EditingAndDeletingSyncUp-03-code-0008.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @Reducer enum Destination { case alert(AlertState<Alert>) case edit(SyncUpForm) @CasePathable enum Alert { case confirmButtonTapped } } @ObservableState struct State: Equatable { @Presents var destination: Destination.State? @Shared var syncUp: SyncUp } enum Action { case cancelEditButtonTapped case deleteButtonTapped case destination(PresentationAction<Destination.Action>) case doneEditingButtonTapped case editButtonTapped } @Dependency(\.dismiss) var dismiss var body: some ReducerOf<Self> { Reduce { state, action in switch action { // case .alert(.presented(.confirmButtonTapped)): case .destination(.presented(.alert(.confirmButtonTapped))): @Shared(.syncUps) var syncUps $syncUps.withLock { _ = $0.remove(id: state.syncUp.id) } return .run { _ in await dismiss() } case .destination: return .none case .cancelEditButtonTapped: state.destination = nil return .none case .delegate: return .none case .deleteButtonTapped: state.alert = .deleteSyncUp return .none case .doneEditingButtonTapped: guard let editedSyncUp = state.editSyncUp?.syncUp else { return .none } state.$syncUp.withLock { $0 = editedSyncUp } state.editSyncUp = nil return .none case .editButtonTapped: state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) return .none } } .ifLet(\.$destination, action: \.destination) } } extension SyncUpDetail.Destination.State: Equatable {} extension AlertState where Action == SyncUpDetail.Action.Alert { static let deleteSyncUp = Self { TextState("Delete?") } actions: { ButtonState(role: .destructive, action: .confirmButtonTapped) { TextState("Yes") } ButtonState(role: .cancel) { TextState("Nevermind") } } message: { TextState("Are you sure you want to delete this meeting?") } } struct SyncUpDetailView: View { // ... }
Update where we populate the
alert
state to instead point thedestination
state to the.alert
case.SyncUpDetail.swift
EditingAndDeletingSyncUp-03-code-0009.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @Reducer enum Destination { case alert(AlertState<Alert>) case edit(SyncUpForm) @CasePathable enum Alert { case confirmButtonTapped } } @ObservableState struct State: Equatable { @Presents var destination: Destination.State? @Shared var syncUp: SyncUp } enum Action { case cancelEditButtonTapped case deleteButtonTapped case destination(PresentationAction<Destination.Action>) case doneEditingButtonTapped case editButtonTapped } @Dependency(\.dismiss) var dismiss var body: some ReducerOf<Self> { Reduce { state, action in switch action { // case .alert(.presented(.confirmButtonTapped)): case .destination(.presented(.alert(.confirmButtonTapped))): @Shared(.syncUps) var syncUps $syncUps.withLock { _ = $0.remove(id: state.syncUp.id) } return .run { _ in await dismiss() } case .destination: return .none case .cancelEditButtonTapped: state.destination = nil return .none case .deleteButtonTapped: // state.alert = .deleteSyncUp state.destination = .alert(.deleteSyncUp) return .none case .doneEditingButtonTapped: guard let editedSyncUp = state.editSyncUp?.syncUp else { return .none } state.$syncUp.withLock { $0 = editedSyncUp } state.editSyncUp = nil return .none case .editButtonTapped: state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) return .none } } .ifLet(\.$destination, action: \.destination) } } extension SyncUpDetail.Destination.State: Equatable {} extension AlertState where Action == SyncUpDetail.Action.Alert { static let deleteSyncUp = Self { TextState("Delete?") } actions: { ButtonState(role: .destructive, action: .confirmButtonTapped) { TextState("Yes") } ButtonState(role: .cancel) { TextState("Nevermind") } } message: { TextState("Are you sure you want to delete this meeting?") } } struct SyncUpDetailView: View { // ... }
Update the
.doneEditingButtonTapped
action by grabbing the edited sync-up from the.edit
case of thedestination
enum.SyncUpDetail.swift
EditingAndDeletingSyncUp-03-code-0010.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @Reducer enum Destination { case alert(AlertState<Alert>) case edit(SyncUpForm) @CasePathable enum Alert { case confirmButtonTapped } } @ObservableState struct State: Equatable { @Presents var destination: Destination.State? @Shared var syncUp: SyncUp } enum Action { case cancelEditButtonTapped case deleteButtonTapped case destination(PresentationAction<Destination.Action>) case doneEditingButtonTapped case editButtonTapped } @Dependency(\.dismiss) var dismiss var body: some ReducerOf<Self> { Reduce { state, action in switch action { // case .alert(.presented(.confirmButtonTapped)): case .destination(.presented(.alert(.confirmButtonTapped))): @Shared(.syncUps) var syncUps $syncUps.withLock { _ = $0.remove(id: state.syncUp.id) } return .run { _ in await dismiss() } case .destination: return .none case .cancelEditButtonTapped: state.destination = nil return .none case .deleteButtonTapped: state.destination = .alert(.deleteSyncUp) return .none case .doneEditingButtonTapped: guard let editedSyncUp = state.destination?.edit?.syncUp else { return .none } state.$syncUp.withLock { $0 = editedSyncUp } state.destination = nil return .none case .editButtonTapped: state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) return .none } } .ifLet(\.$destination, action: \.destination) } } extension SyncUpDetail.Destination.State: Equatable {} extension AlertState where Action == SyncUpDetail.Action.Alert { static let deleteSyncUp = Self { TextState("Delete?") } actions: { ButtonState(role: .destructive, action: .confirmButtonTapped) { TextState("Yes") } ButtonState(role: .cancel) { TextState("Nevermind") } } message: { TextState("Are you sure you want to delete this meeting?") } } struct SyncUpDetailView: View { // ... }
Update where we previously populated the
editSyncUp
state to instead point thedestination
enum to the.edit
case with someSyncUpForm
state.SyncUpDetail.swift
EditingAndDeletingSyncUp-03-code-0011.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @Reducer enum Destination { case alert(AlertState<Alert>) case edit(SyncUpForm) @CasePathable enum Alert { case confirmButtonTapped } } @ObservableState struct State: Equatable { @Presents var destination: Destination.State? @Shared var syncUp: SyncUp } enum Action { case cancelEditButtonTapped case deleteButtonTapped case destination(PresentationAction<Destination.Action>) case doneEditingButtonTapped case editButtonTapped } @Dependency(\.dismiss) var dismiss var body: some ReducerOf<Self> { Reduce { state, action in switch action { // case .alert(.presented(.confirmButtonTapped)): case .destination(.presented(.alert(.confirmButtonTapped))): @Shared(.syncUps) var syncUps $syncUps.withLock { _ = $0.remove(id: state.syncUp.id) } return .run { _ in await dismiss() } case .destination: return .none case .cancelEditButtonTapped: state.destination = nil return .none case .deleteButtonTapped: state.destination = .alert(.deleteSyncUp) return .none case .doneEditingButtonTapped: guard let editedSyncUp = state.destination?.edit?.syncUp else { return .none } state.$syncUp.withLock { $0 = editedSyncUp } state.destination = nil return .none case .editButtonTapped: // state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) state.destination = .edit(SyncUpForm.State(syncUp: state.syncUp)) return .none } } .ifLet(\.$destination, action: \.destination) } } extension SyncUpDetail.Destination.State: Equatable {} extension AlertState where Action == SyncUpDetail.Action.Alert { static let deleteSyncUp = Self { TextState("Delete?") } actions: { ButtonState(role: .destructive, action: .confirmButtonTapped) { TextState("Yes") } ButtonState(role: .cancel) { TextState("Nevermind") } } message: { TextState("Are you sure you want to delete this meeting?") } } struct SyncUpDetailView: View { // ... }
Update the extension on
AlertState
to point to theDestination
’s alert action.SyncUpDetail.swift
EditingAndDeletingSyncUp-03-code-0012.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { @Reducer enum Destination { case alert(AlertState<Alert>) case edit(SyncUpForm) @CasePathable enum Alert { case confirmButtonTapped } } @ObservableState struct State: Equatable { @Presents var destination: Destination.State? @Shared var syncUp: SyncUp } enum Action { case cancelEditButtonTapped case deleteButtonTapped case destination(PresentationAction<Destination.Action>) case doneEditingButtonTapped case editButtonTapped } @Dependency(\.dismiss) var dismiss var body: some ReducerOf<Self> { Reduce { state, action in switch action { // case .alert(.presented(.confirmButtonTapped)): case .destination(.presented(.alert(.confirmButtonTapped))): @Shared(.syncUps) var syncUps $syncUps.withLock { _ = $0.remove(id: state.syncUp.id) } return .run { _ in await dismiss() } case .destination: return .none case .cancelEditButtonTapped: state.destination = nil return .none case .deleteButtonTapped: state.destination = .alert(.deleteSyncUp) return .none case .doneEditingButtonTapped: guard let editedSyncUp = state.destination?.edit?.syncUp else { return .none } state.$syncUp.withLock { $0 = editedSyncUp } state.destination = nil return .none case .editButtonTapped: // state.editSyncUp = SyncUpForm.State(syncUp: state.syncUp) state.destination = .edit(SyncUpForm.State(syncUp: state.syncUp)) return .none } } .ifLet(\.$destination, action: \.destination) } } extension SyncUpDetail.Destination.State: Equatable {} extension AlertState where Action == SyncUpDetail.Destination.Alert { static let deleteSyncUp = Self { TextState("Delete?") } actions: { ButtonState(role: .destructive, action: .confirmButtonTapped) { TextState("Yes") } ButtonState(role: .cancel) { TextState("Nevermind") } } message: { TextState("Are you sure you want to delete this meeting?") } } struct SyncUpDetailView: View { // ... }
Update the
SwiftUI/View/alert(_:)
and.sheet(item:)
modifiers at the bottom of the view so that the$store.scope
further singles out the case for driving navigation.SyncUpDetail.swift
EditingAndDeletingSyncUp-03-code-0013.swiftimport ComposableArchitecture import SwiftUI @Reducer struct SyncUpDetail { // ... } struct SyncUpDetailView: View { @Bindable var store: StoreOf<SyncUpDetail> var body: some View { Form { Section { NavigationLink { } label: { Label("Start Meeting", systemImage: "timer") .font(.headline) .foregroundColor(.accentColor) } HStack { Label("Length", systemImage: "clock") Spacer() Text(store.syncUp.duration.formatted(.units())) } HStack { Label("Theme", systemImage: "paintpalette") Spacer() Text(store.syncUp.theme.name) .padding(4) .foregroundColor(store.syncUp.theme.accentColor) .background(store.syncUp.theme.mainColor) .cornerRadius(4) } } header: { Text("Sync-up Info") } if !store.syncUp.meetings.isEmpty { Section { ForEach(store.syncUp.meetings) { meeting in Button { } label: { HStack { Image(systemName: "calendar") Text(meeting.date, style: .date) Text(meeting.date, style: .time) } } } } header: { Text("Past meetings") } } Section { ForEach(store.syncUp.attendees) { attendee in Label(attendee.name, systemImage: "person") } } header: { Text("Attendees") } Section { Button("Delete") { store.send(.deleteButtonTapped) } .foregroundColor(.red) .frame(maxWidth: .infinity) } } .toolbar { Button("Edit") { store.send(.editButtonTapped) } } .navigationTitle(Text(store.syncUp.title)) .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) .sheet( item: $store.scope(state: \.destination?.edit, action: \.destination.edit) ) { editSyncUpStore in NavigationStack { SyncUpFormView(store: editSyncUpStore) .navigationTitle(store.syncUp.title) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { store.send(.cancelEditButtonTapped) } } ToolbarItem(placement: .confirmationAction) { Button("Done") { store.send(.doneEditingButtonTapped) } } } } } } } #Preview { NavigationStack { SyncUpDetailView( store: Store( initialState: SyncUpDetail.State( syncUp: Shared(value: .mock) ) ) { SyncUpDetail() } ) } }