Observing ViewModel Changes in SwiftUI

SwiftUI uses declarative UI programming, where Views have immutable state, changing automatically as the data state they depend on changes. This is a mindset change for most iOS/macOS developers: instead of changing the UI with a “command and control” MVC mindset, data state changes indirectly change the UI.

But how can we signal to SwiftUI’s unseen magical hand that it needs to make changes to our UI?

Three Potential Approaches

This post presents three approaches to signaling changes to the SwiftUI that I’ve found useful:

  1. Binding View Elements to @Published variables in a ViewModel (or an observable Environment object)
  2. Using the .onChanged() event in a View to monitor when a @Published variable changes—and then fire a code block in the view layer.
  3. Using a NotificationCenter message to send an event that a ViewModel or View can subscribe to.

Binding UI Elements to @Published Variables

This first approach should be used whenever possible. It’s a classic MVMM approach where the ViewModel holds application state, and the UI simply presents that state. It’s simple and effective.

To use this approach, create a ViewModel that conforms to the @ObservableObject protocol, and create @Published variables for any ViewModel properties that should be used to configure UI.

In this example, I’ve created a ViewModel that computes a random color every 1 second via a Timer:

class ContentViewModel: ObservableObject {
    @Published var changingColor = Color.blue
    
    init() {
        Timer.scheduledTimer(
             withTimeInterval: 1.0, 
             repeats: true, 
             block: { _ in
               self.changingColor = self.randomColor()
             }
        )
    }
    
    private func randomColor() -> Color {
        return Color(red: Double.random(in: 0.0...1.0),
                    green: Double.random(in: 0.0...1.0),
                      blue: Double.random(in: 0.0...1.0))
    }
}

The companion view creates the ViewModel instance as a @StateObject, and uses the changingColor in the body.

struct ContentView: View {
    @StateObject var viewModel = ContentViewModel()

    var body: some View {
   	   RoundedRectangle(cornerRadius: 20.0)
          .foregroundColor(viewModel.changingColor)
    }
}
Be careful to use @StateObject with ViewModels when needed instead of @ObservedObjects. The latter may be destroyed during view redraws, and you’ll have weird bugs where your state is lost unexpectedly. Donny Wals discusses this in detail in this post

When the application runs, the color of the rectangle changes to a random color every second as the Timer fires.

Implementing .onChange() in the View

If the UI changes require more knowledge of the View than the ViewModel has, we can use an onChange() event in the View to run a code block when a @Published variable in the ViewModel (or in an Environment object) changes.

In the below example, we’ll rotate the view to a random angle, but for some reason the rotation is adjusted depending on the the values returned by a GeometryReader. Since the ViewModel won’t have knowledge of the GeometryReader, instead we’ll observe the change in the View, and run the final rotation calculation there. This is contrived, but hopefully illustrates that a View is capable of running some code when the ViewModel state changes rather than leaving all control in the ViewModel.

Here’s the View with the .onChange() event added:

struct ContentView: View {
  @StateObject var viewModel = ContentViewModel()
  @State private var degrees = 0.0
    
  var body: some View {
    GeometryReader { geom in
        RoundedRectangle(cornerRadius: 20.0)
            .foregroundColor(viewModel.changingColor)
            .frame(width: viewModel.largeSize ? 200 : 100,
                   height: viewModel.largeSize ? 100 : 50)
            .rotationEffect(Angle.degrees(degrees))
            .onChange(of: viewModel.rotationAngle) { newValue in
                withAnimation {
                    if geom.size.width > 300 {
                        degrees = newValue
                    } else {
                        degrees = newValue - 20.0
                    }
                }
            }
    }
  }
}

The effect of this code is that whenever the ViewModel’s changingColor property changes, the RoundedRectangle’s color is changed directly by the ViewModel as before, but the onChange() event handler is the code block that animates a change to its rotation angle.

Observing Notification Center messages

The above two techniques should be sufficient to cover most state changes between ViewModels and EnvironmentObjects. But there is another tool in the toolbox that I’ve occasionally used: our old friend NotificationCenter.

If the UI needs to respond to events that are completely outside the MVVM related objects (and aren’t even part of the SwiftUI stack), such as events that occur within a traditional Singleton object, a NotificationCenter message can provide the needed glue to trigger UI events in the SwiftUI layers.

For example, let’s say we have some DataService running as a Singleton, and that service receives some state change from the outside world…for example a Push Notification or an IoT message, and this outside world event should cause a UI update.

I’ll simulate this by implementing a DataService singleton that sends a Notification event every 2 seconds. This is a simulation to keep the demo code small, but imagine this could be a silent push notification or other network event outside the context of the app itself.

class DataService {
    static let notificationName = "CHANGE_SIZE"
    
    class var sharedInstance:DataService {
        struct SingletonWrapper {
            static let singleton = DataService()
        }
        return SingletonWrapper.singleton
    }
    
    func startService() {
        Timer.scheduledTimer(
                     withTimeInterval: 2.0, 
                     repeats: true, block: { _ in
           NotificationCenter.default.post(
                  name: NSNotification.Name(
                       rawValue: DataService.notificationName), 
                       object: nil)
           })
    }
}

For the purposes of demo, we’ll start the notification events from this service as the application is launched:

struct SwiftUINotifyExampleApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
    
    init() {
        DataService.sharedInstance.startService()
    }
}

Now the DataService is sending a notification message to any subscribers every 2 seconds for the life of the app--but at this point, nobody is listening for this event.

We always prefer to put logic into the ViewModel (not the View), so the ContentViewModel will listen for the NotificationCenter event.

Whenever the NotificationCenter event arrives, the ViewModel will toggle the RoundedRectangle between a large and small rendering mode. Note that the animation this time is done right in the ViewModel.

class ContentViewModel: ObservableObject {
    @Published var changingColor = Color.blue
    @Published var rotationAngle = 0.0
    @Published var largeSize = true
    private var changeSizeMessageObserver:NSObjectProtocol?
    
    init() {
        Timer.scheduledTimer(withTimeInterval: 1.0, 
                          repeats: true, block: { _ in
            self.changingColor = self.randomColor()
            self.rotationAngle = self.changingColor.cgColor!.components![0] * 360.0
        })
        
        changeSizeMessageObserver =
          NotificationCenter.default.addObserver(
            forName: NSNotification.Name(
            rawValue: DataService.notificationName),
            object: nil, 
            queue: nil) {_ in
                withAnimation {
                    self.largeSize.toggle()
                }
        }
    }
    
    deinit {
        if let obs = changeSizeMessageObserver {
            print("Removing observer")
            NotificationCenter.default.removeObserver(obs)
        }
    }
    
    private func randomColor() -> Color {
        return Color(red: Double.random(in: 0.0...1.0),
                    green: Double.random(in: 0.0...1.0),
                      blue: Double.random(in: 0.0...1.0))
    }
}

And the view is updated to connect to the new largeSize ViewModel @Published property:

RoundedRectangle(cornerRadius: 20.0)
       .foregroundColor(viewModel.changingColor)
       .frame(width: viewModel.largeSize ? 200 : 100,
              height: viewModel.largeSize ? 100 : 50)
       .rotationEffect(Angle.degrees(degrees))

Final App

Here's what the final app looks like when we run it in a simulator. Note the three changes to the UI triggered by each of the techniques:

  1. Color change - View color bound to ViewModel @Published variable, which changes the color and triggers a view redraw.
  2. Rotation - View subscribes to .onChange() of the rotation angle, and calcuales the final rotation value based on the ViewModel's initial recommendation.
  3. Size - ViewModel subscribes to a NotificationCenter message, and animates the size change for the View.

Summary

Changing our mindset from imperative programming where we directly change UI (e.g. UIKit) to declarative programming where we use events and data changes to signal UI changes can be a mind-bender—but once we learn some new patterns and tools, it’s easy to accomplish the same ends with the added safety and predictability of decorative UI programming.

The three techniques covered in this post:

  1. Binding @Published variables in ViewModels and Environment objects — use whenever possible.
  2. Using onChanged() in the View to observe change events to @Published variables — use when the publisher of the variable doesn’t have all the information needed to effect the correct UI change.
  3. Using NotificationCenter. Use when the event that should signal the change is external to the MVVM architecture (for example a legacy service or a singleton that isn’t installed as an Environment object).

Code

The source code for this sample project is available on Github:

SwiftUINotifyExample on Github

Rob Kerr
App development for iOS, creating applications for my own development studio (Cuvenx Inc.), and consulting with awesome clients to build their mobile applications.
Ann Arbor, Michigan, USA

Copyright © 2021 Rob Kerr