Collapsable Section Headers in SwiftUI

Let’s say you have this SwiftUI List with a bunch of different sections. It might be nice if users could tap on the section header and have that section collapse. Its actually pretty easy to do this, you just need a few parts.

Model

Start with a model, which we’ll call SectionModel. It is an ObservableObject because you want SwiftUI to recognize when it changes and redraw the UI. The model’s job is to know which sections are open and which are not.

// 1
class SectionModel: NSObject, ObservableObject {
    // 2
    @Published var sections: [String:Bool] = [String:Bool]()

    func isOpen(title: String) -> Bool {
        // 3
        if let value = sections[title] {
            return value
        } else {
            return true
        }
    }

    // 4
    func toggle(title: String) {
        let current = sections[title] ?? true
        withAnimation {
            sections[title] = !current
        }
    }
}
  1. Declare the SectionModel to implement the Observable protocol. This will allow it to be a @ObservedObject later.

  2. The sections dictionary holds the Bool to say whether or not the section is open (true) or closed (false). This is marked to be @Published so SwiftUI knows it should be watched for changes.

  3. The isOpen function looks to see if a section, by its title, has a value and if so, returns it. If the section has not yet been toggled open or closed, return true - by default all sections are open. You can return false if you want the List to initially show all the sections as closed.

  4. The toggle() function simple inverts the value of the section state and again uses true as the default state.

Section Header

Now we need a custom Section header. This will be simple also: a Text, a Spacer, and an Image that shows the section open or closed.

// 1
struct CategoryHeader: View {
    var title: String
    // 2
    @ObservedObject var model: SectionModel
    var body: some View {
        HStack {
            Text(title)
            Spacer()
            // 3
            Image(systemName: model.isOpen(title: title) ?
                "chevron.down" : 
                "chevron.up")
        }
        // 4
        .contentShape(Rectangle())
        .onTapGesture {
            // 5
            self.model.toggle(title: self.title)
        }
    }
}
  1. Create the CategoryHeader as a SwiftUI View struct. We are calling this “CategoryHeader” to avoid confusion with header part of the SwiftUI Section.

  2. We are going to pass to this struct the SectionModel and mark it as an @ObservedObject so SwiftUI will notice when it changes (from the SectionModel toggle() function).

  3. The Image displayed depends on the state of the section’s “openness”.

  4. Use a contentShape of Rectangle so that the user can tap anywhere in the header to open or close it. Without contentShape the user could only tap on the title or the chevron.

  5. Adding the onTapGesture makes it possible for the user to tap the section header to open or close which is handled by calling on the model.

When the user taps the header, the model just changes the values inside its sections dictionary. Because this is an observed property (@Published inside an ObservableObject), SwiftUI will detect that change and redraw the header.

The List

Now to modify the List. The outer ForEach is going through your primary model which is (I assume) divided into the sections. So each section needs to display the header. Whether or not its content is showing depends on the value in the SectionModel. The List looks like this:

List {
    // 1
    ForEach(self.dataModel, id:\.self) { data in
       // 2
       Section(
           header: CategoryHeader(
                        title: data.title, 
                        model: self.sections)
      ) {
          // 3
          if self.sections.isOpen(title: data.title) {
              // a ForEach to render all of the items in this section
          } else {
              EmptyView()
          }
    }
}
  1. The outer ForEach is looping through your data model, extracting a single item.

  2. The SwiftUI section is given the CategoryHeader defined earlier with the title and model (which will be defined in a moment).

  3. If the section for this item is open (based on the title), the ForEach will run and display the content rows. If it is closed, the else clause displays EmptyView().

Define the sectionModel outside of this View’s var body:

@ObservedObject private var sections = SectionModel()

When you tap on the header, its onTapGesture tells the model to toggle the value of the section’s open/closed state. Because the model is ObservableObject and has a Published member which is being changed, SwiftUI will redraw the List and your section will close (or open).

You’ll see that this animates. What SwiftUI is doing is comparing the last version of the List (section is open) to the new version (section is closed) and removes just the items being hidden.

And that’s all there is to it.

Previous
Previous

A Year of SwiftUI