SwiftUI not updating with Bluetooth events that change object variables - ios

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.

Related

How do you edit an ObservableObject’s properties in SwiftUI from another class?

I’m looking for the proper pattern and syntax to address my goal of having an ObservableObject instance that I can share amongst multiple views, but while keeping logic associated with it contained to another class. I’m looking to do this to allow different ‘controller’ classes to manipulate the properties of the state without the view needing to know which controller is acting on it (injected).
Here is a simplification that illustrates the issue:
import SwiftUI
class State: ObservableObject {
#Published var text = "foo"
}
class Controller {
var state : State
init(_ state: State) {
self.state = state
}
func changeState() {
state.text = "bar"
}
}
struct ContentView: View {
#StateObject var state = State()
var controller: Controller!
init() {
controller = Controller(state)
}
var body: some View {
VStack {
Text(controller.state.text) // always shows 'foo'
Button("Press Me") {
print(controller.state.text) // prints 'foo'
controller.changeState()
print(controller.state.text) // prints 'bar'
}
}
}
}
I know that I can use my ObservableObject directly and manipulate its properties such that the UI is updated in response, but in my case, doing so prevents me from having different ‘controller’ instances depending on what needs to happen. Please advise with the best way to accomplish this type of scenario in SwiftUI
To make SwiftUI view follow state updates, your controller needs to be ObservableObject.
SwiftUI view will update when objectWillChange is triggered - it's done automatically for properties annotated with Published, but you can trigger it manually too.
Using same publisher of your state, you can sync two observable objects, for example like this:
class Controller: ObservableObject {
let state: State
private var cancellable: AnyCancellable?
init(_ state: State) {
self.state = state
cancellable = state.objectWillChange.sink {
self.objectWillChange.send()
}
}
func changeState() {
state.text = "bar"
}
}
struct ContentView: View {
#StateObject var controller = Controller(State())

SwiftUI ObservedObject is re-initiated everytime

I have a simple class to store some data in my SwiftUI app:
final class UserData: ObservableObject {
#Published var id = 1
#Published var name = "Name"
#Published var description = "Initial text"
}
I also defined it as EnvironmentObject in the main app struct:
ContentView(document: file.$document)
.environmentObject(UserData())
In my Content View, I embedded a UIKit Text View:
EditorTextView(document: $document.text)
Where EditorTextView is a UITextView that is applied through UIViewRepresentable.
Now, what I'm trying to do is to update UserData within EditorTextView, like, storing some user input into UserData.description. So I defined the class inside my Coordinator as follows (the code is just for example):
class Coordinator: NSObject, UITextViewDelegate {
#ObservedObject var userData = UserData()
...
// Somewhere in the code, I update UserData when user taps Enter:
func textViewDidChange(_ textView: UITextView) {
if (textView.text.last == "\n") {
userData.description = "My New Text"
}
}
My problem is:
Although it is updated with "My New Text" (as debugger shows), the value of userData.description is re-initiated again with the value "Initial text". It's like if UserData class is created everytime.
I tried to use #StateObject instead of #ObservedObject, but it doesn't make a difference.
"It's like if UserData class is created everytime."
It is created everytime, right here:
class Coordinator: NSObject, UITextViewDelegate {
#ObservedObject var userData = UserData() // <--- here
Try this instead, and pass the UserData in from outside:
class Coordinator: NSObject, UITextViewDelegate {
#ObservedObject var userData: UserData
Typically you would also do this in your main app struct:
#StateObject var userData = UserData()
...
ContentView(document: file.$document).environmentObject(userData)

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

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.

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()
}
}

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