SwiftUI: making Expandable / Collapsible sections in List view





A common task in iOS development is expandable / folding sections in a UITableView. Today we are realizing this task using SwiftUI. As a small twist, add an animated triangle in the section header and make the cells also expand.



Development took place on Xcode 11.2 for macOS Catalina 10.15.1



Start the project



Launch Xcode, File - New Project - Single View App. In the dialog box, specify the Swift development language, we will form the UI using the SwiftUI.







Data



As demonstration data we will use some funny winged expressions in Latin with their translation into Russian.



Add a new Swift file to the project, call it Data.swift and write the following there:



struct QuoteDataModel : Identifiable { var id: String { return latin } var latin : String var russian : String var expanded = false } struct SectionDataModel : Identifiable { var id: Character { return letter } var letter : Character var quotes : [QuoteDataModel] var expanded = false }
      
      





QuoteDataModel is a model of a single expression, in the future it will become the content of each individual cell. In it, we store the original text of the expression, its translation and the sign of the “expanded” cell (by default it is “minimized”)



SectionDataModel is a model of each separate section, here we store the “letter” of the section, an array of quotes starting with this letter and also a sign of the “expanded” section (by default it is also “collapsed”)



In the future, we will display all this in a List view, which requires that the data for it comply with the Identifiable protocol. To do this, we define the id property, which must be unique for each element in the List.



Further, in the same Data.swift file, we form our data:



 var latinities : [SectionDataModel] = [ SectionDataModel(letter: "C", quotes: [ QuoteDataModel(latin: "Calvitium non est vitium, sed prudentiae indicium.", russian: "  ,   ."), QuoteDataModel(latin: "Conjecturalem artem esse medicinam.", russian: "   ."), QuoteDataModel(latin: "Crede firmiter et pecca fortiter!", russian: "    !")]), SectionDataModel(letter: "H", quotes: [ QuoteDataModel(latin: "Homo sine religione sicut equus sine freno.", russian: "      ."), QuoteDataModel(latin: "Habet et musca splenem.", russian: "   .")]), SectionDataModel(letter: "M", quotes: [ QuoteDataModel(latin: "Malum est mulier, sed necessarium malum.", russian: "   ,   ."), QuoteDataModel(latin: "Mulierem ornat silentium.", russian: "  .")])]
      
      





Let's deal with the interface



Now we will determine how our section heading and each cell will look.



Choose File - New - File - SwiftUI View from the menu. Name the file HeaderView.swift and replace its contents with the following:



 import SwiftUI struct HeaderView : View { var section : SectionDataModel var body: some View { HStack() { Spacer() Text(String(section.letter)) .font(.largeTitle) .foregroundColor(Color.black) Spacer() } .background(Color.yellow) } } struct HeaderView_Previews: PreviewProvider { static var previews: some View { HeaderView(section: latinities[0]) } }
      
      









Now again File - New - File - SwiftUI View. Name the file QuoteView.swift and replace its contents with the following:



 import SwiftUI struct QuoteView: View { var quote : QuoteDataModel var body: some View { VStack(alignment: .leading, spacing: 5) { Text(quote.latin) .font(.title) if quote.expanded { Group() { Divider() Text(quote.russian).font(.body) } } } } } struct QuoteView_Previews: PreviewProvider { static var previews: some View { QuoteView(quote: latinities[0].quotes[0]) } }
      
      









Now open the file ContentView.swift and change the structure of the ContentView as follows:



 struct ContentView: View { var body: some View { List { ForEach(latinities) { section in Section(header: HeaderView(section: section), footer: EmptyView()) { if section.expanded { ForEach(section.quotes) { quote in QuoteView(quote: quote) } } } } } .listStyle(GroupedListStyle()) } }
      
      





Congratulations, you just populated the List with up-to-date data! For each element of the latinities array , we create a section with a header based on the HeaderView and with an empty footer. If the section is “expanded”, then for each expression in the quotes array we form a cell based on QuoteView . In our data, all sections and all cells are “collapsed”, so if you make Canvas visible, you will see only section headers:







As you know, now the application is completely “dead” and still far from our final goal. But soon we will fix it!



Modify section header slightly



Back to the HeaderView.swift file. Inside the HeaderView structure, immediately after the body, add this:



 struct Triangle : Shape { func path(in rect: CGRect) -> Path { var path = Path() path.move(to: CGPoint(x: 0, y: 0)) path.addLine(to: CGPoint(x: 0, y: rect.height - 1)) path.addLine(to: CGPoint(x: sqrt(3)*(rect.height)/2, y: rect.height/2)) path.closeSubpath() return path } }
      
      





This structure returns an equilateral triangle. Now add our triangle to the header. Inside the HStack , before the first Spacer add this:



 Triangle() .fill(Color.black) .overlay( Triangle() .stroke(Color.red, lineWidth: 5) ) .frame(width : 50, height : 50) .padding() .rotationEffect(.degrees(section.expanded ? 90 : 0), anchor: .init(x: 0.5, y: 0.5)).animation(.default))
      
      









Modify data



Back to our data. Open Data.swift and Wrap our latinities array in a new UserData class, like this:



 class UserData : ObservableObject { @Published var latinities : [SectionDataModel] = [ SectionDataModel(letter: "C", quotes: [ QuoteDataModel(latin: "Calvitium non est vitium, sed prudentiae indicium.", russian: "  ,   ."), QuoteDataModel(latin: "Conjecturalem artem esse medicinam.", russian: "   ."), QuoteDataModel(latin: "Crede firmiter et pecca fortiter!", russian: "    !")]), SectionDataModel(letter: "H", quotes: [ QuoteDataModel(latin: "Homo sine religione sicut equus sine freno.", russian: "      ."), QuoteDataModel(latin: "Habet et musca splenem.", russian: "   .")]), SectionDataModel(letter: "M", quotes: [ QuoteDataModel(latin: "Malum est mulier, sed necessarium malum.", russian: "   ,   ."), QuoteDataModel(latin: "Mulierem ornat silentium.", russian: "  .")])] }
      
      





Remember to also mark latinities as @Published .



What have we done?
ObservableObject is a special object for our data that can be “bound” to some View. SwiftUI "monitors" all changes that may affect the View and, after the data has changed, changes the View.



After the “wrapping” of latinities, we had a lot of errors, we will correct them. Open HeaderView.swift and correct the HeaderView_Previews structure as follows:



 struct HeaderView_Previews: PreviewProvider { static var previews: some View { HeaderView(section: UserData().latinities[0]) } }
      
      





Now make similar changes to QuoteView.swift :



 struct QuoteView_Previews: PreviewProvider { static var previews: some View { QuoteView(quote: UserData().latinities[0].quotes[0]) } }
      
      





Open the ContentView.swift file and add this before the body declaration



 @EnvironmentObject var userData : UserData
      
      





Also add the environmentObject (UserData ()) modifier to the structure responsible for creating the preview:



 struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView().environmentObject(UserData()) } }
      
      





Finally, open the SceneDelegate.swift file and replace the line



 window.rootViewController = UIHostingController(rootView: contentView)
      
      





on



 window.rootViewController = UIHostingController(rootView: contentView.environmentObject(UserData()))
      
      





Revive the landscape



Back to the file ContentView.swift. Inside the ContenView structure, immediately after the userData definition, add two functions:



 func sectionIndex(section : SectionDataModel) -> Int { userData.latinities.firstIndex(where: {$0.letter == section.letter})! } func quoteIndex(section : Int, quote : QuoteDataModel) -> Int { return userData.latinities[section].quotes.firstIndex(where: {$0.latin == quote.latin})! }
      
      





Add onTapGesture modifiers to the section header and cell we are creating . Final view of body content:



 var body: some View { List { ForEach(userData.latinities) { section in Section(header: HeaderView(section: section) .onTapGesture { self.userData.latinities[self.sectionIndex(section: section)].expanded.toggle() }, footer: EmptyView()) { if section.expanded { ForEach(section.quotes) { quote in QuoteView(quote: quote) .onTapGesture { let sectionIndex = self.sectionIndex(section: section) let quoteIndex = self.quoteIndex(section: sectionIndex, quote: quote) self.userData.latinities[sectionIndex].quotes[quoteIndex].expanded.toggle() } } } } } } .listStyle(GroupedListStyle()) }
      
      





The sectionIndex and quoteUndex functions return the index of the sections and expressions passed to them. Having received these indexes, we change the values ​​of the expanded properties in our array of latinities , which leads to the folding / unfolding of the section or expression.







Conclusion



The finished project can be downloaded here .



Some useful links:







I hope that the publication will be useful to you!



All Articles