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!