Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

What is the most concise way to display a changing value with Combine and SwiftUI?

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)))%")
    }
}
like image 453
gohnjanotis Avatar asked Jan 26 '23 16:01

gohnjanotis


2 Answers

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

like image 189
berni Avatar answered Jan 28 '23 07:01

berni


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
like image 22
Gil Birman Avatar answered Jan 28 '23 06:01

Gil Birman