4

Trying to go from UIKit to SwiftUI I keep wondering how to apply a service layer properly and use observables to publish updates from the service layer all the way out through the view model and to the view.

I have a View that has a View model, which derives from ObservableObject and publishes a property, displayed in the view. Using @EnvironmentObject I can easily access the view model from the view.

struct SomeView: View {
    
    @EnvironmentObject var someViewModel: SomeViewModel
    
    var body: some View {
        TextField("Write something here", text: $someViewModel.someUpdateableProperty)
    }
}

class SomeViewModel: ObservableObject {
    @Published var someUpdateableProperty: String = ""
    
    var someService: SomeService
    
    init(someService: SomeService) {
        self.someService = someService
    }
    
    // HOW DO I UPDATE self.someUpdateableProperty WHEN someService.someProperty CHANGES?
    // HOW DO I UPDATE someService.someProperty WHEN self.someUpdateableProperty CHANGES?
}

class SomeService {
    
    var someProperty: String = ""
    
    func fetchSomething() {
        // fetching
        someProperty = "Something was fetched"
    }

    func updateSomething(someUpdateableProperty: String) {
        someProperty = someUpdateableProperty
    }
}

In my head a simple way could be to forward the property i.e. using computed property

class SomeViewModel: ObservableObject {
    @Published var someUpdateableProperty: String {
        get {
            return someService.someProperty
        }
        set {
            someService.someProperty = value
        }
    }
    
    var someService: SomeService
    
    init(someService: SomeService) {
        self.someService = someService
    }
}

Another approach could be if it was possible to set the two published properties equal to each other:

class SomeService: ObservableObject {
    
    @Published var someProperty: String = ""
    
    func fetchSomething() {
        // fetching
        someProperty = "Something was fetched"
    }
}

class SomeViewModel: ObservableObject {
    @Published var someUpdateableProperty: String = ""
    
    var someService: SomeService
    
    init(someService: SomeService) {
        self.someService = someService
        someUpdateableProperty = someService.someProperty
    }
}

What is the correct way to forward a publishable property directly through a view model?

In UIKit I would use delegates or closures to call from the service back to the view model and update the published property, but is that doable with the new observables in SwiftUI?

*I know keeping the state in the service is not a good practice, but for the example let's use it.

esbenr
  • 1,356
  • 1
  • 11
  • 34

1 Answers1

1

There are, of course, lots of ways to do this, so I can't speculate on the "correct" way. But one way to do this is to remember that ObservableObject only does one thing - it has an objectWillChange publisher, and that publisher sends automatically when any of the @Published properties changes.

In SwiftUI, that is taken advantage of by the @ObservedObject, @StateObject and @EnvironmentObject property wrappers, which schedule a re-rendering of the view whenever that publisher sends.

However, ObservableObject is actually defined within Combine, and you can build your own responses to the publisher.

If you make SomeService an ObservableObject as in your last code example, then you can observe for changes in SomeViewModel:

private var cancellables = Set<AnyCancellable>()

init(someService: SomeService) {
  self.someService = someService
  someService.objectWillChange
    .sink { [weak self] _ in
      self?.objectWillChange.send()
    }
    .store(in: &cancellables)
}

Now, any published change to the service will cascade through to observers of the view model.

It's important to note that this publisher is will change, not did change, so if you are surfacing any properties from the service in your view model, you should make them calculated variables. SwiftUI coalesces all of the will change messages, then schedules a single view update which takes place on the next run loop, by which point the new values will be in place. If you extract values from within the sink closure, they will still represent the old data.

jrturton
  • 118,105
  • 32
  • 252
  • 268
  • Thanks. This explains how I trigger the VM to update when the state of the service changes. But how do I "tie" the property of the service together with the property of the VM? Even though the service trigger an update on the VM, then I'd need to capture that update on the VM, read the updated value from the service and update the property on the VM. – esbenr Jan 18 '22 at 14:31
  • That's what I meant be "make them calculated variables", like in your forwarded property example. Though you wouldn't need to make the forwarded property published, because that would be taken care of by the service, and I'm not sure how well published plays with computed properties. – jrturton Jan 18 '22 at 16:07
  • I see. Published computed properties will not compile. And even if it use a computed property that's not published, I'm pretty sure the view will not update as the computed function will not be a "running function" but only a momentary glimpse. – esbenr Jan 18 '22 at 16:18
  • I guess the conclusion is that ObservableObject is only a good fit for updating the view from the view model. The rest of the way should be with combine or the old fashion way with delegates/closures. – esbenr Jan 18 '22 at 16:22
  • "with a computed property that's not published... I'm pretty sure the view will not update" - not if you follow the pattern in my answer. Your view model is being observed for changes by the view. The view model is observing the service for changes, and will inform the view. – jrturton Jan 18 '22 at 16:54