#Published property inside an NSManagedObject doesn't trigger view updates - ios

I have a Core Data data model that I use in my app, and would like to add a property to that data model that I don't necessarily want to store, so instead of #NSManaged I made that property #Published.
#Published var currentTime = "00:00"
And in the view instances I, of course, use an #ObservedObject
#ObservedObject var timeItem: TimeItem
And in that view, I use a timer to update that value
.onReceive(Timer.publish(every: 0.015, on: .main, in: .common).autoconnect()) { time in
timeItem.currentTime = timeItem.timeFinished.timeIntervalSince(Date()).editableStringMilliseconds()
}
However, that doesn't trigger the view updates. I'm not sure if NSManagedObject is to blame, but if I replace that timeItem.currentTime value with a local #State one, everything works.
#State private var currentTime: String = "00:00"
Any ideas fellas?

It's seems that there is a bug here or whatever it is. I had the same problem. To solve this issue you must trigger publisher manually:
#Published var currentTime = "00:00" {
willSet {
objectWillChange.send()
}
}
At this moment you even don't need #Published wrapper.
To trigger update of #NSManaged properties you can override willChangeValue method.
override public func willChangeValue(forKey key: String) {
super.willChangeValue(forKey: key)
self.objectWillChange.send()
}
I hope Apple will fix this strange behaviour as soon as possible.

Related

SwiftUI not updating with Bluetooth events that change object variables

I’m playing around with some Bluetooth objects and having issues getting SwiftUI to update the view.
Essentially I have a CBCentralManagerDelegate object (btManager) that has a published array of CBPeripheralDelegate objects (btPeripheral) that store their state in variables that get updated when the subscribed Bluetooth messages get processed. However the SwiftUI display (a row in a List) doesn’t update immediately but usually when something else happens to trigger an update (e.g. a different button press).
Im guessing it’s to do with the asynchronous way the peripheral’s delegate methods get called, but despite both classes being ObservableObjects and the array in the manager being #Published it doesn’t update the display straight away.
Any tips would be welcome!
Difficult to show all the code, but the gist of it is:
class BTDataManager: NSObject, ObservableObject, CBCentralManagerDelegate {
#Published var peripherals: [BTPeripheral] = []
private var centralManager: CBCentralManager!
// CBCentralManagerDelegate methods
}
class BTPeripheral: NSObject, ObservableObject, Identifiable, CBPeripheralDelegate {
var manager: BTDataManager?
var peripheral: CBPeripheral?
var RSSI: Int?
var data = "Test"
var id = UUID()
// CBPeripheralDelegate methods
}
So for example when a peripheral delegate method is called and the data var is updated, the SwiftUI view doesn't immediately update.
My ListView is:
struct PeripheralList: View {
#EnvironmentObject var modelData: BTDataManager
var body: some View {
List{
ForEach (modelData.peripherals){ peripheral in
PeripheralDetailRow(peripheral: peripheral)
.environmentObject(modelData)
}
}
}
}
struct PeripheralDetailRow: View {
#EnvironmentObject var modelData: BTDataManager
var peripheral: BTPeripheral
var index: Int {
modelData.peripherals.firstIndex(where: { $0.id == peripheral.id })!
}
var body: some View {
VStack {
Text(peripheral.id.uuidString.suffix(5))
Text(modelData.peripherals[index].data)
.font(caption)
}
}
When a peripheral's delegate function is fired and updates the data var, then the view doesn't update until something else triggers a refresh.
Ok, after typing this out I think I have solved it.
var data becomes #Published var data
In the DetailRow, I have #EnvironmentObject var peripheral: BTPeripheral and I don't need the modelData or index stuff.
In the List View I use .environmentObject(peripheral) to pass it through.
Seems to work now, although I'm not 100% that this is the correct way.

How do I call a function on a view when the #ObservedObject is updated

I have an Observed Object that's working properly. When I update a String, the label updates.
However, I have a Bool that needs to call a custom function when it changes inside of an Observable Object. When the Bool is set to true, I need to flash the background color for 0.1 seconds.
class Event: ObservableObject {
static let current = Event()
#Published var name = ""
#Published var pass = false
}
struct EnterDistanceView: View {
#ObservedObject var event = Event.current
//when event.pass == true, call this
func flash() {
//UI update of flash
}
}
How do I call a method when a property inside of the #ObservedObject changes? Is this possible, or do I need to create
You can use onReceive to do imperative actions when a published value changes:
struct EnterDistanceView: View {
#ObservedObject var event = Event.current
func flash() {
//UI update of flash
}
var body: some View {
Text("Hello world")
.onReceive(event.$pass) { value in
if value {
//do your imperitive code here
flash()
}
}
}
}
Inside your flash method, I assume you'll want to change the value of a #State variable representing the screen color and then use DispatchQueue.main.asyncAfter to change it back shortly thereafter.

Subscribing to value changes inside child objects of environment view models (view not being re-rendered when this happens)

I have a view model that is parent to other children view models. That is:
public class ViewModel: ObservableObject {
#Published var nav = NavigationViewModel()
#Published var screen = ScreenViewModel()
The other children view model, such as nav and screen, all serve a specific purpose. For example, nav’s responsibility is to keep track of the current screen:
class NavigationViewModel: ObservableObject {
// MARK: Publishers
#Published var currentScreen: Screen = .Timeline
}
The ViewModel is instantiated in the App struct:
#main
struct Appy_WeatherApp: App {
// MARK: Global
var viewModel = ViewModel()
// MARK: -
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(viewModel)
}
}
}
And I declare an #EnvironmentObject for it on any view that needs access to it:
#EnvironmentObject var viewModel: ViewModel
Any view referencing a non-object property of ViewModel that is being #Published whose value changes will result in the view to be re-rendered as expected. However, if the currentScreen #Published property of the NavigationViewModel changes, for example, then the view is not being re-rendered.
I know I can make it work if I separate NavigationViewModel from ViewModel, instantiate it at the app level and use it as its own environment object in any views that access any of its published properties.
My question is whether the above workaround is actually the correct way to handle this, and/or is there any way for views to be subscribed to value changes of properties inside child objects of environment objects? Or is there another way that I’ve not considered that’s the recommended approach for what I’m trying to achieve through fragmentation of view model responsibilities?
There are several ways to achieve this.
Option 1
Using Combine.
import Combine
public class ViewModel: ObservableObject {
#Published var nav = NavigationViewModel()
var anyCancellable: AnyCancellable?
init() {
anyCancellable = nav.objectWillChange.sink { _ in
self.objectWillChange.send()
}
}
}
You basically just listen to whenever your navigationViewModel publishes changes. If so, you tell your views that your ViewModel has changes aswell.
Option 2
I suppose due to the name NavigationViewModel, that you would use it quite often inside other view models?
If that's the case, I would go for a singleton pattern, like so:
class NavigationViewModel: ObservableObject {
static let shared = NavigationViewModel()
private init() {}
#Published var currentScreen: Screen = .Timeline
}
Inside your ViewModel:
public class ViewModel: ObservableObject {
var nav: NavigationViewModel { NavigationViewModel.shared }
}
You can of course also call it inside any View:
struct ContentView: View {
#StateObject var navigationModel = NavigationModel.shared
}
You might have to call objectWillChange.send() after changing publishers.
#Published var currentScreen: Screen = .Timeline {
didSet {
objectWillChange.send()
}
}

UIView as a subscriber to #EnvironmentObject

I've got an #EnvironmentObject that's updated in a worker thread, and several SwiftUI views subscribe to changes to the published values.
This all works quite nicely.
But I'm struggling to get a UIView to subscribe to changes in the #EnvironmentObject.
Given
#EnvironmentObject var settings: Settings
where Settings is:
final class Settings {
#Published var bar: Int = 0
#Published var beat: Int = 1
etc.
}
SwiftUI views update based on published value changes rather nicely.
But now, I want to declare a sink that receives the published values inside a UIView that conforms to UIViewRepresentable.
I've been working through the Combine book, and thought that I could declare a .sink closure with something like:
func subscribeToBeatChanges() {
settings.$bar
.sink(receiveValue: {
bar in
self.bar = bar
print("Bar = \(bar)")
} )
settings.$beat
.sink(receiveValue: {
beat in
self.beat = beat
print("Beat = \(beat)")
self.setNeedsDisplay()
} )
}
Unfortunately, the closure is only called once, when subscribeToBeatChanges() is called. What I want is for the closure to be called every time a #Published property in the #EnvironmentObject value changes.
I've also tried to subscribe inside the UIViewRepresentable wrapper, with something inside the makeUIView method, but was unsuccessful.
I'm obviously doing some rather simple and fundamental wrong, and would sure appreciate a push in the right direction, because I'm getting cross-eyed trying to puzzle this out!
Thanks!
You have to store a reference to the AnyCancellable that you receive back from .sink:
private var cancellables = Set<AnyCancellable>()
func subscribeToBeatChanges() {
settings.$bar
.sink(receiveValue: {
bar in
self.bar = bar
print("Bar = \(bar)")
} )
.store(in: &cancellables) // <-- store the instance
settings.$beat
.sink(receiveValue: {
beat in
self.beat = beat
print("Beat = \(beat)")
self.setNeedsDisplay()
} )
.store(in: &cancellables) // <-- store the instance
}
If you don't store it, the AnyCancellable instance will automatically cancel a subscription on deinit.

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)))%")
}
}
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

Resources