Document-Based App with MVVM

import SwiftUI
import UniformTypeIdentifiers
import Foundation

@main
struct DocumentBasedMVVMApp: App {
    @StateObject var viewModel = TheViewModel()
    
    var body: some Scene {
        #if os(iOS)
        DocumentGroupLaunchScene("Title") {
            NewDocumentButton("New motor")
        }
        #endif
        DocumentGroup(newDocument: TheDocument()) { file in
            DocumentView(doc: file.$document, viewModel: viewModel)
                .onChange(of: file.document) { oldDoc, newDoc in
                    viewModel.updateFromDocument(newDoc)
                }
                .onAppear() {
                    viewModel.updateFromDocument(file.document)
                    viewModel.updateArea()
                }
        }
    }
}

/// The main page for any documents
struct DocumentView: View {
    @Binding var doc: TheDocument
    @ObservedObject var viewModel: TheViewModel
    @Environment(\.dismiss) var dismiss
    @State private var showingEditingView = false
    
    var body: some View {
        Text("Computed area: \(viewModel.computedArea)")
        HStack {
            Button("Cancel") {
                dismiss()
            }
            Button("Geometry") {
                showingEditingView.toggle()
            }
            .fullScreenCover(isPresented: $showingEditingView) {
                EditingView(doc: $doc, viewModel: viewModel)
            }
        }
        .padding()
    }
}

/// Example of a view dedicated to modifying the document and doing some computation in the model, via the model viewer.
struct EditingView: View {
    @Binding var doc: TheDocument
    @ObservedObject var viewModel: TheViewModel
    @Environment(\.dismiss) var dismiss
    
    var body: some View {
        ScrollView {
            HStack {
                Text("Stator OD")
                TextField("Stator OD", value: $doc.staOD, format: .number)
            }
            HStack {
                Text("Stator ID")
                TextField("Stator ID", value: $doc.staID, format: .number)
            }
        }
        HStack {
            Button("Cancel", systemImage: "xmark") {
                dismiss()
            }
            Button("Save", systemImage: "hand.thumbsup") {
                viewModel.updateArea()
                dismiss()
            }
        }
        .padding()
    }
}

/// Default file extension from the template
extension UTType {
    static var exampleText: UTType {
        UTType(importedAs: "com.example.plain-text")
    }
}

/// This is the structure I have so far
/// Equatable because of .onChange(of: file.document) in the main app view
struct TheDocument: FileDocument, Equatable {
    var staOD = 80.0 // Stator Outter Diameter
    var staID = 50.0 // Stator Inner Diameter
    
    init() {
    }

    static var readableContentTypes: [UTType] { [.exampleText] }

    init(configuration: ReadConfiguration) throws {
        guard let data = configuration.file.regularFileContents,
              let content = String(data: data, encoding: .utf8) else {
            throw CocoaError(.fileReadCorruptFile)
        }
        
        let lines = content.split(separator: "\n").map { String($0) }
        
        for line in lines {
            let components = line.split(separator: "=", maxSplits: 1).map { String($0) }
            if components.count == 2 {
                let key = components[0]
                let value = components[1]
                
                switch key {
                case "staOD": self.staOD = Double(value) ?? 80.0
                case "staID": self.staID = Double(value) ?? 50.0
                default:
                    print("Unknown key in TheDocument initializer: \(key)")
                    continue
                }
            }
        }
    }
    
    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
        let content = """
        staOD=\(staOD)
        staID=\(staID)
        """

        guard let data = content.data(using: .utf8) else {
            throw CocoaError(.fileWriteInapplicableStringEncoding)
        }

        return .init(regularFileWithContents: data)
    }
    
    /// For Equatable
    static func == (lhs: TheDocument, rhs: TheDocument) -> Bool {
        return lhs.staOD == rhs.staOD &&
               lhs.staID == rhs.staID
    }
}

/// Previously, this ViewModel had `@Published var` instead of `private(set) var`
/// I used to edit directly the values of the view model in my TextFields.
/// However, I thought the document would now play that role, so the ViewModel is left with  button callbacks and interfacing with the model
class TheViewModel: ObservableObject {
    private static func createTheModel() -> (TheModel) {
        return TheModel()
    }
    
    private(set) var theModel = createTheModel()
    private(set) var staOD = 80.0
    private(set) var staID = 50.0
    private(set) var computedArea = 0.0
    
    public func updateFromDocument(_ doc: TheDocument) {
        self.staOD = doc.staOD
        self.staID = doc.staID
    }
    
    private func transferToModel() {
        theModel.staOD = staOD
        theModel.staID = staID
    }
    
    public func updateArea() {
        transferToModel()
        theModel.calcArea()
        computedArea = theModel.area
    }
}

/// Huge structure with a lot of parameters and complex computations
struct TheModel {
    var area = 0.0
    var staOD = 0.0
    var staID = 0.0
    
    mutating func calcArea() {
        area = .pi/4 * (pow(staOD,2) - pow(staID,2))
    }
}
Dear all,

I made an app for computing Finite Element Analysis of electric motors. I (think I) managed to follow the MVVM principle by not exposing the model to the views. Now, my goal is to be able to use documents, each representing a different motor. I want to have my files saved on iCloud, and I want to be able to read plain text from it, so some other code (i.e. python) can create new configurations, even though this app is made for building, graphically.

Before trying to work with FileDocument, my class ViewModel: ObservableObject had properties with Published, like @Published var staOD = 80.0, and I would have views with TextFields to change these values.

Now, I’m trying to blend in FileDocument, and I’m lost. I don’t know how I should work with my data. Under the “Separation of Concerns”, I guessed that:

  • ViewModel: Should handle computations, updates, and application logic.
  • Document: Should focus on data persistence and encapsulate data to be saved/loaded as a document.

My ViewModel looks a bit strange to me, and I’m not sure I’m updating it the right way. I have around 100 parameters, I’m afraid I’m updating these parameters too often and unnecessarily every parameter at the same time, even when only one value is changed in the document.

What I’m asking:

  • Clarifications on how to work with FileDocument in my case of MVVM (I’m open to change the entire workflow, my main knowledge is on the Model built in Swift, but not SwiftUI)
  • Why isn’t the computed area on the DocumentView at the right value when I open a document? I would like to open documents and have it in the “right” state. In reality, I’m computing an image of the electric motor, and it would be nice to open the document and see the “real” image, and not a dummy image before I can validate the geometry.
  • I have these warnings popping every time I open a document and that scares me, especially because I want ideally to use swift 6 in the coming future, with concurrency the right way.
    • Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.

Thanks a lot for your help,

Guillaume


I made an example/simplified code that has all the logic. I can't show the entire code in this prompt due to space limitation. Therefore, I put everything (184 lines) in a single Swift file for you to download. You can just create a Multiplatform Document App. Remove all files except the ...App file, in which you can paste the content of the shared swift file. Run on iPhone simulator.

Development environment: Xcode 16, macOS 15

Run-time configuration: iOS 18, macOS 15

  • Open the app, click on "New motor"
  • You will see "Computed area: 3'063..."
  • Click on "Geometry", change "Stator OD" to 60 instead of 80.
  • Click on "Save" button, now Computed area is 863...
  • Click on "Cancel" button, and reopen the same document
  • Problem: area is again 3'063 while when you open Geometry, you see that "Stator OD" is rightfully 60.
Answered by DTS Engineer in 808285022

I won't comment the MVVM part because it is an architecture level choice, which people may have different opinions depending on their experience and their angle to address the problem.

I can explain why the computed area isn't updated though – It is because onAppear is not triggered when you open the document again. As a result, viewModel.updateArea() is not triggered either, and so computedArea is not updated. You can confirm this behavior by putting a print in the closure of onAppear and observing that it only prints the first time you open a document.

The behavior is as designed. Conceptually, a view's onAppear method is only triggered when the view is newly created (and inserted to the view hierarchy). SwiftUI doesn't create a new document view every time you open a document; instead, it uses the new document to refresh the existing document view. This may be a bit mind-bending – If you feel so, I'd suggest that you go through Demystify SwiftUI to see how SwiftUI manages the life cycle of a view.

Regarding FileDocument (or ReferenceFileDocument), I see a document that conforms the protocol a data controller, which wraps the data and also manages the data access (reading and writing). The protocol doesn't update SwiftUI directly in any way though, and so you need to manage the UI update with your own code.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Accepted Answer

I won't comment the MVVM part because it is an architecture level choice, which people may have different opinions depending on their experience and their angle to address the problem.

I can explain why the computed area isn't updated though – It is because onAppear is not triggered when you open the document again. As a result, viewModel.updateArea() is not triggered either, and so computedArea is not updated. You can confirm this behavior by putting a print in the closure of onAppear and observing that it only prints the first time you open a document.

The behavior is as designed. Conceptually, a view's onAppear method is only triggered when the view is newly created (and inserted to the view hierarchy). SwiftUI doesn't create a new document view every time you open a document; instead, it uses the new document to refresh the existing document view. This may be a bit mind-bending – If you feel so, I'd suggest that you go through Demystify SwiftUI to see how SwiftUI manages the life cycle of a view.

Regarding FileDocument (or ReferenceFileDocument), I see a document that conforms the protocol a data controller, which wraps the data and also manages the data access (reading and writing). The protocol doesn't update SwiftUI directly in any way though, and so you need to manage the UI update with your own code.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Document-Based App with MVVM
 
 
Q