I'm just taking some time to explore SwiftUI / Observable a little more in depth and specifically looking at dependency injection via the SwiftUI Environment.
Then this AppState is injected to the app and views where it's used
@main
struct DependencyInjectionApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.environment(AppState())
}
}
struct ContentView: View {
@Environment(AppState.self) private var appState
@State private var name: String = ""
var body: some View {
Group {
if appState.dataRepository.isLoading {
ProgressView()
} else {
Text(name)
}
}
.padding()
.task {
name = await appState.dataRepository.downloadData()
}
}
}
#Preview {
ContentView()
.environment(AppState())
}
This code causes...
The name to update to "Some data"
No progress view to show while loading inside
On inspection this is because...
The @State for the name property updates once the download happens and redraws the view
The isLoading is inside a struct inside of an Observable class so doesn't redraw the view
If I...
Change the isLoading to be on the AppState this causes a redraw (via AppState being an @Observable)
Change the DataRepository to be a class with @Observable then this also causes redrawing
But...
I'd have expected the fact DataRepository is a value type would cause the @Observable AppState to see something has changed and cause a redraw to SwiftUI, which doesn't seem to be the case
Am I missing something or is this just a limitation of @Observable?
(While the question looks like a SwiftUI specific question, IIUC it's about how observable and async code work together and the behavior can be reproduced by using non-SwiftUI code.)
I think the issue is with the above code. The downloadData() async func receives a mutable self. It first sets its isLoading property, then call an async func, which causes itself suspended. You seemed to think that onChange closure of withObservationTracking should be triggered at step 1. I don't think that's correct. I think it's triggered at step 3, because that's how inout works on concept.
PS: "Using Swift" forum is probably a better place for this question.
class AppState {
var dataRepository = DataRepository() {
willSet { print("willSet") }
didSet { print("didSet") }
}
}
struct DataRepository {
var isLoading = false
mutating func downloadData() {
for i in 0 ..< 100 {
isLoading.toggle()
}
}
}
print("start")
var appState = AppState()
appState.dataRepository.downloadData()
print("end")
In this example isLoading is getting toggled 100 times although dataRepository's willSet / didSet is only called once. Observable using a similar mechanism and thus experiences the same behaviour.
Thanks for the explanation @tera that explains what I'm seeing
OOC is there anywhere I can read more in to when/where/why willSet/didSet is only called once the function finishes? I would have expected that example code mutating func downloadData() to have changed the state of the struct 100 times so would be good to read in to why it doesn't do that a bit more
It’s always called exactly once when passed as an inout parameter (or mutating self), even if the value doesn’t actually change. That fact has also bitten me in the past and I’ve had to say guard value != newValue in didSet listeners before.
mutatingFunction() is equivalent to nonMutatingFunction(&self).
nonMutatingFunction(&self) is semi-equivalent (exclusivity checks and optimisations aside) to self = nonMutatingFunction(self)
for x = foo(...) if x has a set or willSet/didSet observer pair, they are called once when the foo(...) call is completed and its result is available (e.g. to pass it to newValue of willSet):
// pseudocode:
let newValue = foo(...)
x.willSet(newValue: newValue)
let oldValue = x
x := newValue
x.didSet(oldValue: oldValue)