Search code examples
swiftswiftuiobservableobjectproperty-wrapper-published

How to receive notification of changes to any ViewModel property using subscribers?


I'm starting coding in Swift, but trying to make an app well, using best practices.

Application should control Philips Hue bridge to change lights states in home. It reads and send http requests from the Hue bridge and must sync it with visible controllers in my app.

Objects received from Hue bridge are stored in HueSession() class and its subclasses and are mapped initially to array of ViewModels and shown on screen. Everything works well up to this point.

Then I want to subscribe to these ViewModels and receive event when user changes application controls. This would allow me to send http request back to Hue bridge.

Unfortunately, although the label on Toggle changes properly, I'm receiving just one event per light when application starts, like this...

false set on Lightstrip 1
false set on Hue color lamp 2
false set on Hue color lamp 1
false set on Hue lightstrip plus

Changing Toggle state in my app does not print the message, but just changes Toggle label text: ON or OFF. Am I using sink in a wrong way? Or appending class to array makes a copy of it, instead a reference?

ApplicationData.swift

class ApplicationData: ObservableObject {
@Published var hueResources: HueSession
@Published var bulbs: [BulbViewModel]

    var hue = HueController()
    
    
    init(){
        bulbs = []
        hueResources = HueSession()
        
        hue.GetLightsList() {
            resources in
            if resources != nil {
                self.hueResources = resources!
                
                self.hueResources.data.map(){
                    value in
                    
                    let bulb = BulbViewModel(id: value.id)
                    bulb.name = value.metadata.name
                    bulb.isOn = value.on.on
                    
                    bulb.$isOn.sink { value in print("\(value) set on \(bulb.name)") }
                    
                    self.bulbs.append(bulb)
                }
                
            }
        }
    }

}

BulbViewModel.swift

class BulbViewModel: ObservableObject, Identifiable {
    @Published
    var color = 250.0
    
    @Published
    var amplitude = 250.0
    
    @Published
    var isOn = false
    
    var isOnText: String {
        get {
            isOn ? "ON" : "OFF"
        }
    }
    
    @Published
    var name: String = ""
    
    @Published
    var id: String
    
    init(id: String){
        self.id = id
    }
}

BulbView.swift

struct BulbView: View {
    @ObservedObject var bulbViewModel: BulbViewModel

    var body: some View {
        VStack{
            Text("Light: \(bulbViewModel.name)")
            Slider(value: $bulbViewModel.amplitude, in: 1...254, step: 1.0)
            Slider(value: $bulbViewModel.color, in: 153...500, step: 1.0)
            Toggle("\(bulbViewModel.isOnText)", isOn: $bulbViewModel.isOn)
        }
    }
}

First I tried subscribing to on change methods on the View components, but it does not sound like a good practice. I don't want to receive tasks in UI layer.


Solution

  • Changed ViewModel to struct and instead of subscribing to publisher, using now simply didSet for properties that should trigger event.

    struct BulbViewModel: Identifiable {
    
    var color: Double = 250.0 {
        didSet {
            print("\(oldValue) => \(color)")
        }
    }
    

    ...

    Longer explanation of the solution:

    Created commandAggregator class to collect data

    class CommandAggregator {
    
        
        var notifyEvent: (HueResource) -> Void
        
        var stateChanges: [HueResource] = []
        
        init(notifyEvent: @escaping (HueResource) -> Void) {
            self.notifyEvent = notifyEvent
        }
    
        
        func Change(bulbViewModel: BulbViewModel) {
            
            let hueResource = HueResourceBulbVMMapper.MapFromBulbVM(bulbViewModel: bulbViewModel)
            
            stateChanges.append(hueResource)
            if notifyEvent != nil {
                self.notifyEvent(hueResource)
            }
        }
        
        func setNotify(notifyEvent: @escaping (HueResource) -> Void){
            self.notifyEvent = notifyEvent
        }
        
        func onNewCommand(executeTask: @escaping (HueResource) -> Void){
            self.notifyEvent = executeTask
            
        }
    }
    

    and linking it to every BulbViewModel struct during initialization.

    var bulb = BulbViewModel(id: value.id, commandAggregator: self.commandAggregator)
    
    struct BulbViewModel ... {
    ...
    var isOn: Bool = false {
            didSet {
                Update(key: "on", value: String(isOn))
                }
        }
    
    func Update(key: String, value: String) {
            if commandAggregator != nil {
                commandAggregator!.Change(bulbViewModel: self)
            }
        }
    }
    

    Then the commandAggregator class triggers notification.

    commandAggregator.setNotify(notifyEvent: {
                hueObject in
                print("\(hueObject.id) => \(hueObject.on!.on)")
                self.hueCommandsQueue.async {
                    self.hue.SetLight(hueResource: hueObject)
                }
            })
    

    Any better/simpler solution would be welcome.