iOS: Как да се изгради изглед на таблица с разглобяеми секции

Част 2. Продължете приемането на протоколи и MVVM с изгледи на таблици

Това е втората част от моята поредица от уроци в табличен изглед с множество типове клетки.

След като прочетох множеството отговори и съвети за първата част, реших да добавя няколко основни актуализации.
UITableViewController се променя на UIViewController с TableView като подвид.
Сега ViewModel отговаря на протокола TableViewDataSource. NumberOfRowsInSection, cellForRowAt и numberOfSections са част от ViewModel. Това поддържа ViewController и ViewModel разделени.
Моля, намерете окончателния актуализиран проект тук.
Благодаря на всички за приноса!

В първата част създадохме следния изглед на таблица:

В тази статия ще направим някои промени, за да може секцията да се сгъва:

Изглед на маса със сгъваеми секции

За да добавим сгъваемото поведение, трябва да знаем две неща за секцията:

  • секцията е сгъваема или не
  • текущото състояние на секцията: свит / разгънат

Можем да добавим и двете свойства към съществуващ протокол ProfileViewModelItem:

протокол ProfileViewModelItem {
   тип вар: ProfileViewModelItemType {get}
   var sectionTitle: String {get}
   var rowCount: Int {get}
   var isCollapsible: Bool {get}
   var isCollapsed: Bool {get set}
}

Обърнете внимание, че свойството isCollapsible има само getter, защото няма да е необходимо да го модифицираме.

След това добавяме по подразбиране стойност еCollapsible към разширението на протокола. Задаваме стойността по подразбиране на true:

разширение ProfileViewModelItem {
   var rowCount: Int {
      връщане 1
   }
   
   var isCollapsible: Bool {
      връщане вярно
   }
}

След като промените протокола, ще видите множество грешки при компилиране във всеки от ProfileViewModelItems. Поправете го, като добавите това свойство към всеки ViewModelItem:

клас ProfileViewModelNamePictureItem: ProfileViewModelItem {
   var isCollapsed = true
}

Това са всички промени, които трябва да внесем в ViewModel. Останалата част е да промените изгледа, така че да може да се справи с действията за свиване / разгъване.

Няма начин за добавяне на сгъваемо поведение към tableView, така че ще имитираме по много прост начин: когато секцията е свита, ще зададем броя на нейните редове на нула. Когато се разшири, ще използваме по подразбиране rowCount за този раздел. За TableView можем да предоставим тази информация по метода numberOfRowsInSection:

замени функцията tableView (_ tableView: UITableView, numberOfRowsInSection раздел: Int) -> Int {
   нека item = viewModel.items [раздел]
   ако item.isCollapsible && item.isCollapsed {
      върнете 0
   }
   връщане item.rowCount
}

Сега трябва да създадем персонализиран изглед на заглавката, който ще съдържа заглавието и етикета със стрелка. Създайте подклас на UITableViewHeaderFooterView и задайте оформлението в xib или код:

клас HeaderView: UITableViewHeaderFooterView {
   @IBOutlet слаб вар заглавиеLabel: UILabel?
   @IBOutlet слаб var arrowLabel: UILabel?
   вар раздел: Int = 0
}

Ще използваме променливата на секцията, за да съхраним текущия индекс на секцията, който ще ни трябва по-късно.

Когато потребителят докосне секцията, изгледът със стрелка трябва да се завърти надолу. Това можем да постигнем с разширение UIView:

разширение UIView {
   func rotate (_ toValue: CGFloat, продължителност: CFTimeInterval = 0.2) {
      нека анимация = CABasicAnimation (keyPath: „преобразуване.ротация“)
      animation.toValue = toValue
      animation.duration = продължителност
      animation.isRemovedOnCompletion = false
      animation.fillMode = kCAFillModeForwards
      self.layer.add (анимация, forKey: nil)
   }
}
Това е само един от възможните начини за анимиране на въртенето на изгледа

Използвайки този метод на разширение, добавете следния код в класа HeaderView:

func setCollapsed (свит: Bool) {
   arrowLabel? .rotate (свит? 0.0: .pi)
}

Когато наречем този метод за свито състояние, той ще завърти стрелката в първоначалното положение, за разширено състояние ще завърти стрелката към pi radians.

След това трябва да настроим текущото заглавие на секцията. Както направихме за клетките в предишния урок, създайте променливата на елемента и използвайте наблюдателя didSet, за да зададете заглавието и първоначалната позиция на етикета със стрелка:

вар елемент: ProfileViewModelItem? {
   didSet {
      пази нека елемент = елемент друго {
         връщане
      }
     titleLabel? .text = item.sectionTitle
     setCollapsed (свит: item.isCollapsed)
   }
}

Последните въпроси са: как да разпознаете докосването на потребителя върху заглавката и как да уведомите TableView?

За да открием потребителско взаимодействие, можем да зададем TapGestureRecognizer в заглавката си:

отменя func awakeFromNib () {
   super.awakeFromNib ()
   addGestureRecognizer (UITapGestureRecognizer (цел: самостоятелно, действие: #selector (didTapHeader)))
}
@objc private func didTapHeader () {
}

За да уведомим TableView, можем да използваме всеки от начините, описани тук. В този случай ще използвам делегацията. Създайте протокол HeaderViewDelegate с един метод:

протокол HeaderViewDelegate: клас {
   func toggleSection (заглавие: HeaderView, раздел: Int)
}

Добавете свойство на делегат в HeaderView:

слаб вар делегат: HeaderViewDelegate?

И накрая, извикайте този метод на делегат от селектора на tapHeader:

@objc private func tapHeader (gestureRecognizer: UITapGestureRecognizer) {
   делегат? .toggleSection (заглавие: самостоятелно, раздел: раздел)
}

HeaderView вече е готов за употреба. Нека го свържем с нашия ViewController.

Отворете ViewModel и го приведете в съответствие с TableViewDelegate:

разширение ProfileViewModel: UITableViewDelegate {
}

След това премахнете метода titleForHeaderInSection. Тъй като използваме персонализирана заглавка, ще зададем заглавие по друг начин:

func tableView (_ tableView: UITableView, viewForHeaderInSection раздел: Int) -> UIView? {
ако нека headerView = tableView.dequeueReusableHeaderFooterView (withIdentifier: HeaderView.identifier) ​​като? HeaderView {
      headerView.item = viewModel.items [раздел]
      headerView.section = раздел
      headerView.delegate = себе // не забравяйте този ред !!!
      връщане headerView
   }
   върнете UIView ()
}
За да работи dequeueReusableHeaderFooterView, не забравяйте да регистрирате headerView за tableView

След като настроите headerView.delegate за самостоятелно, ще забележите грешката в компилатора, защото нашата ViewModel все още не е в съответствие с протокола. Поправете го, като добавите друго разширение:

разширение ProfileViewModel: HeaderViewDelegate {
   func toggleSection (заглавие: HeaderView, раздел: Int) {
      var item = елементи [раздел]
      ако item.isCollapsible {
         // Превключване на колапса
         нека свит =! item.isCollapsed
         item.isCollapsed = свит
         header.setCollapsed (свит: свит)
         // Регулирайте броя на редовете вътре в секцията
      }
   }
}

Трябва да зададем начин да презаредим раздела TableView, така че той ще актуализира потребителския интерфейс. В по-сложните ViewModels, които изискват да актуализират, добавят или премахват tableViewRows, ще има смисъл да се използва делегат с множество методи. В нашия проект се нуждаем само от един метод (ReloadSection), така че можем да ни върнем обаждането:

клас ProfileViewModel: NSObject {
   var items = [ProfileViewModelItem] ()
   // обратно извикване за презареждане на tableViewSections
   var reloadSections: ((_ раздел: Int) -> void)?
   .....
}

Извикайте този обратен разговор в toggleSection:

разширение ProfileViewModel: HeaderViewDelegate {
   func toggleSection (заглавие: HeaderView, раздел: Int) {
      var item = елементи [раздел]
      ако item.isCollapsible {
         // Превключване на колапса
         нека свит =! item.isCollapsed
         item.isCollapsed = свит
         header.setCollapsed (свит: свит)
         // Регулиране на броя на редовете вътре в секцията
         reloadSections? (раздел)
      }
   }
}

В ViewController използваме този обратен сигнал за презареждане на секциите tableView:

замени функцията viewDidLoad () {
   super.viewDidLoad ()
   viewModel.reloadSections = {[слабо себе си] (раздел: Int) в
      самостоятелно? .tableView? .beginUpdates ()
      самостоятелно? .tableView? .reloadSections ([раздел], с: .fade)
      самостоятелно? .tableView? .endUpdates ()
   }
 
   ...
}

Ако изградите и стартирате проекта, ще видите това хубаво анимационно поведение в колапс.

Можете да разгледате окончателния проект тук.

Има някои потенциални надстройки за тази функция:

  1. Опитайте се да измислите начина, по който да разрешите само един раздел да бъде разширен. Така че, когато потребителят докосне друг раздел, той първо ще свие разширения и след това ще разшири новия.
  2. Когато раздела се разширява, превъртете tableView, за да покажете последния ред в този раздел.

Моля, споделете мислите си в коментарите по-долу, за да можем да ги обсъдим.

Благодаря за четенето!