I am struggling finding examples of how to use DocumentGroup
with TCA, specifically how to propagate changes in a TCA feature up to the document so that macOS realises that there has been a change that can be saved to disk correctly.
My UX consists of 2 tabs. The app starts at tab 1. I then change to tab 2 and add items to a list. This mutation needs to be saved to the document.
I am now passing a binding of the document into the view and mutate the document that has a property of [Item]
in the view in an .onChange(of:)
.
I expect macOS to register the change to the document, enabling me to do a save to disk.
What happens right now though is that additionally to macOS registering the change, it also changes the tab selection to the first tab, which is not intended. It is almost as if the Scene
is re-rendered and the app starts over...
Any suggestions/links to ressources would be very much appreciated.
Heres's the code:
import ComposableArchitecture
import SwiftUI
@main
struct poc_tcaTabViewApp: App {
var body: some Scene {
DocumentGroup(newDocument: Document()) { file in
AppView(
document: file.$document,
store: Store(initialState: AppFeature.State()) {
AppFeature()
}
)
}
}
}
import ComposableArchitecture
import SwiftUI
// MARK: AppFeature
struct AppFeature: Reducer {
enum Tab {
case first, second
}
struct State: Equatable {
var selectedTab: Tab
var secondTab: SecondTabFeature.State
init(
selectedTab: Tab = .first,
items: [Item] = .init()
) {
self.selectedTab = selectedTab
self.secondTab = SecondTabFeature.State(items: items)
}
}
enum Action {
case selectedTabChanged(Tab)
case secondTab(SecondTabFeature.Action)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .selectedTabChanged(let tab):
state.selectedTab = tab
return .none
case .secondTab:
return .none
}
}
Scope(state: \.secondTab, action: /Action.secondTab, child: {
SecondTabFeature()
})
._printChanges()
}
}
// MARK: AppView
struct AppView: View {
@Binding var document: Document
let store: StoreOf<AppFeature>
struct ViewState: Equatable {
let selectedTab: AppFeature.Tab
init(state: AppFeature.State) {
self.selectedTab = state.selectedTab
}
}
var body: some View {
WithViewStore(self.store, observe: ViewState.init) { viewStore in
TabView (
selection: viewStore.binding(
get: \.selectedTab,
send: { .selectedTabChanged($0)}
)
) {
Text("Tab Content 1")
.tabItem { Text("First") }
.tag(AppFeature.Tab.first)
SecondTabView(
document: $document,
store: self.store.scope(
state: \.secondTab,
action: AppFeature.Action.secondTab)
)
.tabItem { Text("Second") }
.tag(AppFeature.Tab.second)
}
.padding()
}
}
}
// MARK: AppView Preview
#Preview {
@State var document = Document()
return AppView(
document: $document,
store: Store(
initialState: AppFeature.State(selectedTab: .second, items: Item.mockArray))
{ AppFeature() }
)
}
import ComposableArchitecture
import SwiftUI
// MARK: SecondTabFeature
struct SecondTabFeature: Reducer {
struct State: Equatable {
@BindingState var items: [Item]
@BindingState var selection: Item.ID?
@PresentationState var detail: SecondTabDetailFeature.State?
}
enum Action: BindableAction, Equatable {
case addItemButtonTapped
case binding(BindingAction<State>)
case deleteItemButtonTapped(Item.ID)
case detail(PresentationAction<SecondTabDetailFeature.Action>)
}
var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
switch action {
case .addItemButtonTapped:
let newItem = Item()
state.items.append(newItem)
state.selection = newItem.id
state.detail = SecondTabDetailFeature.State(itemId: state.selection!)
return .none
case .binding(\.$selection):
guard let selection = state.selection
else { return .none }
state.detail = SecondTabDetailFeature.State(itemId: selection)
return .none
case .binding:
return .none
case .deleteItemButtonTapped(let itemId):
guard let index = state.items.firstIndex(where: { $0.id == itemId })
else { return .none}
state.items.remove(at: index)
let nextIndex: Int
switch index {
case 0:
nextIndex = 0
default:
nextIndex = index - 1
}
guard nextIndex >= 0 && nextIndex <= state.items.count
else { return .none }
state.selection = state.items[nextIndex].id
state.detail = SecondTabDetailFeature.State(itemId: state.items[nextIndex].id)
return .none
case .detail:
return .none
}
}
.ifLet(\.$detail, action: /Action.detail) {
SecondTabDetailFeature()
}
._printChanges()
}
}
// MARK: SecondTabView
struct SecondTabView: View {
@Binding var document: Document
let store: StoreOf<SecondTabFeature>
var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
Group {
if viewStore.items.isEmpty {
ContentUnavailableView(label: {
Label("No items yet", systemImage: "xmark")
}, actions: {
Button("Add first item") {
viewStore.send(.addItemButtonTapped)
}
})
} else {
NavigationView {
List(selection: viewStore.$selection) {
HStack {
Spacer()
Button {
viewStore.send(.addItemButtonTapped)
} label: {
Label("Add item", systemImage: "plus")
}
}
ForEach(viewStore.items) { item in
Text(item.id.uuidString)
.contextMenu {
Button {
viewStore.send(.deleteItemButtonTapped(item.id))
} label: {
Label("Delete", systemImage: "trash")
.labelStyle(.titleAndIcon)
}
}
}
}
.onChange(of: viewStore.items) { _, newValue in
self.document.items = newValue
}
IfLetStore(
self.store.scope(
state: \.$detail,
action: SecondTabFeature.Action.detail)) { store in
SecondTabDetailView(store: store)
} else: {
ContentUnavailableView(label: {
Label("Select an item", systemImage: "xmark")
})
}
}
}
}
}
}
}
import ComposableArchitecture
import SwiftUI
struct SecondTabDetailFeature: Reducer {
struct State: Equatable {
let itemId: Item.ID
}
enum Action: Equatable {}
var body: some ReducerOf<Self> {
Reduce { state, action in
return .none
}
._printChanges()
}
}
struct SecondTabDetailView: View {
let store: StoreOf<SecondTabDetailFeature>
var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
Text(viewStore.itemId.uuidString)
}
}
}
import SwiftUI
import UniformTypeIdentifiers
extension UTType {
static var pocTabView: UTType {
UTType(importedAs: "ch.appfros.pocTabView")
}
}
struct Document: Equatable, FileDocument, Hashable {
static var readableContentTypes: [UTType] { [.pocTabView] }
var items: [Item]
init(items: [Item] = .init()) {
self.items = items
}
init(configuration: ReadConfiguration) throws {
let decoder = JSONDecoder()
let file = configuration.file
guard
let fileWrappers = file.fileWrappers,
let itemsFileWrapper = fileWrappers["items"],
let itemsData = itemsFileWrapper.regularFileContents
else { throw CocoaError(.fileReadCorruptFile) }
self.items = try decoder.decode([Item].self, from: itemsData)
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
let encoder = JSONEncoder()
let itemsData = try encoder.encode(self.items)
let itemsFileWrapper = FileWrapper(regularFileWithContents: itemsData)
let file = FileWrapper(directoryWithFileWrappers: [
"items": itemsFileWrapper
])
return file
}
}
import Foundation
struct Item: Codable, Equatable, Hashable, Identifiable {
let id: UUID
init(id: UUID = .init()) {
self.id = id
}
}
extension Item {
static var mockArray: [Item] {
[
.init(),
.init(),
.init(),
.init(),
]
}
}