Нередко встречающаяся в разработке под 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, что приводит к сворачиванию/разворачиванию секции или выражения.
Заключение
Готовый проект можно скачать здесь.
Несколько полезных ссылок:
- Apple SwiftUI Essentials — Handling User Input
- SwiftUI by Example
- @ObservedObject, @State, @EnvironmentObject
Надеюсь, что публикация будет полезной для вас!
Gargo
как сюда добавить анимацию?
infund Автор
Модифицируем QuoteView (добавляем модификаторы transition и animation):
Gargo
спасибо
жалко только, что SwiftUI все еще сырой — в одном из уроков по анимации с сайта Apple их же пример работает не так, как описано — и это в Xcode 11.2