SwiftUI, REST, and websockets

I'm coming back to iOS development after years away and diving head-first into SwiftUI. It's a lot of fun, but I've hit a brick wall.

The scenario is I have a main view (which itself is a tabview, not important other than that it's not the top-level of the view hierarchy). This has subviews that rely on data coming back from a REST call to the cloud, but then some subviews need to turn around and make subsequent network calls to set up websockets for realtime updates.

In the main view's .onAppear, I fire off an async REST call, it returns JSON that gets parsed into a ModelView.

The ViewModel is declared in the top view like this:

class ViewModel: ObservableObject {
    @Published var appData = CurrentREST() // Codables from JSON
    @State var dataIsLoaded : Bool = false
    
    func fetchData() async  {
        await _ = WebService().downloadData(fromURL: "current") { currentData  in
            DispatchQueue.main.async {
                self.appData = currentData
                self.dataIsLoaded = true
            }
        }
    }
}

The main view declares the model view:

struct HomeTabView: View {
    @ObservedObject var viewModel = ViewModel()
    @Binding private var dataReceived: Bool    
...
}

In the toplevel view, the REST call is triggered like this:

.onAppear {
            if !viewModel.dataIsLoaded {
                Task {
                    await viewModel.fetchData()
                    DispatchQueue.main.async {
                        self.dataReceived = true
                    }
                }
            }
        }

The viewModel gets passed down to subviews so they can update themselves with the returned data. That part all works fine.

But it's the next step that break down. A subview needs to go back to the server and set up subscriptions to websockets, so it can do realtime updates from then on. It's this second step that is failing.

The dataReceived binding is set to true when the REST call has completed. The viewModel and dataReceived flags are passed down to the subviews:

 SummaryView(viewModel: viewModel, dataIsLoaded: self.dataReceived)

What needs to happen next is inside the subview to call a function to wire up the next websocket steps. I've tried setting up:

struct SummaryView: View { @ObservedObject var viewModel: ViewModel @State var dataIsLoaded: Bool = false ... }.onChange(of: dataIsLoaded) { setupWebSocket() }

Problem is, the onChange never gets called.

I've tried various permutations of setting up a @State and a @Binding on the view model, and a separate @State on the main view. None of them get called and the subview's function that wires up the websockets never gets called.

The basic question is:

How do you trigger a cascading series of events through SwiftUI so external events (a network call) can cascade down to subviews and from there, their own series of events to do certain things.

I haven't gone deep into Combine yet, so if that's the solution, I'll go there. But I thought I'd ask and see if there was a simpler solution.

Any suggestions or pointers to best practices/code are most appreciated.

Answered by raminf in 792975022

Thanks to @darkpaw for the suggestion. Tried it, but got:

"Accessing State's value outside of being installed on a View. This will result in a constant Binding of the initial value and will not update."

That was new to me, but it led to more research which led to finding a solution. Here's what ended up working:

  1. Changed the ViewModel so the dataIsLoaded attribute, instead of @State is now @Published:
class ViewModel: ObservableObject {
    @Published var appData = CurrentREST() // Codables from JSON
    @Published var dataIsLoaded : Bool = false
...
  1. Changed the invocation of the subview in the main view so it looks like this now:
SummaryView(viewModel: viewModel, dataIsLoaded: $viewModel.dataIsLoaded)

Note that viewModel.dataIsLoaded is now $viewModel.dataIsLoaded (the $ is in front).

Then inside the fetchData function, once the download and parsing are completed, toggling the dataIsLoaded value triggers the next step:

func fetchData() async  {
        await _ = WebService().downloadData(fromURL: "current") { currentData  in
            DispatchQueue.main.async {
                self.appData = currentData
                self.dataIsLoaded = true
            }
        }
    }

Lastly, inside the SummaryView subview, this required changing the @State to @Binding, as was suggested.

However... this also led to another discovery that the whole thing could be simplified. I can completely do away with the whole secondary dataIsLoaded flag in the main view and passed down to the subview and just drive the whole thing from the ViewModel.

class ViewModel: ObservableObject {
    @Published var appData = CurrentREST()
    @Published var dataIsLoaded : Bool = false // <-- important
    
    func fetchData() async  {
        await _ = WebService().downloadData(fromURL: "current") { currentUIData  in
            DispatchQueue.main.async {
                self.appData = currentUIData
                self.dataIsLoaded = true // This triggers the update
            }
        }
    }
}

Then inside the main view, you just pass the ViewModel down to the subview, so it can use the REST data as it sees fit:

SummaryView(viewModel: viewModel)

In the subview, the View is declared so its onChange handler catches the update in the ViewModel's dataIsLoaded field:

struct SummaryView: View {
    @ObservedObject var viewModel: ViewModel
   ...
    var body: some View {
   ...
   }.onChange(of: viewModel.dataIsLoaded) {
            setupWebSocket()
        }

This is much simpler! I'm leaving details on both approaches here for anyone else who might be having the same problem. The nice part is you can have multiple subviews that need the same ViewModel data and they can all use the same technique to update themselves once the REST data is loaded and parsed.

The only part I don't like is the implicit triggering of the subview hierarchy update, which will make it hard for someone else to come around and follow the control flow. But with sufficient documentation, hopefully, that can be mitigated.

Thanks again to @darkpaw for setting me off on a different breadcrumb trail.

You've got this:

struct SummaryView: View {
    @ObservedObject var viewModel: ViewModel
    @State var dataIsLoaded: Bool = false
}.onChange(of: dataIsLoaded) {
    setupWebSocket()
}

So you created a @State var and set it to false. It's not going to change.

If you're calling that with SummaryView(viewModel: viewModel, dataIsLoaded: self.dataReceived) then the @State var should be a @Binding var instead.

I don't know if that'll fix it, but it looks wrong to me.

Accepted Answer

Thanks to @darkpaw for the suggestion. Tried it, but got:

"Accessing State's value outside of being installed on a View. This will result in a constant Binding of the initial value and will not update."

That was new to me, but it led to more research which led to finding a solution. Here's what ended up working:

  1. Changed the ViewModel so the dataIsLoaded attribute, instead of @State is now @Published:
class ViewModel: ObservableObject {
    @Published var appData = CurrentREST() // Codables from JSON
    @Published var dataIsLoaded : Bool = false
...
  1. Changed the invocation of the subview in the main view so it looks like this now:
SummaryView(viewModel: viewModel, dataIsLoaded: $viewModel.dataIsLoaded)

Note that viewModel.dataIsLoaded is now $viewModel.dataIsLoaded (the $ is in front).

Then inside the fetchData function, once the download and parsing are completed, toggling the dataIsLoaded value triggers the next step:

func fetchData() async  {
        await _ = WebService().downloadData(fromURL: "current") { currentData  in
            DispatchQueue.main.async {
                self.appData = currentData
                self.dataIsLoaded = true
            }
        }
    }

Lastly, inside the SummaryView subview, this required changing the @State to @Binding, as was suggested.

However... this also led to another discovery that the whole thing could be simplified. I can completely do away with the whole secondary dataIsLoaded flag in the main view and passed down to the subview and just drive the whole thing from the ViewModel.

class ViewModel: ObservableObject {
    @Published var appData = CurrentREST()
    @Published var dataIsLoaded : Bool = false // <-- important
    
    func fetchData() async  {
        await _ = WebService().downloadData(fromURL: "current") { currentUIData  in
            DispatchQueue.main.async {
                self.appData = currentUIData
                self.dataIsLoaded = true // This triggers the update
            }
        }
    }
}

Then inside the main view, you just pass the ViewModel down to the subview, so it can use the REST data as it sees fit:

SummaryView(viewModel: viewModel)

In the subview, the View is declared so its onChange handler catches the update in the ViewModel's dataIsLoaded field:

struct SummaryView: View {
    @ObservedObject var viewModel: ViewModel
   ...
    var body: some View {
   ...
   }.onChange(of: viewModel.dataIsLoaded) {
            setupWebSocket()
        }

This is much simpler! I'm leaving details on both approaches here for anyone else who might be having the same problem. The nice part is you can have multiple subviews that need the same ViewModel data and they can all use the same technique to update themselves once the REST data is loaded and parsed.

The only part I don't like is the implicit triggering of the subview hierarchy update, which will make it hard for someone else to come around and follow the control flow. But with sufficient documentation, hopefully, that can be mitigated.

Thanks again to @darkpaw for setting me off on a different breadcrumb trail.

Don't forget to tip your server / mark your answer as accepted :)

SwiftUI, REST, and websockets
 
 
Q