Skip to content

Instantly share code, notes, and snippets.

@appfrosch
Last active September 18, 2023 09:12
Show Gist options
  • Save appfrosch/b46fe65928504f9d772aad2f91585b89 to your computer and use it in GitHub Desktop.
Save appfrosch/b46fe65928504f9d772aad2f91585b89 to your computer and use it in GitHub Desktop.
TCA - Stack-based navigation - child to parent communication for value mutation
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")
}
}
}
@appfrosch
Copy link
Author

appfrosch commented Sep 18, 2023

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment