UIView as a subscriber to #EnvironmentObject - ios

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.

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 modify state var from remote closure

I've got an application written in swift/swiftUI.
the logic part composed of events generator that enable the client to register a callback for each event before the UI part starts.
init.swift
-----------
func applicationDidFinishLaunching(_ aNotification: Notification) {
let event1Handler : eventHandler = {(data) in
ContentView().alert_handler.showAlert = true
}
let event2Handler : eventHandler = {(data) in
...
}
eventGenerator.sharedInstance().registerUIEvent1(event1Handler, event2: event2Handler)
window = NSWindow(...)
window.contentView = NSHostingView(rootView: ContentView())
...
}
in the UI part, there's an optional alert that is presented depent on the showAlert that can be set from the closure from the previous file...
class alertHandler: ObservableObject {
#Published var showAlert = false
}
struct ContentView: View {
#ObservedObject var alert_handler = alertHandler()
var body: some View {
GeometryReader { metrics in
.....
}.alert(isPresented: $alert_handler.showAlert, content: {
Alert(title: Text("Alert:"),
message: Text("press OK to execute default action..."),
dismissButton: Alert.Button.default(
Text("Press ok here"), action: { print("Hello world!") }
)
)
})
Unfortunately I couldn't see the alert appears when the first event was triggered. Perhaps anyone can tell me if I'd doing anything wrong ? or suggest an alternative approach to modify swiftUI struct variable (event_handler) from remote closure ?
I believe that my the problem may derived from ContentView module which is not a singleton, so when I set showAlert I'm doing so for another instance of ContentView. How can I fix my code to access showAlert that belongs to the currently running instance of ContentView ?
Your suspicion about ContentView not being a singleton instance is correct. You can solve this by owning your alertHandler in the parent (in this case, the app delegate) and passing that down to ContentView.
var handler = alertHandler()
func applicationDidFinishLaunching(_ aNotification: Notification) {
let event1Handler : eventHandler = { (data) in
self.handler.showAlert = true //<-- Here
}
let event2Handler : eventHandler = {(data) in
...
}
eventGenerator.sharedInstance().registerUIEvent1(event1Handler, event2: event2Handler)
window = NSWindow(...)
window.contentView = NSHostingView(rootView: ContentView(alert_handler: handler))
...
}
struct ContentView: View {
#ObservedObject var alert_handler : alertHandler //<-- Here
That way, when you modify the showAlert property, the ContentView gets updated because it's the same instance of alertHandler.
Note: I'd consider adopting the Swift conventions of capitalizing type names and using camel case rather than snake case -- it'll make it easier for others to read your code.

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.

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

Keep reference on view/data model after View update

Consider we have a RootView and a DetailView. DetailView has it's own BindableObject, let's call it DetailViewModel and we have scenario:
RootView may be updated by some kind of global event e.g. missed
internet connection or by it's own data/view model
When RootView handling event it's
content is updated and this is causes new struct of DetailView to
be created
If DetailViewModel is created by DetailView on init,
there would be another reference of DetailViewModel and it's state (e.g. selected object) will be missed
How can we avoid this situation?
Store all ViewModels as EnvironmentObjects that is basically a singleton pool. This approach is causes to store unneeded objects in memory when they are not used
Pass throw all ViewModels from RootView to it's children and to children of child (has cons as above + painfull dependencies)
Store View independent DataObjects (aka workers) as EnvironmentObjects. In that case where do we store view dependent states that corresponds to Model? If we store it in View it will end up in situation where we cross-changing #States what is forbidden by SwiftUI
Better approach?
Sorry me for not providing any code. This question is on architecture concept of Swift UI where we trying to combine declarative structs and reference objects with data.
For now I don't see da way to keep references that corresponds to appropriate view only and don't keep them in memory/environment forever in their current states.
Update:
Lets add some code to see whats happening if VM is created by it's View
import SwiftUI
import Combine
let trigger = Timer.publish(every: 2.0, on: .main, in: .default)
struct ContentView: View {
#State var state: Date = Date()
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: ContentDetailView(), label: {
Text("Navigation push")
.padding()
.background(Color.orange)
})
Text("\(state)")
.padding()
.background(Color.green)
ContentDetailView()
}
}
.onAppear {
_ = trigger.connect()
}
.onReceive(trigger) { (date) in
self.state = date
}
}
}
struct ContentDetailView: View {
#ObservedObject var viewModel = ContentDetailViewModel()
#State var once = false
var body: some View {
let vmdesc = "View model uuid:\n\(viewModel.uuid)"
print("State of once: \(once)")
print(vmdesc)
return Text(vmdesc)
.multilineTextAlignment(.center)
.padding()
.background(Color.blue)
.onAppear {
self.once = true
}
}
}
class ContentDetailViewModel: ObservableObject, Identifiable {
let uuid = UUID()
}
Update 2:
It seems that if we store ObservableObject as #State in view (not as ObservedObject) View keeps reference on VM
#State var viewModel = ContentDetailViewModel()
Any negative effects? Can we use it like this?
Update 3:
It seems that if ViewModel kept in View's #State:
and ViewModel is retained by closure with strong reference - deinit will never be executed -> memory leak
and ViewModel is retained by closure with weak reference - deinit invokes every time on view update, all subs will be reseted, but properties will be the same
Mehhh...
Update 4:
This approach also allows you to keep strong references in bindings closures
import Foundation
import Combine
import SwiftUI
/**
static func instanceInView() -> UIViewController {
let vm = ContentViewModel()
let vc = UIHostingController(rootView: ContentView(viewModel: vm))
vm.bind(uiViewController: vc)
return vc
}
*/
public protocol ViewModelProtocol: class {
static func instanceInView() -> UIViewController
var bindings: Set<AnyCancellable> { get set }
func onAppear()
func onDisappear()
}
extension ViewModelProtocol {
func bind(uiViewController: UIViewController) {
uiViewController.publisher(for: \.parent)
.sink(receiveValue: { [weak self] (parent) in
if parent == nil {
self?.bindings.cancel()
}
})
.store(in: &bindings)
}
}
struct ModelView<ViewModel: ViewModelProtocol>: UIViewControllerRepresentable {
func makeUIViewController(context: UIViewControllerRepresentableContext<ModelView>) -> UIViewController {
return ViewModel.instanceInView()
}
func updateUIViewController(_ uiViewController: UIViewController, context: UIViewControllerRepresentableContext<ModelView>) {
//
}
}
struct RootView: View {
var body: some View {
ModelView<ParkingViewModel>()
.edgesIgnoringSafeArea(.vertical)
}
}
Apple says that we should use ObservableObject for the data that lives outside of SwiftUI. It means you have to manage your data source yourself.
It looks like a single state container fits best for SwiftUI architecture.
typealias Reducer<State, Action> = (inout State, Action) -> Void
final class Store<State, Action>: ObservableObject {
#Published private(set) var state: State
private let reducer: Reducer<State, Action>
init(initialState: State, reducer: #escaping Reducer<State, Action>) {
self.state = initialState
self.reducer = reducer
}
func send(_ action: Action) {
reducer(&state, action)
}
}
You can pass the instance of the store into the environment of your SwiftUI app and it will be available in all views and will store your app state without data losses.
I wrote a blog post about this approach, take a look at it for more information
https://swiftwithmajid.com/2019/09/18/redux-like-state-container-in-swiftui/

Resources