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.
I have the following setup:
@Observable
class AppState {
var dataRepository = DataRepository()
}
struct DataRepository {
private(set) var isLoading = false
mutating func downloadData() async -> String {
isLoading = true
try? await Task.sleep(for: .seconds(3))
isLoading = false
return "Some data"
}
}
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())
}
@State
for the name property updates once the download happens and redraws the viewisLoading
is inside a struct inside of an Observable class so doesn't redraw the viewisLoading
to be on the AppState
this causes a redraw (via AppState
being an @Observable
)DataRepository
to be a class
with @Observable
then this also causes redrawingI'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
?
Yes, this is a limitation of @Observable
. The @Observable
macro works by adding special getters and setters to the class's properties, to track which properties are accessed and modified. That is, it only observes changes when the property setter is called.
Try expanding the @Observable
macro and the @ObservationTracked
macro that the former generates!
In your code, the setter for dataRepository
is only called once for each call of downloadData
, even when isLoading
is set twice in the body of downloadData
. This is just how mutating
methods work. They call the setter of whatever property they are called on, only after they return. When downloadData
returns, isLoading
is set back to false
, so the change to true
is not observed.
Moving isLoading
to AppState
causes the @Observable
macro to add the special getters and setters to it, so each call of isLoading
's setter will be observed. The same applies when you make DataRepository
an observable class.
Only as a proof of concept, you can call the setter of dataRepository
by injecting the instance of AppState
into DataRepository
:
@Observable
class AppState {
var dataRepository = DataRepository()
init() {
dataRepository.appState = self
}
}
struct DataRepository {
private(set) var isLoading = false
var appState: AppState?
mutating func downloadData() async -> String {
// this calls the setter of dataRepository
appState?.dataRepository.isLoading = true
try? await Task.sleep(for: .seconds(3))
// this calls the setter of dataRepository
appState?.dataRepository.isLoading = false
return "Some data"
}
}
Of course, this doesn't make much sense in practice.