0

I'm slowly going crazy because my view doesn't always update, and I can't figure out what's causing the problem. I've added @MainActor for network calls. The picker on CreateInvoiceDetails view is empty and doesn’t show currencies on the first view appearance. This happens ~90% of the time. But if I go back and return to the screen, it shows correctly. Its not happening only with arrays its happening with pure strings.

I am creating viewmodel in HomeScreen and passing it to next view through init.

struct HomeScreen: View {
    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)
            }
        }
}

In CreateInvoiceDetails I tried making network call in onAppear of my view or in a task. None of the approaches work consistently.

struct CreateInvoiceDetails2: View {
    @Bindable var viewModel: CreateInvoiceViewModel
    
    var body: some View {
        VStack {
            Text(viewModel.selectedCurrency?.name ?? "-")
            Picker(selection: $viewModel.selectedCurrency) {
                ForEach(viewModel.currencies) { c in
                    Text(c.name)
                        .tag(c)
                }
            } label:{
                Text("")
            }
        }
        .task {
            await viewModel.getConstants()
        }
//        .onAppear {
//            Task {
//                await viewModel.getConstants()
//            }
//        }
    }
}

The network call succeeds, always returns the same result, and the print statement works always.

@Observable
class CreateInvoiceViewModel: BaseViewModel {
    var selectedCurrency: Currency?
    var currencies: [Currency] = []

@MainActor
    func getConstants() async {
        loadingState = .getConstants
        let response = await repository.getConstants()
        switch response {
        case .result(let everyResponse):
            guard let c = everyResponse.data?.currencies
                .map({ $0.value})
            else { return }
            currencies.removeAll()
            currencies.append(contentsOf: c)
//          currencies = c. // also not working
            print("DEBUGPRINT: currencies: ", c)
            selectedCurrency = c.first
        }
        loadingState = .no
    }
}

@Observable
class BaseViewModel {
    var loadingState: LoadingState = .no
    
    let repository = AppRepository.shared
    
    var navPub = PassthroughSubject<NavigationHelper, Never>()
}

Currency model is:

struct Currency: Decodable, Hashable, Identifiable, Equatable {
    let symbol: String
    let name: String
    let code: String
    
    var id: String {
        code
    }
}

But my view is not updating every time. Mostly the first time when I run it. Is @Observable reliable, or should I go back and use ObservedObject and @Published ?

6
  • 3
    It should be @State private var createInvoiceViewmodel = CreateInvoiceViewModel() in HomeScreen Commented May 27 at 21:47
  • Try using the built-in @State for your view data instead of a custom class. Pass down as let for read only and @Binding for read/write. Commented May 27 at 22:01
  • Also try returning a result from your async func. Commented May 27 at 22:02
  • @JoakimDanielson Thanks! Can you post this as an answer so I can accept it? I can't believe I missed such a simple thing. Commented May 27 at 22:44
  • @State is just for value types (stack memory) if you use it with class it will needlessly re-init expensive heap memory every time the View containing it is init which will slow down SwiftUI. Commented May 28 at 11:25

2 Answers 2

1

An observable type should be used either as a State or Environment object in a view so change the declaration in HomeScreen to

@State private var createInvoiceViewmodel = CreateInvoiceViewModel()

You must use State & Bindable since the sub-view updates the observable object, if the sub-view is only displaying the data of the object it can be let declared.

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

3 Comments

For completeness, you may want to specify why it should be used as a State in this case, as an Observable does not have to be used as a state in a view (since it can be accepted just as well as a let in many cases).
A detailed explanation will be appreciated, especially in this case (using @State and executing an initialiser for an Observable) because there are a few subtleties with the solution. CreateInvoiceViewModel() could execute side effects, which may be an error depending on what the side effects are doing. While it works in many cases (as Andrei already pointed out), in some others cases, a quite elaborate solution will be required.
Then please post a detailed answer explaining all the subtleties and side effects. I have no problem loosing the accepted flag to another answer if it answers the question in a better way but I have no intention to adress all the ins and outs of using the Observable macro in this answer.
-3

.task replaces the need for a class and async funcs should always return results, simply do:

.task {
    myState = await Controller().getConstants()
}

Where the myState is a @State and Controller is the struct that holds your async funcs. A good pattern is to put the controller in the environment so you can mock it for previews, e.g. @Environment(\.myController) var myController.

Comments

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.