Title: Why is deinit not called after removing an instance from an array using SwiftUI Observation framework?
I'm new to SwiftUI and currently experimenting with the new Observation framework. I have a class called Streamer marked with @Observable, and I'm storing instances of it in an array inside a modelManager class (also marked as @Observable).
I'm updating the UI with SwiftUI, and when I remove a Streamer instance from the array, I expect its deinit to be called — but it never is. The object appears to be removed from the array, yet it’s clearly still retained somewhere.
struct MainUI : View {
@State private var streamer = ""
@State private var model = modelManager();
var body: some View {
let _ = MainUI._printChanges()
TextField("remove name", text: $streamer)
Button("refresh") {
if (!model.allStreamers.isEmpty) {
model.removeStreamer(named: streamer)
}
print("Remaining: \(model.allStreamers.count)")
for streamer in model.allStreamers {
print("\(streamer.name) \(streamer.state) \(streamer.title)")
}
}
Button("add streamer") {
model.addStreamer(UUID().uuidString.prefix(5).description)
}
List(model.allStreamers, id: \.name) { streamer in
////
HStack(spacing:10) {
Circle()
.fill( (streamer.state == .online ) ? Color.green : Color.red)
.frame(width: 12, height: 12)
Text(streamer.name)
.bold(true)
Text(streamer.title)
Spacer()
Text(streamer.state.rawValue)
.foregroundStyle((streamer.state == .online ) ? Color.green : Color.red)
.animation(.bouncy(duration: 1.5), value: streamer.state)
}.padding(10)
Button("change status") {
streamer.state = streamerState.allCases.randomElement()!
streamer.title = "title\(Int.random(in: 1...10))"
print(streamer.state.rawValue)
}
///
}
Spacer()
}
}
@Observable
class Streamer {
let name: String
var state: streamerState
var title: String
init(name: String, state: streamerState, title: String) {
self.name = name
self.state = state
self.title = title
}
deinit {
print("\(self.name) has been deinit")
}
}
@Observable
class modelManager {
var allStreamers = [Streamer]()
func addStreamer(_ newStreamer: String) {
allStreamers.append(Streamer(name: newStreamer, state: .offline, title: "my title is \(Int.random(in: 1...100))"))
}
func removeStreamer(named nameToRemove: String) {
if let index = allStreamers.firstIndex(where: { $0.name == nameToRemove }) {
allStreamers.remove(at: index)
}
}
}
Despite removing the instance from the array, deinit is never called. I'm only using the Observation framework.
Why isn't the Streamer instance being deallocated after removal? What am I missing when using the Observation framework?
Thank you in advance!
This is due to the lazy container (List
) caching off-screen views and deferring the teardown/deinit until the respective view is unmounted (you scrolls far enough off-screen, you navigate away, or you unmount the whole list).
You can verify this by switching from a List
to a non-lazy container, like a VStack
, and noticing the deinit will be triggered right away. On the other hand, a LazyVStack
will act just like a List
.
Another approach is to setup a flow where you navigate to the List
and then navigate away from it, moment at which the deinit
will be triggered. The example below shows this scenario.
And yes, forcing a weak capture like @Sweeper showed will also do the trick.
To see the behavior in the below code:
Tap to go to StreamerList
Add some Streamers.
Delete at will (deinit will not be called)
Tap back to return to the main screen (deinit
will be called as soon as List
disappears).
import SwiftUI
struct StreamerMainUI: View {
//Body
var body: some View {
NavigationStack {
NavigationLink(destination: StreamerList()){
Text("Go to Streamer List")
}
}
}
}
struct StreamerList : View {
//State values
@State private var streamer = ""
@State private var model = StreamerManager()
//Body
var body: some View {
let _ = Self._printChanges()
Button("Add Streamer") {
model.addStreamer(UUID().uuidString.prefix(5).description)
}
//
Button("Refresh") {
print("Remaining: \(model.allStreamers.count)")
for streamer in model.allStreamers {
print("\(streamer.name) \(streamer.state) \(streamer.title)")
}
}
List {
ForEach(model.allStreamers, id: \.name) { streamer in
VStack {
HStack(spacing:10) {
Circle()
.fill( (streamer.state == .online ) ? Color.green : Color.red)
.frame(width: 12, height: 12)
Text(streamer.name)
.bold(true)
Text(streamer.title)
Spacer()
Text(streamer.state.rawValue)
.foregroundStyle((streamer.state == .online ) ? Color.green : Color.red)
// .animation(.bouncy(duration: 1.5), value: streamer.state)
}
.padding(10)
HStack {
Button("change status") {
streamer.state = streamer.state == .online ? .offline : .online
streamer.title = "title\(Int.random(in: 1...10))"
print(streamer.state.rawValue)
}
Spacer()
Button(role: .destructive) {
let nameToRemove = streamer.name
Task {
await MainActor.run {
model.allStreamers.removeAll { $0.name == nameToRemove }
}
}
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
}
}
}
}
Spacer()
}
}
enum StreamerState: String, CaseIterable {
case online, offline
}
@Observable
class Streamer {
let name: String
var state: StreamerState
var title: String
init(name: String, state: StreamerState, title: String) {
self.name = name
self.state = state
self.title = title
}
deinit {
print("\(self.name) has been deinit")
}
}
@Observable
class StreamerManager {
var allStreamers = [Streamer]()
func addStreamer(_ newStreamer: String) {
allStreamers.append(Streamer(name: newStreamer, state: .offline, title: "my title is \(Int.random(in: 1...100))"))
}
func removeStreamer(named nameToRemove: String) {
if let index = allStreamers.firstIndex(where: { $0.name == nameToRemove }) {
allStreamers.remove(at: index)
}
}
}
#Preview {
StreamerMainUI()
}