Search code examples
iosswiftswiftuimvvm

MVVM and the responsibility of state management


I'm confused by the MVVM architecture, especially when in consideration with having references inside of @Observable classes. Consider the following example:

Suppose I'm implementing an app that needs location authorizations. I want to display a sheet view asking for the authorization if the user hasn't given it. For this, I need some kind of location delegate that responds to authorization status changes. Sources ([1] [2]) generally suggest implementing something like this:

final class LocationManager: NSObject {
    static let shared = LocationManager()

    private let locationManager = CLLocationManager()
    var isAuthorized = false
    
    override init() {
        super.init()
        locationManager.delegate = self
    }
}

extension LocationManager: CLLocationManagerDelegate {
    func locationManagerDidChangeAuthorization(_ lm: CLLocationManager) {
        // Respond to auth changes
        switch locationManager.authorizationStatus {
        case .authorizedAlways, .authorizedWhenInUse:
            isAuthorized = true
        default:
            isAuthorized = false
        }
    }
}

And consider the simplest scenario where I am to implement a view that shows a sheet when isAuthorized is false. My "view" and "view-model" might look something like:

struct ContentView: View {
    @State var viewModel = ViewModel()
    
    var body: some View {
        SomeOtherView()
            .sheet(isPresented: $viewModel.isNotAuthorized) {
                Text("Please authorize location services")
                    .interactiveDismissDisabled()
            }
    }
    
    @Observable class ViewModel {
        var isNotAuthorized = !LocationManager.shared.isAuthorized
    }
}

Correct me if I'm wrong, but I don't think changes to isNotAuthorized are properly observed. So I see a few possible solutions:

  1. I mark LocationManager itself as @Observable, in which case the hope would be that isNotAuthorized inside of ViewModel would be trying to reference a published variable inside of another @Observable class. I'm not sure if this would work, nor whether if it would be a good idea from a code practice perspective.

  2. I use LocationManager directly inside of my view, in which case I would be forgoing the "view-model" in the architecture. Perhaps this is fine since MVVM is just a construct, but this means that LocationManager would now need to manage the states of different views. i.e. I would need to have another variable named isNotAuthorized inside of LocationManager. When there's multiple views and view states, it becomes a mess.

  3. I forgo any type of state management inside of LocationManager and push all state managements to every view's "view-model", I can perhaps achieve this with some sort of call back, but this seems impractical. Namely, this means for any model I implement, it cannot hold any state and must provide ways for view-models to process state changes.

Hence my confusion. It feels like I can either implement MVVM with nested observation or force myself to do unnecessarily complex state managements. Is there something simple I'm missing?


Solution

  • I think the simple thing you are missing is in SwiftUI the View structs are a view model already. Try this:

    struct ContentView: View {    
        var body: some View {        
            SomeOtherView()
                .overlay {
                    AuthorizationView()
                }
            }
        }
    }
    
    struct AuthorizationView: View {
        @StateObject var locationAuthorizer = LocationAuthorizer()
        
        var body: some View {                
            if !locationAuthorizer.isAuthorized) {
                Text("Please authorize location services")
            }
        }
    }
    
    @Observable
    final class LocationAuthorizer: NSObject, ObservableObject {
        private let locationManager = CLLocationManager()
        var isAuthorized = false // @Published not needed because this class is @Observable
        
        override init() {
            super.init()
            locationManager.delegate = self
        }
    
        func locationManagerDidChangeAuthorization(_ lm: CLLocationManager) {
            // Respond to auth changes
            switch locationManager.authorizationStatus {
            case .authorizedAlways, .authorizedWhenInUse:
                isAuthorized = true
            default:
                isAuthorized = false
            }
        }
    }
    

    SwiftUI diffs these view model structs, uses the difference to create/init/deinit UIKit objects. It chooses different UI objects depending on the context and platform. It also updates them on change of region settings, which is something most would never remember to do in their own view models.