Search code examples
iosswiftforeachstateobservedobject

Why does this SwiftUI List require an extra objectWillChange.send?


Here is a simple list view of "Topic" struct items. The goal is to present an editor view when a row of the list is tapped. In this code, tapping a row is expected to cause the selected topic to be stored as "tappedTopic" in an @State var and sets a Boolean @State var that causes the EditorV to be presented.

When the code as shown is run and a line is tapped, its topic name prints properly in the Print statement in the Button action, but then the app crashes because self.tappedTopic! finds tappedTopic to be nil in the EditTopicV(...) line.

If the line "tlVM.objectWillChange.send()" is uncommented, the code runs fine. Why is this needed?

And a second puzzle: in the case where the code runs fine, with the objectWillChange.send() uncommented, a print statement in the EditTopicV init() shows that it runs twice. Why?

Any help would be greatly appreciated. I am using Xcode 13.2.1 and my deployment target is set to iOS 15.1.

Topic.swift:

struct Topic: Identifiable {
    var name: String = "Default"
    var iconName: String = "circle"
    var id: String { name }
}

TopicListV.swift:

struct TopicListV: View {
    @ObservedObject var tlVM: TopicListVM

    @State var tappedTopic: Topic? = nil
    @State var doEditTappedTopic = false
    
    var body: some View {
        VStack(alignment: .leading) {
            List {
                ForEach(tlVM.topics) { topic in
                    Button(action: {
                        tappedTopic = topic
                        
                        // why is the following line needed?
                        tlVM.objectWillChange.send()
                        
                        doEditTappedTopic = true
                        print("Tapped topic = \(tappedTopic!.name)")
                    }) {
                        Label(topic.name, systemImage: topic.iconName)
                            .padding(10)
                    }
                }
            }
            
            Spacer()
        }
        .sheet(isPresented: $doEditTappedTopic) {
            EditTopicV(tlVM: tlVM, originalTopic: self.tappedTopic!)
        }
    }
}

EditTopicV.swift (Editor View):

struct EditTopicV: View {
    @ObservedObject var tlVM: TopicListVM
    @Environment(\.presentationMode) var presentationMode
    let originalTopic: Topic
    
    @State private var editTopic: Topic
    @State private var ic = "circle"
    let iconList = ["circle", "leaf", "photo"]
    
    init(tlVM: TopicListVM, originalTopic: Topic) {
        print("DBG: EditTopicV: originalTopic = \(originalTopic)")
        self.tlVM = tlVM
        self.originalTopic = originalTopic
        self._editTopic = .init(initialValue: originalTopic)
    }
    
    var body: some View {
        VStack(alignment: .leading) {
            HStack {
                Button("Cancel") {
                    presentationMode.wrappedValue.dismiss()
                }
                Spacer()
                Button("Save") {
                    editTopic.iconName = editTopic.iconName.lowercased()
                    tlVM.change(topic: originalTopic, to: editTopic)
                    presentationMode.wrappedValue.dismiss()
                }
            }
            
            HStack {
                Text("Name:")
                TextField("name", text: $editTopic.name)
                Spacer()
            }
            Picker("Color Theme", selection: $editTopic.iconName) {
                ForEach(iconList, id: \.self) { icon in
                    Text(icon).tag(icon)
                }
            }
            .pickerStyle(.segmented)

            Spacer()
        }
        .padding()
    }
}

TopicListVM.swift (Observable Object View Model):

class TopicListVM: ObservableObject {
    @Published var topics = [Topic]()
    
    func append(topic: Topic) {
        topics.append(topic)
    }
    
    func change(topic: Topic, to newTopic: Topic) {
        if let index = topics.firstIndex(where: { $0.name == topic.name }) {
            topics[index] = newTopic
        }
    }
    
    static func ex1() -> TopicListVM {
        let tvm = TopicListVM()
        tvm.append(topic: Topic(name: "leaves", iconName: "leaf"))
        tvm.append(topic: Topic(name: "photos", iconName: "photo"))
        tvm.append(topic: Topic(name: "shapes", iconName: "circle"))
        return tvm
    }

}

Here's what the list looks like:

Topic List


Solution

  • Using sheet(isPresented:) has the tendency to cause issues like this because SwiftUI calculates the destination view in a sequence that doesn't always seem to make sense. In your case, using objectWillSend on the view model, even though it shouldn't have any effect, seems to delay the calculation of your force-unwrapped variable and avoids the crash.

    To solve this, use the sheet(item:) form:

    .sheet(item: $tappedTopic) { item in
        EditTopicV(tlVM: tlVM, originalTopic: item)
    }
    

    Then, your item gets passed in the closure safely and there's no reason for a force unwrap.

    You can also capture tappedTopic for a similar result, but you still have to force unwrap it, which is generally something we want to avoid:

    .sheet(isPresented: $doEditTappedTopic) { [tappedTopic] in
        EditTopicV(tlVM: tlVM, originalTopic: tappedTopic!)
    }