Okay, I have come across an interesting problem that stumps me a bit. First, some background info:
- I have created a very small
View
to show a list of 5 tag buttons in SwiftUI. As the rest of my app is still written inUIKit
this lives inside aUIHostingController
, this is kind of important later on (I believe). - There are 5 fixed tags, whose data resides in
CoreData
, so each tag is represented byTaskTag
, a subclass ofNSManagedObject
. - The tags have a to-many relationship to
CoachingTask
, which is also anNSManagedObject
subclass (the inverse relationship is to-1, so currently eachCoachingTask
can have only oneTaskTag
, multiple tags will be supported later). TheCoachingTask
objects are the important ones here, as the UI is supposed to define whichTaskTag
is associated to a givenCoachingTask
.
Since I also need to provide a UI when no CoachingTask
is currently displayed in a boring old UIView
I was interested in haven a @StateObject
or @ObservedObject
that basically wraps an optional (so the property can be nil
). For various reasons I wanted to use a wrapper like this:
class ObservableOptional<T: ObservableObject>: ObservableObject {
var optional: T? {
willSet {
recreateSubscriber(with: newValue)
objectWillChange.send()
}
}
private var subscriber: AnyCancellable?
init(optional: T?) {
self.optional = optional
recreateSubscriber(with: optional)
}
private func recreateSubscriber(with newOptional: T?) {
subscriber = newOptional?.objectWillChange.sink(receiveValue: { [weak self] _ in
self?.objectWillChange.send()
})
}
}
My SwiftUI view then looks like this:
struct TaskTagsList: View {
@Namespace var namespace
private let title = "TaskTagsList"
@ObservedObject private var model: ObservableOptional<CoachingTask>
private let fixedTags: [TaskTag]
var coachingTask: CoachingTask? {
get { model.optional }
set { model.optional = newValue }
}
var tagButtonModels: [TagButton.ViewModel] { // TagButton.ViewModel is a DAO, a struct
TagButton.ViewModel.tagButtonModels(for: coachingTask, and: fixedTags)
}
init(for coachingTask: CoachingTask?, and fixedTags: [TaskTag]) {
self.fixedTags = fixedTags
model = ObservableOptional(optional: coachingTask)
}
var body: some View {
// next three lines are for debugging and illustrating the issue:
let _ = print(self.title, self.namespace, terminator: " -- ")
let _ = print("model address", Unmanaged.passUnretained(model).toOpaque(), terminator: " -- ")
let _ = Self._printChanges()
HStack {
ForEach(tagButtonModels) { btnModel in
// TagButton is omitted here for now, it's basically just a bubble
TagButton(id: btnModel.id, title: btnModel.title,
iconColor: btnModel.color, active: btnModel.active,
tapped: btnModel.toggleTaskTagOnCoachingTask)
}
Spacer()
}
}
}
The containing ViewController
that embeds the UIHostingController<TaskTagsList>
is pretty standard, in its viewDidLoad
I create and embed the hoster (which all layouts fine) and if the CoachingTask
property changes I recreate the hoster's rootView
with a new TaskTagsList
view.
The interesting part is: When I go back and forth to/from the view controller (i.e. the hoster and everything else gets recreated several times) I can see that my ObservableOptional
instances are not properly dealloced. Also, the actual "view nodes" (for lack of a better word, I don't mean the instances of my TaskTagsList
structs, but the objects SwiftUI creates to render them) seem to not be thrown away. My debugging lines clearly show there are still "old" such nodes (with their own ID and so on).
At first I naturally thought it's because I use @ObservedObject
, but that is not it (i.e. @StateObject
results in the same, see below why I used @ObservedObject
in the first place †).
I am really at a loss here... I understand that SwiftUI basically keeps my observed objects around somewhere (they're reference types for that reason after all) to determine what to redraw when, but I would think that once I dealloc the UIHostingController
that all is gone. Furthermore I am unsure if that's just expected with my code (i.e. I am dumb) and logical that the runtime keeps instances for "previous views" around (and since they reference my ObservableOptional
s they stay as well) or whether it's the other way round and I messed up in a way that keeps the ObservableOptional
s around which in turn keeps the view nodes in memory somehow.
My hunch is the latter, because if I recreate the entire hierarchy for a different CoachingTask
I don't see the same old "ghost nodes" identifiers.
Sorry for the long post, but if you made it this far, perhaps you have an idea or a pointer to a solution?
†: If I simply use a @StateObject
I run into an issue where the model I wrap in it is modified before the view is actually drawn, as the UIHostingController
is created and configured in viewDidLoad
. That gives a runtime warning, which is not the case for @ObservedObject
. Since my TaskTagsList
view does not require relying on one fixed instance of the model I was fine with using the second property wrapper and as said that alone does not result in the "ghost nodes".