swiftuiobservableobservation

Using a struct property inside an Observable object


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:

Code sample

@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())
}

This code causes...

On inspection this is because...

If I...

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?


Solution

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