New tools available to us with iOS 15 can be used to enhance DisclosureGroups.
This year I found myself in the exact same scenario I was in last year. I was building a new SwiftUI project when the new changes introduced at WWDC were released. And once again I’ve found myself so appreciative of some of the changes that were made to SwiftUI.
For the project I’m working on I have an object that contains an array of objects.
I wanted a screen such as this. It’s a List that has collapsable sections.
SidebarListStyle
I achieved this in SwiftUI by using the listStyle of SidebarListStyle. First for this example I populated the structs.
Then I added a List with a style of SidebarListStyle.
I eventually changed my mind about using this implementation. Initially I was really confused why using SidebarListStyle in two different projects (both with a target of the iPhone) could cause the Lists to look so different.
I could really use some ideas as to why two SwiftUI Lists using the same .listStyle of SidebarListStyle look so different. The right is a super simple test project. Left is a "real" project. For the left where are the separators? Where is the white background for the sections? pic.twitter.com/FVONYmIdQ9
— Chris Wu (@MuseumShuffle) July 8, 2021
After a lot of research I finally figured out that my Lists looked different because in my “real” project I had used a navigationViewStyle of “.stack” in the View that linked to my View with a List. This was done to get around an issue where an “isActive” binding passed to a NavigationLink will stop working if you pass it to a few Views.
Just filed FB9212872 for SwiftUI. I used a NavigationLink with an isActive and then another NavigationLink with another isActive (with a different variable for the Binding, of course). Changing isActive to false in the third View does nothing. pic.twitter.com/5pxRnnXxDC
— Chris Wu (@MuseumShuffle) June 26, 2021
Another thing that wasn’t great was that if you collapse a section, hit the “back” button, and then navigate again to the View the section didn’t remain collapsed.
It was this Tweet by famous iOS developer Emmanuel Crouvisier that really made me pause.
The sidebar list style is really just intended for iPad sidebars I think — using it in other places, such as detail views, is where I’ve seen issues. I’ve coded around most current issues still present in B7 with a slew of #warning to revert the hacks when I can. SwiftUI life…
— Emmanuel Crouvisier (@emcro) August 26, 2021
When someone who has been featured in the App Store gives you a warning it’s time to listen! I hadn’t even considered the fact that I wasn’t using this in an iOS Sidebar! Based off of a suggestion from Emmanuel I looked at using a DisclosureGroup.
DisclosureGroup
We’ll still use a List with the DisclosureGroup. The listStyle has been removed and Section has been replaced with DisclosureGroup.
When all of the DisclosureGroups are expanded the View looks similar (actually better in my opinion).
The issue is that by default DisclosureGroup is not expanded. So the View initially looks like this.
DisclosureGroup lets us pass in a binding called “isExpanded” that controls whether or not it is expanded.
The problem with this code is that collapsing or expanding one section also does it to the others!
The outer ForEach loop is using the same “expandMe” binding for each DisclosureGroup so it makes sense that changing that variable’s value also affects the other DisclosureGroups that are using it. Clearly we need a different binding to pass to each DisclosureGroup in the loop. In this simple example we created a State variable to control whether or not to expand. But how do you control multiple DisclosureGroups in the same List? It’s not so easy to add a State variable for each of them. My project is dealing with a dynamically created List where the number of DisclosureGroups to display could always be changed by the user. How do we create a binding for each of them? This immediately made me wonder if the approach I came up with in my earlier post “Moving items in multiple sections of a SwiftUI List” would work (spoiler alert: it does).
As with my previous post the idea is to move the variable that controls expanding each DisclosureGroup into the outer Struct.
How do we get a binding to these multiple variables? That’s when I remembered something quite interesting from Paul Hudson’s list of SwiftUI changes for iOS 15. SwiftUI now lets us use a List based on a binding. That lets us modify each entry the List is showing. In order to prepare our project for this I moved the State variable to the root view so that it will survive navigating back from the View with the List.
Now look carefully at how the code has been updated to use these bindings. We pass a binding to the ForEach used to build the contents of the List. The closure can then modify the “expandMe” variable in each individual struct to control whether that particular DisclosureGroup is collapsed or expanded.
Now when the View is displayed for the first time all of the parts are expanded by default. If we collapse a DisclosureGroup, go back, and then navigate to the View again the DisclosureGroups that were collapsed by the user remain collapsed.
Addendum
After I posted this blog entry I continued experimenting. Here’s an example if you’d rather have the DisclosureGroups controlled via a State variable in the same View.
Here are structs I set up for this example to support an enum. I’m basing this off of my recent adventures in parsing JSON emoji files. In this example we know that for the data we’re using every country has animals associated with it.
In this example we’re setting up a State variable called controlIt that is an array that will be used to control whether a DisclosureGroup is expanded or not. I’ve added on onAppear closure that will populate an entry for each of our countries when the View appears. The ForEach used to build the List is still being passed a Binding and when the user expands or collapses a DisclosureGroup it updates the appropriate member of the array. I forgot to mention that Jordi Bruin came up with that nice use of “filter” in the inner ForEach that I’m using for matching the corresponding country.
Because we now have control over whether each DisclosureGroup is expanded or collapsed the onAppear logic could be changed. Here I’ve made it so that Canada defaults to collapsed while the other countries are expanded.
When the View first appears Canada is shown as collapsed while the other countries are expanded.
Update January 18, 2022
There is a potential problem with this approach. I realized that if you delete the last entry in the source array it will cause a crash.
I can't determine if I've made a coding error (likely) or if this is a SwiftUI bug. My app will crash if you remove the last element in the source array. If you add a new item then there's no problem removing the item that used to be the last. 1/5🧵 pic.twitter.com/CX9nwAS2bC
— Chris Wu (@MuseumShuffle) January 17, 2022
If I remove the Binding in the ForEach the crash goes away. I submitted feedback FB9850253 about this potential SwiftUI issue.
Wrap Up
What a handy addition to SwiftUI this is!
If this post was helpful to you I’d love to hear about it! Find me on Twitter.