Expanding or collapsing views to show or hide content is often necessary for a better user experience. The Section container in SwiftUI is a well-suited view for this purpose, which, as of iOS 17, provides initializers that enable expanding and collapsing, making it easier to present and hide content on demand. While it might not be obvious at first glance, there are two ways to achieve that behavior; programmatically and through user interaction.
In this post, we'll get to know what it takes to put both in motion. In addition to that, we'll meet an approach on how to manage multiple sections efficiently, so you can expand and collapse while avoiding repetitive code and unnecessary work.
Presenting an expandable section
The fastest way to present an expandable Section is by using the following initializer:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
struct ContentView: View { @State private var isExpanded = true var body: some View { List { Section("Europe", isExpanded: $isExpanded) { Text("France") Text("Germany") Text("Italy") } } } } |
The Section(_:isExpanded:content:)
initializer gets as arguments:
- The title of the header that will appear above the section.
- The binding value of a state property that indicates the expanded state of the section.
- A closure with the content of the section.
In this example, isExpanded
is the state property that you see right before the view's body:
1 2 3 |
@State private var isExpanded = true |
You should also notice that, although I'm embedding the Section in a List
here, it could be perfectly fit into a Form
too.
It's obvious that the expanded state of the section can be controlled only programmatically, as there's no direct way to interact with it. If you would run the above code you would see a normal section; no way to collapse it.
However, and for the sake of the tutorial, let's add a toolbar button at the bottom bar that toggles the isExpanded
state, so we can actually view the section expanding and collapsing:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
List { Section("Europe", isExpanded: $isExpanded) { ... } } .toolbar { ToolbarItem(placement: .bottomBar) { Button("", systemImage: "eye") { isExpanded.toggle() } } } |
Running the above now and tapping or clicking on the eye button will make the Section hide and show its content on demand:
With almost no effort we managed to display an expandable Section. But managing the expanded either programmatically or by providing a button in a different location is not always a suitable solution. Users should be able to interact with the Section on demand, so let's get to that.
Initializing an expandable section with customizable header
The Section initializer of the previous part does not allow any customization. To customize the Section title, and most importantly, to make it interactive, then you'd better use the Section(isExpanded:content:header:)
initializer that's shown next:
1 2 3 4 5 6 7 8 9 10 11 12 |
Section(isExpanded: $isExpanded) { Text("France") Text("Germany") Text("Italy") } header: { Button("Europe") { isExpanded.toggle() } .buttonStyle(.plain) } |
Note here that the header is a custom view, and instead of a plain Text view I used a button that toggles the expanded state of the section.
Even though it's now possible to expand and collapse on demand, it's not obvious at all -nor implied by any means- that the title is a button. In fact, users would expect to see a familiar indicator that would identify an expandable area; that is the arrow button (chevron icon) to the trailing edge of the header, similar to the DisclosureGroup view.
To achieve that, we'll change the header a bit, and instead of having a single button, we'll add an HStack with the title on one edge and a button with the chevron icon on the other. Furthermore, the icon will rotate by 90 degrees when the section is expanded so it points towards bottom:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
Section(isExpanded: $isExpanded) { ... } header: { HStack { Text("Europe") Spacer() Button(action: { withAnimation { isExpanded.toggle() } }) { Image(systemName: "chevron.right") .rotationEffect( !isExpanded ? Angle(degrees: 0) : Angle(degrees: 90) ) } .frame(width: 20, height: 20) } } |
Note: The frame is set to avoid an unwanted movement of the content during the animation.
This time isExpanded
is updated using an animation, while the chevron icon indicates that the content can be expanded or collapsed. That's definitely something that users are acquainted with.
Creating reusable views
In cases of multiple sections, repeating the button's code is counter-productive and against the DRY (Don't Repeat Yourself) principle. Additionally, if it ever becomes necessary to make the slightest modification, that should happen to all button occurrences. It would be much better to have the button implemented only once, and manage it as a reusable view whenever necessary.
For that purpose, we can define the following view that contains the button, with the expanded state property existing as a binding property:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
struct ExpandButton: View { @Binding var isExpanded: Bool var body: some View { Button(action: { withAnimation { isExpanded.toggle() } }) { Image(systemName: "chevron.right") .rotationEffect( !isExpanded ? Angle(degrees: 0) : Angle(degrees: 90) ) } .frame(width: 20, height: 20) } } |
We can update the Section's header now as shown in the next code:
1 2 3 4 5 6 7 8 9 10 11 |
Section(isExpanded: $isExpanded) { ... } header: { HStack { Text("Europe") Spacer() ExpandButton(isExpanded: $isExpanded) } } |
Whenever the button is tapped, the isExpanded
binding will update the respective property in the main view, and the Section's content will either expand or collapse.
We could go one step further, and move the entire HStack into another reusable view, just like we did for the button. Doing so is meaningful when a title and the chevron button are the only elements that form the content of the headers in our sections, and once again we avoid repetitive code that way.
So, let's implement one more view:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
struct SectionHeader: View { let title: String @Binding var isExpanded: Bool var body: some View { HStack { Text(title) Spacer() ExpandButton(isExpanded: $isExpanded) } } } |
In the SectionHeader
brand new view, the displayed section title and the expanded state will be given as arguments upon initialization. The value of the isExpanded
binding property especially, is provided as argument to the ExpandButton(isExpanded:)
instance.
For one more time we can update the header of the Section like so:
1 2 3 4 5 6 7 8 9 |
Section(isExpanded: $isExpanded) { Text("France") Text("Germany") Text("Italy") } header: { SectionHeader(title: "Europe", isExpanded: $isExpanded) } |
See that the header content is a single line now, while users can interact with it (the chevron button in particular) and expand or collapse the Section's content. Of course, if you are not happy with the default appearance of the title, you can always change it as you like in the SectionHeader
view. For instance, you can change the font style, or update the view so it accepts the name of an SF Symbol to show as icon along with the text.
Presenting multiple sections
If you have a small number of sections to present in your lists or forms, then repeating the above a few times and having a matching number of state properties to control the expanded states is not a big deal. However, if you'd rather avoid that repetition, or you have a series of sections to show, then there are better paths to take. Right next I'm demonstrating a technique, though you could definitely come up with a different approach. Note that what follows is adapted to the example used in this tutorial; displaying sections with Text views as content. In your own implementation you should adapt accordingly.
That said, let's suppose that we want to display all habitable continents of the world as sections, with some of their countries as the content of each section. The purpose is to add one Section view only in the list (as we met it previously), embedded in a ForEach container. The latter will show multiple sections eventually.
In order to make that happen, we need to have a collection of data that will feed ForEach, and each Section subsequently during the iteration. Before we get to that however, let's define the distinct pieces of data we need to specify for each section. They are the following three:
- The section title.
- The collection of countries.
- The expanded state.
We can represent all these in a struct
that we'll name ExpandableSection
. Note that it conforms to Identifiable
protocol and it contains the additional required id
property (for simplicity, it's a UUID
value). That's necessary for proper iteration in the ForEach container we'll implement in a while:
1 2 3 4 5 6 7 8 |
struct ExpandableSection: Identifiable { let id = UUID() let title: String var items: [String] var isExpanded: Bool } |
Next, we can add the necessary collection of data; an array of ExpandableSection
instances that will contain the continent names as section titles, countries as the section content, and the initial expanded state for each section. In the example here, this can be a state property in the view:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
struct ContentView: View { @State private var sections: [ExpandableSection] = [ ExpandableSection( title: "Africa", items: ["Nigeria", "Kenya", "Egypt"], isExpanded: false ), ExpandableSection( title: "America", items: ["USA", "Canada", "Mexico"], isExpanded: false ), ExpandableSection( title: "Asia", items: ["China", "Japan", "India"], isExpanded: false ), ExpandableSection( title: "Europe", items: ["France", "Germany", "Italy"], isExpanded: false ), ExpandableSection( title: "Oceania", items: ["Australia", "New Zealand", "Fiji"], isExpanded: false ) ] ... } |
We can now go inside the List
and define a ForEach
container, using the sections
collection as the data source -specifically, its binding value ($sections
), since each Section
should be able to update the isExpanded
state of its corresponding ExpandableSection
element.
1 2 3 4 5 |
ForEach($sections) { $section in } |
In the closure of the ForEach we'll define a Section as we did in the previous part. The section
parameter value is an ExpandableSection
instance, so we'll get the values to display through its properties:
1 2 3 4 5 6 7 8 9 10 |
ForEach($sections) { $section in Section(isExpanded: $section.isExpanded) { } header: { SectionHeader(title: section.title, isExpanded: $section.isExpanded) } } |
As for the Section's content, we'll add an inner ForEach that will go through the countries array and present them in a Text view:
1 2 3 4 5 6 7 8 9 10 11 12 |
ForEach($sections) { $section in Section(isExpanded: $section.isExpanded) { ForEach(section.items, id: \.self) { country in Text(country) } } header: { SectionHeader(title: section.title, isExpanded: $section.isExpanded) } } |
That's all the implementation we need in order to show a series of expandable sections, capable of being expanded and collapsed with user interaction:
The entire view implementation is the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
struct ContentView: View { @State private var sections: [ExpandableSection] = [ ExpandableSection( title: "Africa", items: ["Nigeria", "Kenya", "Egypt"], isExpanded: false ), ExpandableSection( title: "America", items: ["USA", "Canada", "Mexico"], isExpanded: false ), ExpandableSection( title: "Asia", items: ["China", "Japan", "India"], isExpanded: false ), ExpandableSection( title: "Europe", items: ["France", "Germany", "Italy"], isExpanded: false ), ExpandableSection( title: "Oceania", items: ["Australia", "New Zealand", "Fiji"], isExpanded: false ) ] var body: some View { NavigationStack { List { ForEach($sections) { $section in Section(isExpanded: $section.isExpanded) { ForEach(section.items, id: \.self) { country in Text(country) } } header: { SectionHeader(title: section.title, isExpanded: $section.isExpanded) } } } .navigationTitle("Continents & Countries") } } } |
Conclusion
Creating an expandable Section
in SwiftUI is not difficult as of iOS 17, and it can often be a useful way to present content in our apps. In this post, you've learned how to manage the expanded state programmatically and how to let users expand and collapse sections on demand. The reusable views we defined will help you avoid repeating code. But in addition, you've also met a way to present multiple sections using a custom type, a collection of data, and a ForEach
container. Hopefully, all of this has been useful to you. As always, thank you for reading!