I am trying to wrap my head around SwiftUI and Combine. I want to keep some text in the UI up-to-date with a value. In this case, it's the battery level of the device, for example.
Here is my code. First of all, it seems like this is quite a bit of code to achieve what I want to do, so I'm wondering if I may be able to do without some of it. Also, this code used to run over the summer, but now it crashes, probably due to changes in SwiftUI and Combine.
How can this be fixed to work with the current version of SwiftUI and Combine? And, is it possible to cut back on the amount of code here to do the same thing?
import SwiftUI
import Combine
class ViewModel: ObservableObject {
var willChange = PassthroughSubject<Void, Never>()
var batteryLevelPublisher = UIDevice.current
.publisher(for: \.batteryLevel)
.receive(on: RunLoop.main)
lazy var batteryLevelSubscriber = Subscribers.Assign(object: self,
keyPath: \.batteryLevel)
var batteryLevel: Float = UIDevice.current.batteryLevel {
didSet {
willChange.send()
}
}
init() {
batteryLevelPublisher.subscribe(batteryLevelSubscriber)
}
}
struct ContentView: View {
@ObservedObject var viewModel = ViewModel()
var body: some View {
Text("\(Int(round(viewModel.batteryLevel * 100)))%")
}
}
Minimal working example to be pasted inside iPadOS Swift playground.
Basically it’s the give code aligned with the latest changes from SwiftUI and Combine.
use the @Published property wrapper for any properties you want to observe in your view (Docs: By default an ObservableObject synthesizes an objectWillChange publisher that emits the changed value before any of its @Published properties changes.). This avoids the usage of custom setters and objectWillChange.
the cancellable is the output from Publishers.Assign, it can be used to cancel the subscription manually and for best practice, it will be stored in a “CancellableBag” thus cancel the subscription on deinit. This practice is inspired by other reactive frameworks such as RxSwift and ReactiveUI.
I found without turning on the battery level notifications the KVO publisher for battery level will emit just once -1.0.
// iPadOS playground
import SwiftUI
import Combine
import PlaygroundSupport
class BatteryModel : ObservableObject {
@Published var level = UIDevice.current.batteryLevel
private var cancellableSet: Set<AnyCancellable> = []
init () {
UIDevice.current.isBatteryMonitoringEnabled = true
assignLevelPublisher()
}
private func assignLevelPublisher() {
_ = UIDevice.current
.publisher(for: \.batteryLevel)
.assign(to: \.level, on: self)
.store(in: &self.cancellableSet)
}
}
struct ContentView: View {
@ObservedObject var batteryModel = BatteryModel()
var body: some View {
Text("\(Int(round(batteryModel.level * 100)))%")
}
}
let host = UIHostingController(rootView: ContentView())
PlaygroundPage.current.liveView = host
Here's a generalizable solution that will work for any object that supports KVO:
class KeyPathObserver<T: NSObject, V>: ObservableObject {
@Published var value: V
private var cancel = Set<AnyCancellable>()
init(_ keyPath: KeyPath<T, V>, on object: T) {
value = object[keyPath: keyPath]
object.publisher(for: keyPath)
.assign(to: \.value, on: self)
.store(in: &cancel)
}
}
So to monitor the battery level (for example) in your view you'd add an @ObservedObject
like this
@ObservedObject batteryLevel = KeyPathObserver(\.batteryLevel, on: UIDevice.current)
And then you can access the value either directly or via the @ObservedObject
's value
property
batteryLevel.value
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