I'm attempting to build a test application using the ToDo example merged with the sample code on presenting a modal view but it's not going well...
The problem relates to my attempts to separate out AppState into separate domains. The ToDo items (Patients in my example) form part of the global state but I've tried to reference these in a list-specific domain. I'm unable to figure out the correct syntax to plug into ForEachStore (see the PatientListView file below). The state: parameter is fine (or, at least it appears so at the moment) but I'm struggling with the action: parameter. If anyone can point me in the right direction that would be great!
Full code (excluding the App and ModalView files) as follows:
// MARK:- APP STATE
struct AppState: Equatable {
var patients: [Patient] = []
var modal: ModalState? = nil
var isAddSheetPresented: Bool { self.modal != nil }
}
extension AppState {
var patientList: PatientListState {
get {
PatientListState(patients: self.patients, modal: self.modal)
}
set {
self.patients = newValue.patients
self.modal = newValue.modal
}
}
// TODO: PatientList needs, in essence, to be a non-optional version of ModalCompoundState as it needs the list of Patients and access to modalState so it can mutate it
// when the add button is pressed. There does seem to be an element of duplication here... but roll with it for now and see if it can be refactored
var modalCompound: ModalCompoundState? { // creates a compound state struct when the modal "add patient" view is displayed
get {
modal.map {
.init(patients: self.patients, modal: $0)
}
}
set {
self.patients = newValue?.patients ?? self.patients
self.modal = newValue?.modal
}
}
}
// MARK:- APP ACTION
enum AppAction: Equatable {
case patientList(PatientListAction)
case modal(ModalAction)
}
// MARK:- ENVIRONMENT
struct AppEnvironment {
var uuid: () -> UUID
static let live = AppEnvironment(uuid: UUID.init)
}
// MARK:- APP REDUCER
let appReducer: Reducer<AppState, AppAction, AppEnvironment> =
Reducer.combine(
patientListReducer
.pullback(
state: \.patientList,
action: /AppAction.patientList,
environment: { $0 }),
modalReducer
.optional()
.pullback(
state: \.modalCompound,
action: /AppAction.modal,
environment: { $0}
)
)
// MARK:- PATIENT LIST DOMAIN
struct PatientListState: Equatable {
var patients: [Patient] = []
var modal: ModalState? = nil
}
enum PatientListAction: Equatable {
case patient(index: Int, action: PatientRowAction) // AppAction isolates Patient at a specific index
case addButtonTapped
case addSheetDismissed
}
let patientListReducer: Reducer<PatientListState, PatientListAction, AppEnvironment> = Reducer.combine(
reducer,
patientRowReducer.forEach(
state: \.patients,
action: /PatientListAction.patient(index:action:),
environment: { _ in .live }
)
)
let reducer = Reducer<PatientListState, PatientListAction, AppEnvironment> { state, action, environment in
switch action {
case .patient(_, _):
return .none
case .addButtonTapped:
fatalError("Not implemented")
return .none
case .addSheetDismissed:
fatalError("Not implemented")
return .none
}
}
// MARK:- VIEW
struct PatientListView: View {
let store: Store<AppState, AppAction>
var body: some View {
NavigationView {
WithViewStore(store.scope(
state: \.patientList,
action: AppAction.patientList
)) { viewStore in
List {
ForEachStore(
store.scope(
state: \.patientList.patients,
action: AppAction.patientList(.patient(index:action:)),
content: { PatientRowView(store: $0) }
)
)
Text("-- End of list --")
}
.navigationBarTitle("Patients")
.navigationBarItems(
trailing: Button("Add") {
viewStore.send(.addButtonTapped)
}
)
}
}
}
}
// MARK:- SUBVIEW
enum PatientRowAction: Equatable {
case checkboxTapped
}
let patientRowReducer = Reducer<Patient, PatientRowAction, AppEnvironment> { state, action, environment in
switch action {
case .checkboxTapped:
state.isComplete.toggle()
return .none
}
}
struct PatientRowEnvironment { }
// MARK:- VIEW
struct PatientRowView: View {
let store: Store<Patient, PatientRowAction>
var body: some View {
WithViewStore(store) { patientViewStore in
HStack {
Text(patientViewStore.name)
Spacer()
Button(action: {
patientViewStore.send(.checkboxTapped)
}) {
Image(systemName: patientViewStore.isComplete ? "checkmark.square" : "square")
}
.buttonStyle(PlainButtonStyle())
}
.foregroundColor(patientViewStore.isComplete ? .gray : nil)
}
}
}
Corrected a relatively obvious typo relating to the state.scope passed to the ForEachStore so fewer errors but still not correct:
ForEachStore(store.scope(state: \.patientList.patients,
action: AppAction.patientList(PatientListAction.patient(index:action:))),
content: { PatientRowView(store: $0) })
Error messages now stand at:
Cannot convert value of type 'AppAction' to expected argument type '((Int, PatientRowAction)) -> AppAction'
Cannot convert value of type '(Int, PatientRowAction) -> PatientListAction' to expected argument type 'PatientListAction'
After some additional tinkering, it made sense to scope the store to domain-specific state and actions which simplified matters (ForEachStore and ViewStore now only need a key path for state) and resolved the original issue= but (typically) pushed the problem elsewhere:
struct PatientListView: View {
// let store: Store<AppState, AppAction>
let store: Store<PatientListState, PatientListAction>
var body: some View {
NavigationView {
WithViewStore(store) { viewStore in
List {
ForEachStore(store.scope(
state: \.patients,
action: PatientListAction.patient(index:action:)
), content: PatientRowView.init(store:) )
Text("-- End of list --")
}
.navigationBarTitle("Patients")
.navigationBarItems(
trailing: Button("Add") {
viewStore.send(.addButtonTapped)
}
)
.sheet(isPresented: viewStore.binding(
get: \.isAddSheetPresented,
send: .addSheetDismissed
)) {
IfLetStore(store.scope(state: \.modal, action: AppAction.modal)) { store in <<< ERROR
PatientModalView(store: store)
}
}
}
}
}
}
The error is: Cannot convert value of type '(ModalAction) -> AppAction' to expected argument type '(ModalAction) -> PatientListAction'.
This isn't unexpected considering I've changed the Action type but now I'd need to move the action into the PatientList domain which would muck up the appReducer... Getting very confused as I've never worked with functions, keyPaths, etc this extensively before and suspect the solution is so blindingly simple I can't see it....
OK - it's fixed!
The problem arose because I was trying to keep views in a flat hierarchy so essentially there was just one layer of views all deriving their state from AppState rather than taking the logical approach of deriving the state from their logical ancestor (parent). In this case, as the modal view was only ever presented by the PatientList, it made sense for PatientModalView to be a leaf/child of PatientList rather than a standalone view.
Moving state derivation removed the errors and reorganising the reducers (essentially lumping parent and child/leaf reducers together) did the trick with the app now functioning as expected. I hope it gets easier with practice because otherwise more complex scenarios will make my head explode!
Anyway, solution below which may prove useful for someone in a similar position:
// MARK:- APP STATE
struct AppState: Equatable {
var patients: [Patient] = []
var modal: ModalState? = nil
var isAddSheetPresented: Bool { self.modal != nil }
}
extension AppState {
var patientList: PatientListState {
get {
PatientListState(patients: self.patients, modal: self.modal)
}
set {
self.patients = newValue.patients
self.modal = newValue.modal
}
}
}
// MARK:- APP ACTION
enum AppAction: Equatable {
case patientList(PatientListAction)
case modal(ModalAction)
}
// MARK:- ENVIRONMENT
struct AppEnvironment {
var uuid: () -> UUID
static let live = AppEnvironment(uuid: UUID.init)
}
// MARK:- APP REDUCER
let appReducer: Reducer<AppState, AppAction, AppEnvironment> =
patientListReducer
.pullback(
state: \.patientList,
action: /AppAction.patientList,
environment: { $0 })
// MARK:- PATIENT LIST DOMAIN
struct PatientListState: Equatable {
var patients: [Patient] = []
var modal: ModalState? = nil
var isAddSheetPresented: Bool { self.modal != nil }
}
extension PatientListState {
var modalCompound: ModalCompoundState? { // creates a compound state struct when the modal "add patient" view is displayed
get {
modal.map {
.init(patients: self.patients, modal: $0)
}
}
set {
self.patients = newValue?.patients ?? self.patients
self.modal = newValue?.modal
}
}
}
enum PatientListAction: Equatable {
case patient(index: Int, action: PatientRowAction) // AppAction isolates Patient at a specific index
case addButtonTapped
case addSheetDismissed
case modal(ModalAction)
}
let patientListReducer: Reducer<PatientListState, PatientListAction, AppEnvironment> = Reducer.combine(
reducer,
patientRowReducer.forEach(
state: \.patients,
action: /PatientListAction.patient(index:action:),
environment: { _ in .live }
),
modalReducer
.optional()
.pullback(
state: \.modalCompound,
action: /PatientListAction.modal,
environment: { $0 }
)
)
let reducer = Reducer<PatientListState, PatientListAction, AppEnvironment> { state, action, environment in
switch action {
case .patient(_, _):
// defer to PatientRowReducer
return .none
case .addButtonTapped:
state.modal = .init()
return .none
case .addSheetDismissed:
state.modal = nil
return .none
case .modal(_):
// defer to ModalReducer
return .none
}
}
// MARK:- VIEW
struct PatientListView: View {
let store: Store<PatientListState, PatientListAction>
var body: some View {
NavigationView {
WithViewStore(store) { viewStore in
List {
ForEachStore(store.scope(
state: \.patients,
action: PatientListAction.patient(index:action:)
), content: PatientRowView.init(store:) )
Text("-- End of list --")
}
.navigationBarTitle("Patients")
.navigationBarItems(
trailing: Button("Add") {
viewStore.send(.addButtonTapped)
}
)
.sheet(isPresented: viewStore.binding(
get: \.isAddSheetPresented,
send: .addSheetDismissed
)) {
IfLetStore(store.scope(
state: \.modal,
action: PatientListAction.modal
)) { store in
PatientModalView(store: store)
}
}
}
}
}
}
// MARK:- SUBVIEW
enum PatientRowAction: Equatable {
case checkboxTapped
}
let patientRowReducer = Reducer<Patient, PatientRowAction, AppEnvironment> { state, action, environment in
switch action {
case .checkboxTapped:
state.isComplete.toggle()
return .none
}
}
struct PatientRowEnvironment { }
// MARK:- VIEW
struct PatientRowView: View {
let store: Store<Patient, PatientRowAction>
var body: some View {
WithViewStore(store) { patientViewStore in
HStack {
Text(patientViewStore.name)
Spacer()
Button(action: {
patientViewStore.send(.checkboxTapped)
}) {
Image(systemName: patientViewStore.isComplete ? "checkmark.square" : "square")
}
.buttonStyle(PlainButtonStyle())
}
.foregroundColor(patientViewStore.isComplete ? .gray : nil)
}
}
}