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.
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:
- 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
...
- 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.