Instantly share code, notes, and snippets.
Last active
September 18, 2023 09:12
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
Save appfrosch/b46fe65928504f9d772aad2f91585b89 to your computer and use it in GitHub Desktop.
TCA - Stack-based navigation - child to parent communication for value mutation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import ComposableArchitecture | |
import SwiftUI | |
@main | |
struct poc_TCA_NavStack_multilevelApp: App { | |
var body: some Scene { | |
WindowGroup { | |
RootView( | |
store: Store( | |
initialState: RootFeature.State( | |
items: .init() | |
), | |
reducer: { RootFeature() } | |
) | |
) | |
} | |
} | |
} | |
//MARK: Model | |
struct Item: Codable, Equatable, Hashable, Identifiable { | |
let id: UUID | |
var title: String | |
var subItems: IdentifiedArrayOf<SubItem> | |
init( | |
id: UUID = .init(), | |
title: String = "New Item", | |
subItems: IdentifiedArrayOf<SubItem> = .init() | |
) { | |
self.id = id | |
self.title = title | |
self.subItems = subItems | |
} | |
} | |
struct SubItem: Codable, Equatable, Hashable, Identifiable { | |
let id: UUID | |
var title: String | |
init( | |
id: UUID = .init(), | |
title: String = "New Subitem" | |
) { | |
self.id = id | |
self.title = title | |
} | |
} | |
//MARK: Root Feature | |
struct RootFeature: Reducer { | |
struct State: Equatable { | |
var path = StackState<Path.State>() | |
var items: IdentifiedArrayOf<Item> | |
} | |
enum Action: Equatable { | |
case addNewItemButtonTapped | |
case delete(atOffsets: IndexSet) | |
case path(StackAction<Path.State, Path.Action>) | |
} | |
var body: some ReducerOf<Self> { | |
Reduce { state, action in | |
switch action { | |
case .addNewItemButtonTapped: | |
state.items.append(Item()) | |
return .none | |
case let .delete(atOffsets: offsets): | |
state.items.remove(atOffsets: offsets) | |
return .none | |
case let .path(.element(id: _, action: pathAction)): | |
switch pathAction { | |
case let .detailItem(itemAction): | |
switch itemAction { | |
case .binding: | |
return .none | |
case let .delegate(itemDelegateAction): | |
switch itemDelegateAction { | |
case let .onChange(item): | |
state.items[id: item.id] = item | |
state.path.ids.forEach { id in | |
if state.path[id: id, case: /Path.State.detailItem]?.item.id == item.id { | |
state.path[id: id, case: /Path.State.detailItem]?.item = item | |
} | |
} | |
return .none | |
} | |
case .addSubItem: | |
return .none | |
} | |
case let .detailSubItem(subitemAction): | |
switch subitemAction { | |
case .binding: | |
return .none | |
case let .delegate(subitemDelegateAction): | |
switch subitemDelegateAction { | |
case let .onChange(subItem): | |
//Save in root view | |
state.items.ids.forEach { itemId in | |
state.items[id: itemId]?.subItems.ids.forEach { subitemId in | |
state.items[id: itemId]?.subItems[id: subitemId] = subItem | |
} | |
} | |
//Save in item detail view (i.e. in each path with the subitems parent item | |
state | |
.path | |
.ids | |
.forEach { pathId in | |
state.path[id: pathId, case: /Path.State.detailItem]?.item.subItems.ids.forEach { subItemId in | |
if state.path[id: pathId, case: /Path.State.detailItem]?.item.subItems[id: subItemId]?.id == subItem.id { | |
state.path[id: pathId, case: /Path.State.detailItem]?.item.subItems[id: subItemId] = subItem | |
} | |
} | |
} | |
} | |
return .none | |
} | |
} | |
case .path: | |
return .none | |
} | |
} | |
.forEach(\.path, action: /Action.path) { | |
Path() | |
} | |
._printChanges() | |
} | |
} | |
//MARK: Path Feature | |
extension RootFeature { | |
struct Path: Reducer { | |
enum State: Codable, Equatable, Hashable { | |
case detailItem(ItemFeature.State) | |
case detailSubItem(SubItemFeature.State) | |
} | |
enum Action: Equatable { | |
case detailItem(ItemFeature.Action) | |
case detailSubItem(SubItemFeature.Action) | |
} | |
var body: some Reducer<State, Action> { | |
Scope(state: /State.detailItem, action: /Action.detailItem) { | |
ItemFeature() | |
} | |
Scope(state: /State.detailSubItem, action: /Action.detailSubItem) { | |
SubItemFeature() | |
} | |
} | |
} | |
} | |
//MARK: Root View | |
struct RootView: View { | |
let store: StoreOf<RootFeature> | |
var body: some View { | |
NavigationStackStore( | |
self.store.scope( | |
state: \.path, | |
action: { .path($0) } | |
) | |
) { | |
WithViewStore(self.store, observe: { $0 } ) { viewStore in | |
List { | |
if viewStore.items.isEmpty { | |
ContentUnavailableView("No items yet …", systemImage: "plus") | |
} | |
ForEach(viewStore.items) { item in | |
NavigationLink( | |
state: RootFeature.Path.State.detailItem( | |
ItemFeature.State(item: item) | |
) | |
) { | |
VStack(alignment: .leading) { | |
Text(item.id.uuidString) | |
.font(.caption2) | |
Text(item.title) | |
} | |
} | |
} | |
.onDelete(perform: { viewStore.send(.delete(atOffsets: $0)) }) | |
} | |
.navigationTitle("Root View") | |
.toolbar { | |
ToolbarItem(placement: .automatic) { | |
Button { | |
viewStore.send(.addNewItemButtonTapped) | |
} label: { | |
Label("Add Item", systemImage: "plus") | |
} | |
} | |
} | |
} | |
} destination: { state in | |
switch state { | |
case .detailItem: | |
CaseLet( | |
/RootFeature.Path.State.detailItem, | |
action: RootFeature.Path.Action.detailItem, | |
then: ItemView.init(store:) | |
) | |
case .detailSubItem: | |
CaseLet( | |
/RootFeature.Path.State.detailSubItem, | |
action: RootFeature.Path.Action.detailSubItem, | |
then: SubItemView.init(store:) | |
) | |
} | |
} | |
} | |
} | |
//Mark: Root Preview | |
#Preview("Root") { | |
RootView( | |
store: Store( | |
initialState: RootFeature.State( | |
items: IdentifiedArrayOf( | |
uniqueElements: [ | |
.init( | |
title: "First Item", | |
subItems: IdentifiedArray( | |
uniqueElements: [ | |
.init(title: "1st Subitem"), | |
.init(title: "2nd Subitem"), | |
.init(title: "3rd Subitem"), | |
] | |
) | |
) | |
] | |
) | |
), | |
reducer: { RootFeature() }) | |
) | |
} | |
//MARK: Item Feature | |
struct ItemFeature: Reducer { | |
struct State: Codable, Equatable, Hashable { | |
@BindingState var item: Item | |
} | |
enum Action: BindableAction, Equatable { | |
case addSubItem | |
case binding(BindingAction<State>) | |
case delegate(Delegate) | |
enum Delegate: Equatable { | |
case onChange(Item) | |
} | |
} | |
var body: some ReducerOf<Self> { | |
BindingReducer() | |
Reduce { state, action in | |
switch action { | |
case .addSubItem: | |
state.item.subItems.append(SubItem(title: "New Subitem")) | |
return .send(.delegate(.onChange(state.item))) | |
case .binding(\.$item): | |
return .send(.delegate(.onChange(state.item))) | |
case .binding: | |
return .none | |
case .delegate: | |
return .none | |
} | |
} | |
} | |
} | |
//MARK: Item View | |
struct ItemView: View { | |
let store: StoreOf<ItemFeature> | |
var body: some View { | |
WithViewStore(store, observe: { $0 }) { viewstore in | |
List { | |
Section("Item") { | |
Text(viewstore.item.id.uuidString) | |
.font(.caption2) | |
TextField("Title", text: viewstore.$item.title) | |
} | |
Section("Subitems") { | |
if viewstore.item.subItems.isEmpty { | |
ContentUnavailableView("No subitems yet …", systemImage: "plus") | |
} | |
ForEach(viewstore.item.subItems) { subitem in | |
NavigationLink(state: RootFeature.Path.State.detailSubItem(SubItemFeature.State(subItem: subitem))) { | |
VStack(alignment: .leading) { | |
Text(subitem.id.uuidString) | |
.font(.caption2) | |
Text(subitem.title) | |
} | |
} | |
} | |
} | |
.navigationTitle("Item View") | |
} | |
.toolbar { | |
ToolbarItem(placement: .automatic) { | |
Button { | |
viewstore.send(.addSubItem) | |
} label: { | |
Label("Add subitem", systemImage: "plus") | |
} | |
} | |
} | |
} | |
} | |
} | |
//MARK: SubItem Feature | |
struct SubItemFeature: Reducer { | |
struct State: Codable, Equatable, Hashable { | |
@BindingState var subItem: SubItem | |
} | |
enum Action: BindableAction, Equatable { | |
case binding(BindingAction<State>) | |
case delegate(Delegate) | |
enum Delegate: Equatable { | |
case onChange(SubItem) | |
} | |
} | |
var body: some ReducerOf<Self> { | |
BindingReducer() | |
Reduce { state, action in | |
switch action { | |
case .binding(\.$subItem): | |
return .send(.delegate(.onChange(state.subItem))) | |
case .binding: | |
return .none | |
case .delegate: | |
return .none | |
} | |
} | |
} | |
} | |
//MARK: SubItem View | |
struct SubItemView: View { | |
let store: StoreOf<SubItemFeature> | |
var body: some View { | |
WithViewStore(store, observe: { $0 }) { viewstore in | |
Form { | |
Text(viewstore.subItem.id.uuidString) | |
.font(.caption2) | |
TextField("Title", text: viewstore.$subItem.title) | |
} | |
.navigationTitle("Subitem View") | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
I don't know what the best way is to communicate changes in a child to its parent in stack based navigation.
Which is why I started a discussion on this topic over at pointfree discussions.