First of all, thanks for the amazing work on the Swift Composable Architecture!
I am playing with programmatic sheets dismissal. I would like to add a "Cancel" button in the sheet navigation bar that dismissed the sheet when pressed (like in the picture below).
Ideally, I would also like to put this logic in the reducer controlling the sheet, so that I can isolate them in their own module.
The best I could come up so far is trigger the action to dismiss the sheet from its reducer, but handling it from the "upper-level" reducer. While this approach is working perfectly, the logic to handle sheet actions is now spread across different reducers. Are there better approaches?
struct AppState: Equatable {
var items: [Item] = []
var addSheet: AddItemSheetState? = nil
var isAddSheetPresented: Bool { self.addSheet != nil }
}
struct AddItemSheetState: Equatable {
var input: String = ""
var inputTrimmed: String { self.input.trim() }
var isInputValid: Bool { self.inputTrimmed.count > 0 }
}
enum AppAction {
case addButtonTapped
case addSheetDismissed
case addSheet(_ action: AddItemSheetAction)
}
enum AddItemSheetAction {
case inputChanged(String)
case cancelButtonTapped
case doneButtonTapped(String)
}
let addItemSheetReducer = Reducer<AddItemSheetState, AddItemSheetAction, Void> { state, action, _ in
switch action {
case let .inputChanged(newValue):
state.input = newValue
return .none
// TODO: is there a better way to programmatically cancel the sheet from inside it?
case .cancelButtonTapped, .doneButtonTapped(_):
// currently, these action are handled in the "upper-level" reducer
return .none
}
}
let appReducer = addItemSheetReducer
.optional()
.pullback(
state: \.addSheet,
action: /AppAction.addSheet,
environment: { _ in }
)
.combined(
with: Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in
switch action {
case .addButtonTapped:
state.addSheet = .init()
return .none
case .addSheetDismissed:
state.addSheet = nil
return .none
// TODO: I currently handle sheet cancellation in the "upper-level" reducer
// Is there a way to push it to the "addItemSheetReducer"?
case .addSheet(.cancelButtonTapped):
state.addSheet = nil
return .none
// TODO: What is the best way to read the input from the sheet?
// I pass the input from the sheet through this action for convenience
case let .addSheet(.doneButtonTapped(name)):
// ... this is the alternative way, but I have to force the unwrapping of the optional state :(
let alternativeName = state.addSheet!.input
assert(name == alternativeName)
let item = Item(id: environment.id(), name: name)
state.items.append(item)
state.addSheet = nil
return .none
case .addSheet(_):
return .none
}
}
)
struct MainView: View {
let store: Store<AppState, AppAction>
var body: some View {
WithViewStore(self.store) { viewStore in
NavigationView {
List {
ForEach(viewStore.items) { item in
Text(item.name)
}
}
.navigationBarTitle("Homepage", displayMode: .inline)
.navigationBarItems(
trailing:
Button("Add") {
viewStore.send(.addButtonTapped)
}
)
.sheet(isPresented: viewStore.binding(
get: \.isAddSheetPresented,
send: .addSheetDismissed
)) {
IfLetStore(
self.store.scope(state: \.addSheet, action: AppAction.addSheet),
then: AddItemSheetView.init(store:)
)
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
}
Hey there, you've got two other options you can try out.
First of all, if you simply want the "Cancel" button to dismiss the modal with no other further logic you can use the environment value presentationMode to programmatically dismiss:
struct ModalView: View {
@Environment(\.presentationMode) var presentationMode
var body: some View {
Button("Cancel") { self.presentationMode.dismiss() }
}
}
This will automatically communicate to the binding that drives the modal, which in term causes an action to be sent to the store (since you are using the viewStore.binding helper), and so everything will "just work".
However, because the .dismiss() is happening entirely in the view, outside the purview of your modal reducer, you won't have the ability to do any custom logic when "Cancel" is tapped (e.g. maybe you want to track analytics or something).
So another way is to create some local state in your modal's domain, and then use that with the .onChange view modifier to determine when the view should invoke presentationMode.dismiss():
struct ModalState {
var isPresented = true
...
}
let modalReducer = Reducer<...> { state, action, environment in
switch action {
case .cancelButtonTapped:
state.isPresented = false
return .none
...
}
}
struct ModalView: View {
@Environment(\.presentationMode) var presentationMode
var body: some View {
Button("Cancel") { viewStore.send(.cancelButtonTapped) }
.onChange(viewStore.isPresented) { isPresented in
if !isPresented { presentationMode.dismiss() }
}
}
}
With the above you have full control over the dismissal of the modal. You could even fire off an async effect, and when you get a response you flip isPresented to false to dismiss.
Thanks for the quick answer. I appreciate it. I experimented with both solutions, and they work like a charm.
Now I am trying to refactor the logic for the "Done" button inside the modal reducer. Pressing "Done" should create a new item with the value in the TextField, append it to the list (shown in the parent view), and finally dismiss the modal. I am trying to use the second technique, but I struggle with appending the item to the list. I need somehow to expand the modal state to include also the list of items, but doing that breaks the nice encapsulation of AppState:
struct AppState: Equatable {
var items: [Item] = []
var modal: ModalState? = nil
...
}
I played a bit with it, but could not find an elegant way to do it. The best I could think of is passing the entire AppState to the modal reducer:
// this is working, but I feel like there is something wrong in the optional value unwrapping
let modalReducer = Reducer<AppState, ModalAction, AppEnvironment> { state, action, environment in
switch action {
case let .inputChanged(newValue):
state.modal?.input = newValue
return .none
case .cancelButtonTapped:
state.modal = nil
return .none
case .doneButtonTapped:
let item = Item(id: environment.id(), name: state.modal!.inputTrimmed)
state.items.append(item)
state.modal = nil
return .none
}
}
The nice thing is that I do not need the variable isPresented and the presentationMode environment value since I have direct access to the entire modal state and can use state.modal = nil in the modalReducer to dismiss the modal.
The things I do not like much:
The modalReducer now needs to handle nil values.
There is a mismatch between the state managed by the modalReducer and what the ModalView really needs (that still accepts a ModalState). Somehow, this feels wrong
struct ModalView: View {
let store: Store<ModalState, ModalAction>
...
}
There is something you can do that is kinda like sharing all of AppState with your modal domain, but it's a little more focused. You can create a new domain that holds everything your core modal feature needs along with any additional data you need from the AppState:
struct ModalFeatureState {
var items: [Item] = []
var modal: ModalState
}
Note that this state doesn't hold anything from AppState that your modal feature doesn't need to know about. Your modal reducer will operate on that state, and so it will be quite easy for you to append items to it when the "Done" button is tapped.
But the real magic happens by constructing a custom key path that projects out those two pieces of state from AppState, which allows you to pull back your reducer. That can look like this:
extension AppState {
var modalFeature: ModalFeatureState? {
get {
self.modal.map {
.init(items: self.items, modal: $0)
}
}
set {
self.items = newValue?.items ?? self.items
self.modal = newValue?.modal
}
}
}
If you pull back your reducer along this key path you instantly get the ability to share just a little bit of AppState with your modal reducer, and so any changes your modal reducer makes to it will be instantly reflected in the main application.
Thanks a lot, that did the trick! I even though about something like that, but I was missing this key trick: self.items = newValue?.items ?? self.items. I love the result. I find it very clear and elegant.
Do you think this example might be a good addition to the CaseStudies app in the Composable Architecture repository? I think this is a common use case (I found it in many Apple apps, for example), and many developers at the first steps with the Composable Architecture might benefit from it. I would be happy to contribute with a Pull Request.