Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Expandable List in SwiftUI only expands once and won't contract

I'm trying (and failing!) to implement an expandable list of questions and answers in SwiftUI.

struct FAQ: Equatable, Identifiable {
    static func ==(lhs: FAQ, rhs: FAQ) -> Bool {
        return lhs.id == rhs.id
    }
    let id = UUID()
    let question: String
    let answers: [String]
    var isExpanded: Bool = false
}

struct ContentView: View {
    @State private (set) var faqs: [FAQ] = [
        FAQ(question: "What is the fastest animal on land?", answers: ["The cheetah"]),
        FAQ(question: "What colours are in a rainbox?", answers: ["Red", "Orange", "Yellow", "Blue", "Indigo", "Violet"])
    ]

    var body: some View {
        List {
            ForEach(faqs) { faq in
                Section(header: Text(faq.question)
                    .onTapGesture {
                        if let index = self.faqs.firstIndex(of: faq) {
                            self.faqs[index].isExpanded.toggle()
                        }
                    }
                ) {
                    if faq.isExpanded {
                        ForEach(faq.answers, id: \.self) {
                            Text("• " + $0).font(.body)
                        }
                    }
                }
            }
        }
    }
}

Tapping any question successfully expands the answers into view, but tapping the same header again doesn't contract the answers, nor does tapping a second question expand those answers.

enter image description here

With some judiciously placed prints, I can see that isExpanded is toggled to true on the first question tapped, but then won't toggle back to false.

Could someone explain what I'm doing wrong?

like image 648
Ashley Mills Avatar asked Dec 22 '25 15:12

Ashley Mills


1 Answers

The issue is with your @State var faq: [FAQ] line. The @State property wrapper allows your view to watch for changes in your array, but changing a property of one of the array's elements does not count as a change in the array.

Instead of using a state variable, you probably want to create a small view model, like so:

class ViewModel: ObservableObject {
    @Published var faqs: [FAQ] = [
           FAQ(question: "What is the fastest animal on land?", answers: ["The cheetah"]),
           FAQ(question: "What colours are in a rainbox?", answers: ["Red", "Orange", "Yellow", "Blue", "Indigo", "Violet"])
       ]
}

and update your ContentView:

struct ContentView: View {
    @ObservedObject var model = ViewModel()

    var body: some View {
        List {
            ForEach(model.faqs.indices) { index in
                Section(header: Text(self.model.faqs[index].question)
                    .onTapGesture {
                        self.model.faqs[index].isExpanded.toggle()
                    }
                ) {
                    if self.model.faqs[index].isExpanded {
                        ForEach(self.model.faqs[index].answers, id: \.self) {
                            Text("• " + $0).font(.body)
                        }
                    }
                }
            }
        }
    }
}

The @ObservedObject property wrapper in your ContentView now watches for any changes its ViewModel object announces, and the @Published wrapper tells the ViewModel class to announce anything that happens to the array, including changes to its elements.

like image 163
John M. Avatar answered Dec 24 '25 10:12

John M.