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.