I'm trying to figure out how to work with async operations using Combine and SwiftUI.
For example, I have a HealthKitManager
class that, among other things, handles requesting health store authorisation…
final class HealthKitManager {
enum Error: Swift.Error {
case notAvailable
case authorisationError(Swift.Error)
}
let healthStore = HKHealthStore()
func getHealthKitData(for objects: Set<HKObjectType>, completion: @escaping (Result<Bool, Error>) -> Void) {
guard HKHealthStore.isHealthDataAvailable() else {
completion(.failure(.notAvailable))
return
}
self.healthStore.requestAuthorization(toShare: nil, read: objects) { completed, error in
DispatchQueue.main.async {
if let error = error {
completion(.failure(.authorisationError(error)))
}
completion(.success(completed))
}
}
}
}
which is used as follows…
struct ContentView: View {
let healthKitManager = HealthKitManager()
@State var showNextView = false
@State var showError = false
@State var hkError: Error?
let objectTypes = Set([HKObjectType.quantityType(forIdentifier: .bloodGlucose)!])
var body: some View {
NavigationView {
NavigationLink(destination: NextView(), isActive: $showNextView) {
Button("Show Next View") {
self.getHealthKitData()
}
}.navigationBarTitle("Content View")
}.alert(isPresented: $showError) {
Alert(title: Text("Error"), message: Text(hkError?.localizedDescription ?? ""), dismissButton: .cancel())
}
}
func getHealthKitData() {
self.healthKitManager.getHealthKitData(for: self.objectTypes) { result in
switch result {
case let .success(complete):
self.showNextView = complete
case let .failure(error):
self.hkError = error
self.showError = true
}
}
}
}
What I'd like to do is use Combine rather than a Result
closure. I'm guessing something like this…
final class HealthKitManager: ObservableObject {
enum Error: Swift.Error {
case notAvailable
case authorisationError(Swift.Error)
}
@Published var authorisationResult: Result<Bool, Error>?
let healthStore = HKHealthStore()
func getHealthKitData(for objects: Set<HKObjectType>) {
guard HKHealthStore.isHealthDataAvailable() else {
self.authorisationResult = .failure(.notAvailable)
return
}
self.healthStore.requestAuthorization(toShare: nil, read: objects) { completed, error in
DispatchQueue.main.async {
if let error = error {
self.authorisationResult = .failure(.authorisationError(error))
return
}
self.authorisationResult = .success(completed)
}
}
}
}
But then it's unclear how to bind to the values for NavigationLink(isActive:)
and alert(isPresented:)
, and to get the error.
struct ContentView: View {
@ObservedObject var healthKitManager = HealthKitManager()
let objectTypes = Set([HKObjectType.quantityType(forIdentifier: .bloodGlucose)!])
var body: some View {
NavigationView {
NavigationLink(destination: NextView(), isActive: ????) { // How do I get this
Button("Show Next View") {
self.healthKitManager.getHealthKitData(for: self.objectTypes)
}
}.navigationBarTitle("Content View")
}.alert(isPresented: ????) { // or this
Alert(title: Text("Error"), message: Text(????.localizedDescription ?? ""), dismissButton: .cancel()) // or this
}
}
}
I'm guessing that @Published var authorisationResult: Result<Bool, Error>?
isn't correct?
Should I be using Future / Promise
, something else?
Update
I found there's another way to present an alert…
.alert(item: self.$error) { error in
Alert(title: Text(error.localizedDescription))
which means I don't need the Bool for showError
(it just requires the Error
object to be Identifiable
)
Combine is a framework that lets you handle asynchronous events and maintain states in your app's lifecycle, an aspect that is absolutely necessary in any app. It allows you to sync data across your app and have your UI respond instantly to its changes.
Before moving forward, it's important to highlight that Async/Await is not meant to replace Combine. Thus, code that operate on streams should remain using the latter. This article will be focused on code that doesn't necessarily need such capabilities.
Combine is Apple's framework to work with asynchronous events in a unified and reactive way that ensures your app is always up to date based on the latest state of its data.
Mark functions that call asynchronous code as async. Mark functions that update published properties with the @MainActor attribute. To fetch data when a view appears, use the task view modifier. Use the refreshable view modifier to use pull-to-refresh to fetch data asynchronously.
I like having result
as you did in second variant
@Published var authorisationResult: Result<Bool, Error>?
so the possible approach for usage can be as follow
NavigationLink(destination: NextView(), isActive:
Binding<Bool>.ifSuccess(self.healthKitManager.authorisationResult)) {
Button("Show Next View") {
self.healthKitManager.getHealthKitData(for: self.objectTypes)
}
}.navigationBarTitle("Content View")
where some convenient extension
extension Binding {
static func ifSuccess<E>(_ result: Result<Bool, E>?) -> Binding<Bool> where E: Error {
Binding<Bool>(
get: {
guard let result = result else { return false }
switch result {
case .success(true):
return true
default:
return false
}
}, set: { _ in })
}
}
the variant for error
can be done in similar way.
extension Result {
func getFailure() -> Failure? {
switch self {
case .failure(let er):
return er
default:
return nil
}
}
func binding<B>(
success successClosure: (@escaping (Success) -> B),
failure failureClosure: @escaping (Failure) -> B) -> Binding<B> {
return Binding<B>(
get: {
switch self {
case .success(let value):
return successClosure(value)
case .failure(let failure):
return failureClosure(failure)
}
}, set: { _ in })
}
func implicitBinding(failure failureClosure: @escaping (Failure) -> Success) -> Binding<Success> {
return binding(success: { $0 }, failure: failureClosure)
}
}
class HealthKitManager: ObservableObject {
enum Error: Swift.Error {
case authorisationError(Swift.Error)
case notAvailable
}
@Published var authorisationResult = Result<Bool, Error>.failure(.notAvailable)
let healthStore = HKHealthStore()
func getHealthKitData(for objects: Set<HKObjectType>) {
guard HKHealthStore.isHealthDataAvailable() else {
self.authorisationResult = .failure(.notAvailable)
return
}
self.healthStore.requestAuthorization(toShare: nil, read: objects) { completed, error in
DispatchQueue.main.async {
if let error = error {
self.authorisationResult = .failure(.authorisationError(error))
return
}
self.authorisationResult = .success(completed)
}
}
}
}
struct ContentView: View {
@ObservedObject var healthKitManager = HealthKitManager()
let objectTypes = Set([HKObjectType.quantityType(forIdentifier: .bloodGlucose)!])
var body: some View {
NavigationView {
NavigationLink(destination: NextView(),
isActive: healthKitManager.authorisationResult.implicitBinding(failure: { _ in false })) {
Button("Show Next View") {
self.healthKitManager.getHealthKitData(for: self.objectTypes)
}
}.navigationBarTitle("Content View")
}.alert(isPresented: healthKitManager.authorisationResult.binding(success: { _ in false }, failure: { _ in true })) {
let message = healthKitManager.authorisationResult.getFailure()?.localizedDescription ?? ""
return Alert(title: Text("Error"), message: Text(message), dismissButton: .cancel()) // or this
}
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With