To isolate the business logic from view, this is a useful pattern. The view will be reactive to an observable status in the view model.

Import spreadsheets

The main view will contain the list of spreadsheets and the sign-in / out buttons. Each spreadsheet is represented by another view, that contains information about the spreadsheet and a button to import all the words from it.

Main view status:

enum Status {
    case off
    case loading
    case loaded(spreadsheets: [Spreadsheet])
}

Spreadsheet view status:

enum Status {
    case importable
    case imported
}

Status property must be inside a view model and each view will have its own, for example:

extension ImportView {
    class ViewModel: NSObject, ObservableObject {

        enum Status {
            case off
            case loading
            case loaded(spreadsheets: [Spreadsheet])
        }

        @Published private(set) var status: Status = .off
    }
}

To use the view model from the view:

struct ImportView: View {

    @StateObject private var viewModel: ViewModel

    init(viewModel: ViewModel) {
        _viewModel = StateObject(wrappedValue: viewModel)
    }
}

In summary:

  • The view model must be a class that implements ObservableObject.
  • The properties that will be observed, must be marked as @Published.
  • The view model must be a @StateObject in the view.
  • The view model must be initialized using the StateObject(wrappedValue:) initializer.

After doing this, the view model’s status can be observed from the view:

var body: some View {
    NavigationView {
        if case .loading = viewModel.status {
            // ...
        } else if case .loaded(let spreadsheets) = viewModel.status {
            // ...
        } else {
            // ...
        }
    }
}

Bindings

After importing the words in a spreadsheet into the app, the fetched translations will be updated and each spreadsheet must indicate if its translations can be imported or if they have already been imported.

From the main view model, all the imported translations are fetched:

class ViewModel: NSObject, ObservableObject {

    // ...

    private let dataController: DataController
    private let translationsController: NSFetchedResultsController<Translation>

    @Published var translations: [Translation] = []

    // ...

    init(dataController: DataController, /* ... */) {
        self.dataController = dataController
        // ...
        let request: NSFetchRequest<Translation> = Translation.fetchRequest()
        request.sortDescriptors = []
        translationsController = NSFetchedResultsController(
            fetchRequest: request,
            managedObjectContext: dataController.container.viewContext,
            sectionNameKeyPath: nil,
            cacheName: nil
        )
        super.init()
        translationsController.delegate = self
        try? translationsController.performFetch()
        translations = translationsController.fetchedObjects ?? []
    }
}

The view model becomes the delegate of the NSFetchedResultsController, receiving the the new fetched translations every time they are updated:

extension ImportView.ViewModel: NSFetchedResultsControllerDelegate {
    func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        if let newTranslations = controller.fetchedObjects as? [Translation] {
            translations = newTranslations
        }
    }
}

The fetched translations are marked as @Published, so changes in the array will be notified to all its observers. From the main view, each time a spreadsheet view is created, the binding to the @Published translations is passed:

ForEach(spreadsheets, id: \.id) { spreadsheet in
    SpreadsheetView(
        translations: $viewModel.translations,
        viewModel: .init(spreadsheet: spreadsheet) {
            viewModel.importTranslations(from: spreadsheet)
        }
    )
    .padding()
}

To update the spreadsheet’s view reactively with translation’s changes, the published value must be bound:

struct SpreadsheetView: View {

    @Binding var translations: [Translation]

    // ...

    init(translations: Binding<[Translation]>, /* ... */) {
        self._translations = translations
        //...
    }

Every time the translations are updated, each spreadsheet checks if its translations have been imported. If this is the case, the status of the spreadsheet’s view becomes imported.