Me: “Why is my SwiftUI view not updating when the model changes?”

Development / iOS / Making of Captionista

The initialisers of Views in SwiftUI have some surprising and obscure behaviours related to state. If you’ve used SwiftUI at all you will likely have discovered this.

In addition, the rules around how properties that are wrapped with Property Wrappers are accessed are interesting when inside the initialiser of the type. It is this latter issue that bit me.

I recently had a bug in Captionista where the subtitle list was not updating when the model changed. This was very strange as the model was using Combine to emit changes and it was all doing the right things, and would fix itself if you switched to another screen and came back again.

It turned out that the problem was in some of our model types where they observe other objects using Combine, and forward events to another publisher after doing some work. SwiftUI needs the will change events of ObservedObject but typically in an underlying model you need to emit did change events for other parts of your model / view models to observe. As a result you then need to emit will change when receiving a did change from lower in your model stack.

We had code something like:


class MyModel: ObservableObject {
  @Published var items: [Model] = []
    …
    
  init(other: OtherModel) {
    // Hook into our custom didChange publisher, mutate
    // state and emit a willChange for SwiftUI
    other.didChangePublisher.sink { [weak self] items in
      self?.items = items.map { … }
    }.store(in: &cancellables)
  }
}

This all seemed to work… but then sort of started not working in obscure ways.

I eventually realised that this line was the problem:


self?.items = items.map { … }

Because this Combine subscription is created in the initialiser of my type, the @Published property wrapper is never used even though the events from the upstream publisher happen later, outside the initialiser. So the automatic notification to SwiftUI never happens.

“Of course” you say, and yet… one is also pushed to these kinds of approaches because of the requirements to emit “will” change for SwiftUI but normally needing “didChange” in your data model so that side effects such as mapping in View Models can occur. Not doing this requires two-phase initialisation on your types which is generally best avoided.

The solution of course is to manually emit the willChange:


self?.objectWillChange.send()
self?.items = items.map { … }

You live and learn.

The Author

Marc Palmer (Twitter, Mastodon) is a consultant and software engineer specialising in Apple platforms. He currently works on the iOS team of Concepts sketching app, as well as his own apps like video subtitle app Captionista. He created the Flint open source framework. He can also do a pretty good job of designing app products. Don't ask him to draw anything, because that's really embarrassing. You can find out more here.