
Нередко встречающаяся в разработке под iOS задача — раскрывающиеся/складывающиеся секции в UITableView. Сегодня мы реализуем эту задачу, используя SwiftUI. В качестве небольшого twist'a добавим анимированный треугольник в заголовке секции и сделаем ячейки также раскрывающимися.
Разработка проходила на XCode 11.2 под macOS Catalina 10.15.1
Начинаем проект
Запускаем XCode, File — New Project — Single View App. В диалоговом окне указываем язык разработки Swift, UI будем формировать, используя SwiftUI.

Данные
В качестве демонстрационных данных будем использовать несколько забавных крылатых выражений на латинском языке с их переводом на русский.
Добавляем в проект новый Swift-файл, называем его Data.swift и пишем там следующее:
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 — это модель отдельного выражения, в дальнейшем это станет содержимым каждой отдельной ячейки. В ней мы храним оригинальный текст выражения, его перевод и признак «развернутости» ячейки (по умолчанию она «свёрнута»)
SectionDataModel — это модель каждой отдельной секции, здесь мы храним «букву» секции, массив цитат, начинающихся с этой буквы и также признак «развернутости» секции (по умолчанию также «свёрнута»)
В дальнейшем всё это мы будем отображать в List view, который требует, чтобы данные для него отвечали протоколу Identifiable. Для этого мы определяем свойство id, которое должно быть уникальным для каждого элемента в List.
Далее, в этом же файле Data.swift, формируем наши данные:
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: "Женщину красит молчанье.")])]
Займёмся интерфейсом
Сейчас мы определим, как у нас будет выглядеть заголовок секции и каждая ячейка.
Выберите в меню File — New — File — SwiftUI View. Назовите файл HeaderView.swift и замените его содержимое следующим:
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]) } }

Теперь опять File — New — File — SwiftUI View. Назовите файл QuoteView.swift и замените его содержимое следующим:
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) }.transition(.move(edge: .top)).animation(.default) } } } } struct QuoteView_Previews: PreviewProvider { static var previews: some View { QuoteView(quote: latinities[0].quotes[0]) } }

Теперь откроем файл ContentView.swift и изменим структуру ContentView следующим образом:
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()) } }
Поздравляю, вы только что заполнили List актуальными данными! Для каждого элемента массива latinities мы создаём секцию с заголовком на основе HeaderView и с пустым футером. Если секция «раскрыта», то для каждого выражения в массиве quotes мы формируем ячейку на основе QuoteView. У нас в данных все секции и все ячейки «свёрнуты», поэтому, если вы сделаете Canvas видимым, то вы увидите только заголовки секций:

Как вы понимаете, сейчас приложение совершенно «мёртвое» и ещё далеко от нашей конечной цели. Но скоро мы это исправим!
Слегка модифицируем заголовок секции
Вернёмся к файлу HeaderView.swift. Внутри структуры HeaderView, сразу за body добавьте это:
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 } }
Эта структура возвращает равносторонний треугольник. Теперь добавим наш треугольник в заголовок. Внутри HStack, перед первым Spacer добавьте это:
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))

Модифицируем данные
Вернёмся к нашим данным. Откройте Data.swift и ОБЕРНИТЕ наш массив latinities в новый класс UserData, вот так:
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: "Женщину красит молчанье.")])] }
Не забудьте также пометить latinities как @Published.
Что мы сделали?
ObservableObject — это специальный объект для наших данных, которые можно «привязать» к некоторым View. SwiftUI «следит» за всеми изменениями, которые могут влиять на View и, после того, как данные изменились, изменяет и View.
После «оборачивания» latinities у нас возникло много ошибок, исправим их. Откройте HeaderView.swift и исправьте структуру HeaderView_Previews следующим образом:
struct HeaderView_Previews: PreviewProvider { static var previews: some View { HeaderView(section: UserData().latinities[0]) } }
Теперь внесите похожие изменения в QuoteView.swift:
struct QuoteView_Previews: PreviewProvider { static var previews: some View { QuoteView(quote: UserData().latinities[0].quotes[0]) } }
Откройте файл ContentView.swift и добавьте это перед объявлением body
@ObservedObject var userData = UserData()
Оживляем пейзаж
Вернёмся к файлу ContentView.swift. Внутри структуры ContenView, сразу за определением userData, добавьте две функции:
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})! }
Добавим модификаторы onTapGesture к формируемым нами заголовку секции и ячейке. Окончательный вид содержимого body:
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()) }
Функции sectionIndex и quoteUndex возвращают нам индекс передаваемых им секции и выражения. Получив эти индексы, мы меняем в нашем массиве latinities значения свойств expanded, что приводит к сворачиванию/разворачиванию секции или выражения.

Заключение
Готовый проект можно скачать здесь.
Несколько полезных ссылок:
- Apple SwiftUI Essentials — Handling User Input
- SwiftUI by Example
- @ObservedObject, @State, @EnvironmentObject
Надеюсь, что публикация будет полезной для вас!
