4
votes

There are many possible variants of this question, but take as an example the CNAuthorizationStatus returned by CNContactStore.authorizationStatus(for: .contacts), which can be notDetermined, restricted, denied, or authorized. My goal is to always show the current authorization status in my app's UI.

To expose this to SwiftUI, I might make an ObservableObject called ModelData with a contacts property:

final class ModelData: ObservableObject {
    @Published var contacts = Contacts.shared
}

Where contacts contains my contact-specific model code, including Authorization:

class Contacts {
    fileprivate let store = CNContactStore()
    static let shared = Contacts()

    enum Authorization {
        case notDetermined
        case restricted
        case denied
        case authorized
    }
    
    var authorization: Authorization {
        switch CNContactStore.authorizationStatus(for: .contacts) {
        case .notDetermined:
            return .notDetermined
        case .restricted:
            return .restricted
        case .denied:
            return .denied
        case .authorized:
            return .authorized
        @unknown default:
            return .notDetermined
        }
    }
}

And I might add a method that a button could call to request access:

    func requestAccess(handler: @escaping (Bool, Error?) -> Void) {
        store.requestAccess(for: .contacts) { (granted, error) in
            // TODO: tell SwiftUI views to re-check authorization 
            
            DispatchQueue.main.async {
                handler(granted, error)
            }
        }
    }

And for the sake of simplicity, say my view is just:

Text(String(describing: modelData.contacts.authorization))

So my questions are:

  1. Given that ModelData().contacts.authorization calls a getter function, not a property, how can I inform the SwiftUI view when I know it's changed (e.g. where the TODO is in the requestAccess() function)?
  2. Given that the user can toggle the permission in the Settings app (i.e., the value might change out from under me), how can I ensure the view state is always updated? (Do I need to subscribe to an NSNotification and similarly force a refresh? Or is there a better way?)
2
Don't have time for a full answer, but for now I'll say that using @Published with a class (especially a singleton that never changes) is probably not going to yield any useful results. What you'll probably want is a @Published property that stores the result of the last check of the authorization status. contacts can probably turn into a private property, in fact.jnpdx
Good point. But then I’m storing the result myself instead of just passing through the result from the system. I was hoping to avoid the complexity of ever storing the result at all, and instead just find a pattern to broadcast that the state changed when I get an NSNotification or system callback.Aaron Brager
You can for sure come up with a Combine publisher that will let you pipe things in a variety of ways, but I think you'll still need that value stored somewhere. Since SwiftUI is all about declarative state, you can't just use the value in passing like you might in imperative programing to set a value -- you need something set so that you can keep referring to it on subsequent re-renders.jnpdx
@AaronBrager Does my answer below cover all the use cases you can think of? I tested that with .notDetermined > .authorized > .denied Tarun Tyagi

2 Answers

1
votes

As @jnpdx pointed out - using @Published with a class (especially a singleton that never changes) is probably not going to yield any useful results

@Published behaves like CurrentValueSubject and it will trigger an update only in case there are changes in the value it is storing/observing under the hood. Since it is storing a reference to the Contacts.shared instance, it won't provide/trigger any updates for the authorization state changes.

Now to your question - Given that ModelData().contacts.authorization calls a getter function, not a property, how can I inform the SwiftUI view when I know it's changed

As long as you are directly accessing a value out of the getter ModelData().contacts.authorization, it's just a value of Contacts.Authorization type that does NOT provide any observability.

So even if the value changes over time (from .notDetermined => .authorized), there is no storage (reference point) against which we can compare whether it has changed since last time or not.

We HAVE TO define a storage that can compare the old/new values and trigger updates as needed. This can achieved be by marking authorization as @Published like following -

import SwiftUI
import Contacts

final class Contacts: ObservableObject {
    fileprivate let store = CNContactStore()
    static let shared = Contacts()
    
    enum Authorization {
        case notDetermined
        case restricted
        case denied
        case authorized
    }
    
    /// Since we have a storage (and hence a way to compare old/new status values)
    /// Anytime a new ( != old ) value is assigned to this
    /// It triggers `.send()` which triggers an update
    @Published var authorization: Authorization = .notDetermined
    
    init() {
        self.refreshAuthorizationStatus()
    }
    
    private func refreshAuthorizationStatus() {
        authorization = self.currentAuthorization()
    }
    
    private func currentAuthorization() -> Authorization {
        switch CNContactStore.authorizationStatus(for: .contacts) {
        case .notDetermined:
            return .notDetermined
        case .restricted:
            return .restricted
        case .denied:
            return .denied
        case .authorized:
            return .authorized
        @unknown default:
            return .notDetermined
        }
    }
    
    func requestAccess() {
        store.requestAccess(for: .contacts) { [weak self] (granted, error) in
            DispatchQueue.main.async {
                self?.refreshAuthorizationStatus()
            }
        }
    }
    
}

struct ContentView: View {
    @ObservedObject var contacts = Contacts.shared
    
    var body: some View {
        VStack(spacing: 16) {
            Text(String(describing: contacts.authorization))
            
            if contacts.authorization == .notDetermined {
                Button("Request Access", action: {
                    contacts.requestAccess()
                })
            }
        }
    }
}
0
votes

I think you have it all working. This line gets called when user changes the access level from the Settings app.

Text(String(describing: modelData.contacts.authorization))

So your view is always displaying the current state.