How to make a list with collapsible sections in SwiftUI
Lists are a great way to show lots of data. Sections are useful for grouping them. How can you build variable, programmatic, and collapsible sections?
One way to group lists is to have a sidebar where you can hide and show different sections. This helps you and your users only see the lists that are useful for your app.
Simple way
iOS14 introduced an optional binding parameter for the section state variable. This, together with the .liststyle sidebar, automatically hides and shows the section.
@State private var isExpandedAllSeas: Bool = true
....
VStack {
List {
Section(
isExpanded: $isExpandedAllSeas,
content: {
ForEach(allSea) { sea in
HStack {
Image(systemName: "water.waves")
Text(sea.name)
}
}
.onDelete(perform: allSeaDelete)
.onMove(perform: allSeaMove)
},
header: {
Text("All seas")
}
)
}
.listStyle(.sidebar)
The initial setting of the state variable allows one to start with collapsed or expanded sections.
The tougher way
What if you have a two-dimensional data array and need different numbers of sections?
Using a state variable as a bool doesn't solve the problem. It makes all sections either closed or open.
We need a Binding<Bool> because this is the type which isExpanded expects. So we must take care of the value with getter and setter properties.
Let us start with the two dimensional data for example some oceans.
@State private var oceanRegions: [OceanRegion] = [
OceanRegion(name: "Pacific",
seas: [Sea(name: "Australasian Mediterranean"),
Sea(name: "Philippine"),
Sea(name: "Coral"),
Sea(name: "South China")]),
OceanRegion(name: "Atlantic",
seas: [Sea(name: "American Mediterranean"),
Sea(name: "Sargasso"),
Sea(name: "Caribbean")]),
OceanRegion(name: "Indian",
seas: [Sea(name: "Bay of Bengal")])
]
Then we need a slightly different state variable.
@State private var isExpanded: Set<String> = []
I'm using a set of strings with all the names of ocean regions of expanded sections.
Now we can build our List with section from OceanRegions with some Seas in every section.
领英推荐
VStack {
List {
ForEach(oceanRegions) { region in
Section(
isExpanded: Binding<Bool> (
get: {
return isExpanded.contains(region.name)
},
set: { isExpanding in
if isExpanding {
isExpanded.insert(region.name)
} else {
isExpanded.remove(region.name)
}
}
),
content: {
ForEach(region.seas) { sea in
Text(sea.name)
}.onDelete(perform: { indexSet in
delete(indexSet: indexSet, region: region.id)
})
},
header: {
Text(region.name)
}
)
}
.listStyle(.sidebar)
We can't use our State variable isExpanded directly because we need to insert and remove region names when the collapse state of the sections changes.
To do this I'm using a Generic Binding of type Bool because that's what isExpanded is expecting.
isExpanded: Binding<Bool> (...)
Now I need my getter and setter logic. This is quite simple I'm using the region.name of the section because it is unique. Of cource one can use an uuid in a more complex scenario.
The getter returns a boolean and because this boolean came from a State variable it's a Binding<Bool>.
The setter on the other hand add a region.name as String to the Set when a Section is expanded and remove the region.name when the Section is collapsed.
isExpanded: Binding<Bool> (
get: {
return isExpanded.contains(region.name)
},
set: { isExpanding in
if isExpanding {
isExpanded.insert(region.name)
} else {
isExpanded.remove(region.name)
}
}
With this setting there is one open point that with this setting all Sections are initially collapsed because the initial state of isExpanded is a empty Set:
@State private var isExpanded: Set<String> = []
But all necessary data is in place and we only have to connect it with an own init function.
init() {
_isExpanded = State(initialValue: Set(oceanRegions.map { $0.name }))
}
To fill the initial state of isExpanded, we just need to map data from the oceanRegions array.
And with this last all of the section from oceanRegions array are displayed expanded.
Article released first on medium.com