I'm just starting out with TCA but I am unsure how to create reusable views and reducers that can be used with different models.
As an example, I've created a reusable View that has a reducer factory that allows a different concrete type to be used for the view state as long as it conforms to a protocol:
EditableItemView.swift
protocol EditableItemConformable: Equatable, Identifiable {
var id: UUID { get }
var title: String { get set }
}
enum EditableItemAction: Equatable {
case textFieldChanged(String)
}
func makeItemReducer<T>() -> Reducer<T, EditableItemAction, AppEnvironment> where T: EditableItemConformable {
return Reducer { item, action, environment in
switch action {
case .textFieldChanged(let string):
item.title = string
return .none
}
}
}
struct EditableItemView<T>: View where T: EditableItemConformable {
let store: Store<T, EditableItemAction>
var body: some View {
WithViewStore(store) { viewStore in
TextField(
viewStore.title,
text: viewStore.binding(get: \.title, send: EditableItemAction.textFieldChanged)
)
}
}
}
Given I have the following models:
Models.swift
struct ChildModel: EditableItemConformable {
let id: UUID
var title: String
// other unrelated fields
var isPopular: Bool
}
struct GroupModel: EditableItemConformable {
let id: UUID
var title: String
var items: IdentifiedArrayOf<ChildModel> = []
}
Both of those can be used as the state for the EditableItemView
.
Now putting this all together I have two screens that list items, the first screen is a list of "groups" that should have editable titles, the second screen is a list of "child" items that belong to the select group.
GroupsView.swift
enum GroupsViewAction: Equatable {
case addGroup
case delete(IndexSet)
case move(IndexSet, Int)
case editModeChanged(EditMode)
case item(id: GroupModel.ID, action: GroupDetailsAction)
case item2(id: GroupModel.ID, action: EditableItemAction)
}
struct GroupsViewState: Equatable {
var items: IdentifiedArrayOf<GroupModel> = []
var editMode: EditMode = .inactive
}
/// How can I use this reducer for a single view (EditableItemView) below?
let groupReducer: Reducer<GroupModel, EditableItemAction, AppEnvironment> = makeItemReducer()
let groupsViewReducer = Reducer<GroupsViewState, GroupsViewAction, AppEnvironment>.combine(
groupDetailsReducer.forEach(
state: \.items,
action: /GroupsViewAction.item(id:action:),
environment: { $0 }
),
Reducer { state, action, environment in
switch action {
case .addGroup:
let item = GroupModel(
id: environment.uuid(),
title: "Test",
items: []
)
state.items.append(item)
return .none
case let .editModeChanged(editMode):
state.editMode = editMode
return .none
case .item, .item2:
return .none
case let .delete(indexSet):
state.items.remove(atOffsets: indexSet)
return .none
case let .move(source, destination):
state.items.move(fromOffsets: source, toOffset: destination)
return .none
}
}
)
.debugActions(actionFormat: .prettyPrint)
struct GroupsView: View {
let store: Store<GroupsViewState, GroupsViewAction>
var body: some View {
WithViewStore(store) { viewStore in
NavigationView {
List {
ForEachStore(
self.store.scope(
state: \.items,
action: GroupsViewAction.item(id:action:)
)
) { itemStore in
NavigationLink {
GroupDetailsView(store: itemStore)
} label: {
/// Cannot convert value of type 'Store<GroupModel, GroupDetailsAction>' to expected argument type 'Store<GroupModel, EditableItemAction>'
/// Need to be able to scoped version of `itemStore`for each item that works with the `EditableItemAction`, how
/// can I achieve this?
EditableItemView<GroupModel>(store: itemStore)
.padding()
.border(Color.red, width: 1)
Spacer()
.frame(width: 100)
// WithViewStore(itemStore) { viewStore in
// Text(viewStore.title)
// }
}
}
.onDelete { viewStore.send(.delete($0)) }
.onMove { viewStore.send(.move($0, $1)) }
}
.navigationTitle("Items")
.toolbar {
HStack {
EditButton()
Button {
viewStore.send(.addGroup)
} label: {
Image(systemName: "plus")
}
}
}
}
.environment(
\.editMode,
viewStore.binding(get: \.editMode, send: GroupsViewAction.editModeChanged)
)
}
}
}
The detail screen looks like this:
GroupDetailsView.swift
enum GroupDetailsAction: Equatable {
case addItem
case delete(IndexSet)
case move(IndexSet, Int)
case item(id: ChildModel.ID, action: EditableItemAction)
}
let childReducer: Reducer<ChildModel, EditableItemAction, AppEnvironment> = makeItemReducer()
let groupDetailsReducer = Reducer<GroupModel, GroupDetailsAction, AppEnvironment>.combine(
childReducer.forEach(
state: \.items,
action: /GroupDetailsAction.item(id:action:),
environment: { $0 }
),
Reducer { state, action, environment in
switch action {
case .addItem:
let item = ChildModel(
id: environment.uuid(),
title: "Test",
isPopular: false
)
state.items.append(item)
return .none
case .item:
return .none
case let .delete(indexSet):
state.items.remove(atOffsets: indexSet)
return .none
case let .move(source, destination):
state.items.move(fromOffsets: source, toOffset: destination)
return .none
}
}
)
.debugActions(actionFormat: .prettyPrint)
struct GroupDetailsView: View {
let store: Store<GroupModel, GroupDetailsAction>
var body: some View {
WithViewStore(store) { viewStore in
List {
ForEachStore(
self.store.scope(
state: \.items,
action: GroupDetailsAction.item(id:action:)
)
) { groupDetails in
HStack {
EditableItemView<ChildModel>(store: groupDetails)
WithViewStore(groupDetails) { viewStore in
if viewStore.isPopular {
Text("Popular!")
}
}
}
}
.onDelete { viewStore.send(.delete($0)) }
.onMove { viewStore.send(.move($0, $1)) }
}
.toolbar {
HStack {
EditButton()
Button {
viewStore.send(.addItem)
} label: {
Image(systemName: "plus")
}
}
}
.navigationTitle(viewStore.title)
}
}
}
If you want to try building this you'll need an entry point:
main.swift
import ComposableArchitecture
import SwiftUI
@main
struct testApp: App {
var body: some Scene {
WindowGroup {
GroupsView(
store: Store(
initialState: GroupsViewState(items: .mock),
reducer: groupsViewReducer,
environment: AppEnvironment(
uuid: UUID.init
)
)
)
}
}
}
struct AppEnvironment {
var uuid: () -> UUID
}
extension IdentifiedArray where ID == GroupModel.ID, Element == GroupModel {
static let mock: Self = [
GroupModel(
id: UUID(uuidString: "DEADBEEF-DEAD-BEEF-DEAD-BEEDDEADBEEF")!,
title: "Group 1",
items: .mock
),
GroupModel(
id: UUID(uuidString: "CAFEBEEF-CAFE-BEEF-CAFE-BEEFCAFEBEEF")!,
title: "Group 2",
items: .mock
),
GroupModel(
id: UUID(uuidString: "D00DCAFE-D00D-CAFE-D00D-CAFED00DCAFE")!,
title: "Group 3",
items: .mock
),
]
}
extension IdentifiedArray where ID == ChildModel.ID, Element == ChildModel {
static let mock: Self = [
ChildModel(
id: UUID(uuidString: "2A543D97-844A-4BAA-B648-86B6478D475A")!,
title: "Item 1",
isPopular: false
),
ChildModel(
id: UUID(uuidString: "9C4436EF-6D5A-4CE0-9321-08FA8C7DD0D7")!,
title: "Item 2",
isPopular: true
),
ChildModel(
id: UUID(uuidString: "FC604136-38E0-4306-BFF2-3CCBE8FB5E34")!,
title: "Item 3",
isPopular: false
),
]
}
The issue I'm having is this code in the GroupsView.swift:
ForEachStore(
self.store.scope(
state: \.items,
action: GroupsViewAction.item(id:action:)
)
) { itemStore in
NavigationLink {
GroupDetailsView(store: itemStore)
} label: {
/// Cannot convert value of type 'Store<GroupModel, GroupDetailsAction>' to expected argument type 'Store<GroupModel, EditableItemAction>'
/// Need to be able to scoped version of `itemStore`for each item that works with the `EditableItemAction`, how
/// can I achieve this?
EditableItemView<GroupModel>(store: itemStore)
.padding()
.border(Color.red, width: 1)
Spacer()
.frame(width: 100)
It's unclear to me how to scope the already scoped itemStore
so that it uses the GroupsViewAction.item2(id:action:)
which is needed because the action is EditableItemAction
which is needed to perform the editing action on the EditableItemView
's reducer.
Can anyone advise how I can work with a scoped array using ForEach with two different actions?
I'm wondering if I'm approaching this all wrong but it feels like I should be able to have shared views that have their own reducers that can work with different concrete types as long as they conform to a protocol.