0

I think the Environment approach would work, but my codebase is already quite large and everything is working fine. However, the initializer for each view model is called frequently. I’ll try to explain using an example of what I have.

Apple says:

A State property always instantiates its default value when SwiftUI instantiates the view. For this reason, avoid side effects and performance-intensive work when initializing the default value. For example, if a view updates frequently, allocating a new default object each time the view initializes can become expensive. Instead, you can defer the creation of the object using the task(priority:_:) modifier, which is called only once when the view first appears:

struct ContentView: View {
    @State private var library: Library?


    var body: some View {
        LibraryView(library: library)
            .task {
                library = Library()
            }
    }
}

But how to do it if I need to pass model through navigation?

This approach works, but CreateInvoiceViewModel.init is called every time. I find it interesting that I don’t lose the data—maybe it’s just comparing the new object with the old one?

struct HomeScreen: View {
    @State private var createInvoiceViewmodel = CreateInvoiceViewModel()
    
    var body: some View {
        VStack {
            Button {
                createInvoiceViewmodel.navPub.send(.createInvoiceGoTo(.details))
            } label: {
                Text("New Invoice")
                    .style(.labelMedium)
            }
        }
        .navigationDestination(for: CreateInvoiceFlow.self) { screen in
            switch screen {
            case .details:
                CreateInvoiceDetails(viewModel: createInvoiceViewmodel)
            }
        }
}

If i convert to:

struct HomeScreen: View {
@State private var createInvoiceViewmodel: CreateInvoiceViewModel?

var body: some View {
    VStack {
        Button {
            createInvoiceViewmodel.navPub.send(.createInvoiceGoTo(.details))
        } label: {
            Text("New Invoice")
                .style(.labelMedium)
        }
    }
    .task {
        createInvoiceViewmodel = CreateInvoiceViewModel()
    }
    .navigationDestination(for: CreateInvoiceFlow.self) { screen in
        switch screen {
        case .details:
            CreateInvoiceDetails(viewModel: createInvoiceViewmodel!) // this will fail
        }
    }
}

I don’t want to use @StateObject or conform to ObservableObject because I am using @Observable.

I could move CreateInvoiceViewModel up in the hierarchy—would that be a solution?

But I want to understand what Apple’s recommended way is to do navigation and share instances of classes.

7
  • If you have any good documentation on @Observable and working with navigation, please share it. Commented Oct 22 at 13:51
  • In SwiftUI you shouldn't need view models, trying learning the property wrappers especially @Binding which do the same thing. Normally you just have model object and view structs and swiftui handles creating/updating the actual view objects automatically. So you can think of the view structs as your view model then just delete all your unnecessary view model classes. You can init model object as a let in App because its only init once. Commented Oct 22 at 16:09
  • @lokredi Is CreateInvoiceViewModel supposed to be specific to your HomeScreen view or shared so that it applies to multiple views? Commented Oct 22 at 16:59
  • Google fatbobman lazystate Commented Oct 22 at 19:23
  • @AndreiG. its shared with multiple views Commented Oct 23 at 16:52

1 Answer 1

1

Note that a ViewModel is supposed to be specific to a View, if you adopt MVVM. Otherwise, if it is globally available, you may want to name it differently, like a Manager, Observer or whatever is suitable for your model.

In the example below, I am using the name InvoiceManager for the shared Observable instance.

If you want to make an @Observable instance available globally, you have basically two options:

  1. Create an instance of the class in your App struct and share it via the Environment .

  2. Use a singleton that exposes exactly one shared instance of the class.


1. Shared instance via Environment

Consider this simplified InvoiceManager class:

@Observable
final class InvoiceManager {

   var notifyUser: Bool = true

   func create() {}
}

If you instantiate this class in your root view, like you did in HomeScreen, it will be initialized every time you access the home screen. If you don't want this, you can initialize it in the App struct:

struct DemoInvoiceApp: App {

    //State
    @State private var invoiceManager: InvoiceManager = InvoiceManager()

    var body: some Scene {
        WindowGroup {
            InvoiceHomeView()
                .environment(invoiceManager) // <- make the InvoiceManager instance available via the environment
        }
    }
}

Note, it's passed to the InvoiceHomeView root view via .environment(invoiceManager).

Now, it can be accessed by the root view, or any of its child views using @Environment:

 @Environment(InvoiceManager.self) private var invoiceManager

Using the create() function of the InvoiceManager global instance in the body of the home view becomes as simple as:

invoiceManager.create()

To use it in any other (child) views, you don't need to pass it again via environment (like you would pass a parameter). Any other views can simply grab it from @Environment(InvoiceManager.self) as shown before.

2. Use a singleton pattern

Another way of using a global instance is by having the class use a singleton pattern with a private initializer and a shared static instance.

In this case, the InvoiceManager class would look like this:

@Observable
final class InvoiceManager {

    var notifyUser: Bool = true

    //Singleton instance
    static let shared = SharedInvoiceManager()

    //Private init
    private init() {}

   func create() {}
}

This means that instead of creating an instance using let manager = InvoiceManager(), you would simply access the (only) instance via InvoiceManager.shared:

let invoiceManager = InvoiceManager.shared // not constructed; retrieved

So now accessing the create() function of the InvoiceManager singleton in the body of any view becomes as simple as:

InvoiceManager.shared.create()

Note it is no longer needed to grab it via @Environment.

For clarity, here's what the App struct would look like if using a singleton:

struct DemoInvoiceApp: App {

    //Initialize singleton
    let invoiceManager: InvoiceManager = InvoiceManager.shared

    var body: some Scene {
        WindowGroup {
            InvoiceHomeView()
        }
    }
}

Note: Technically, you don't even need to declare it in the App struct, because it will be automatically initialized whenever it's first accessed, but by convention it's a good idea to keep it there.

Here's how it would be used in a view:

struct InvoiceHomeSingletonView: View {

    //Access the singleton instance and assign it to a local property (for convenience)
    let invoiceManager = InvoiceManager.shared

    //Body
    var body: some View {
        NavigationStack {
            VStack {
                Button {
                    invoiceManager.create() // <- using the convenience property
                    InvoiceManager.shared.create() // <- this also works
                } label: {
                    Text("Create invoice")
                }
            }
        }
    }
}

Now, whether you use one or the other is up to you, I recommend reading more on the topic. Most people, including most AI models, will say that the environment method is strongly preferred, with some going as far as calling using a singleton an anti-pattern.

Personally, I use both, because sometimes it's just easier to use a singleton and I have yet to hit any major roadblocks.

Here's a full working example using the environment method:

import SwiftUI
import SwiftData

@main
struct DemoInvoiceApp: App {

    //State
    @State private var invoiceManager: InvoiceManager = InvoiceManager()

    var body: some Scene {
        WindowGroup {
            InvoiceHomeView()
                .environment(invoiceManager) // <- make the InvoiceManager instance available via the environment
                .modelContainer(for: Invoice.self)
        }
    }
}

struct InvoiceHomeView: View {

    //Queries
    @Query(sort: \Invoice.createdDate, order: .reverse) private var invoices: [Invoice]

    //Environment
    @Environment(InvoiceManager.self) private var invoiceManager
    @Environment(\.modelContext) private var modelContext

    //Body
    var body: some View {
        @Bindable var invoiceManager = invoiceManager // <- get a binding if needed

        NavigationStack {
            List {
                if !invoices.isEmpty {
                    Section("Invoices") {
                        ForEach(invoices) {invoice in
                            VStack(alignment: .leading) {
                                LabeledContent {
                                    Button {
                                        invoiceManager.send(invoice: invoice)
                                    } label: {
                                        Image(systemName: "paperplane.fill")
                                    }
                                } label: {
                                    Text("Invoice #\(invoice.numberString)")
                                    Text(invoice.createdDate, format: .dateTime)
                                }
                            }
                        }
                    }
                }
            }
            .overlay {
                if invoices.isEmpty {
                    ContentUnavailableView {
                        Label("No invoices", systemImage: "note.text")
                    } description: {
                        Text("Add invoices by tapping the + button.")
                    }
                }
            }
            .navigationTitle("Invoice Home")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button {
                        withAnimation {
                            invoiceManager.create()
                        }
                    } label: {
                        Image(systemName: "plus")
                    }
                }
            }
            .safeAreaInset(edge: .bottom) {
                VStack {
                    Toggle("Notify user on invoice creation", isOn: $invoiceManager.notifyUser ) // <- uses the binding to the instance
                }
                .padding(.horizontal, 30)
            }
        }
        .task {
            if invoiceManager.context == nil {
                invoiceManager.context = modelContext
            }
        }
    }
}

@Model
final class Invoice {

    //Properties
    var number: Int?
    var createdDate: Date = Date()

    var numberString: String {
        if let number {
            String(format: "%05d", number)
        } else {
            "N/A"
        }
    }
    //Init
    init(number: Int? = nil) {
        self.number = number ?? Int.random(in: 0...99999)
    }

}

@Observable
final class InvoiceManager {

    @ObservationIgnored
    var context: ModelContext?

    var notifyUser: Bool = true

    @MainActor
    func create() {
        guard let context = context else {
            print("InvoiceManager context not set. Ensure the view sets it before calling create().")
            return
        }

        //Create new invoice
        let newInvoice = Invoice()

        //Insert into context
        context.insert(newInvoice)

        //Notify user
        if notifyUser {
            print("-- User notified --")
        }

        //Save context
        try? context.save()

        print("Created invoice with number: \(newInvoice.number ?? -1)")
    }

    func send(invoice: Invoice) {
        print("Send invoice function called for invoice: \(invoice.numberString)")
    }
}

#Preview {
    @Previewable @State var invoiceManager = InvoiceManager()

    InvoiceHomeView()
        .environment(invoiceManager)
        .modelContainer(for: Invoice.self, inMemory: true)
}

Hope this helps.

Sign up to request clarification or add additional context in comments.

1 Comment

What are you trying to achieve with .task? Odd to use that without any awaits.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.