Using `DocumentGroup` with TCA

50 Views Asked by At

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(),
    ]
  }
}
0

There are 0 best solutions below