SwiftUI: делаем Expandable/Collapsible секции в List view +3




Нередко встречающаяся в разработке под 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)
                }
            }
        }
    }
}

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

@EnvironmentObject var userData : UserData

Также добавьте модификатор environmentObject(UserData()) в структуру, отвечающую за создание preview:

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

Наконец, откройте файл SceneDelegate.swift и замените строчку

window.rootViewController = UIHostingController(rootView: contentView)

на

window.rootViewController = UIHostingController(rootView: contentView.environmentObject(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, что приводит к сворачиванию/разворачиванию секции или выражения.



Заключение


Готовый проект можно скачать здесь.

Несколько полезных ссылок:



Надеюсь, что публикация будет полезной для вас!




К сожалению, не доступен сервер mySQL