[Из песочницы] VIPER и UITableView/UICollectionView с простыми ячейками
Доброго времени суток!
Недавно я начал переводить приложение, написанное по плохому MVC на VIPER. Это был мой первый опыт с VIPER-архитектурой и в силу того, что информации на просторах интернета на данный момент по этой архитектуре мало, я столкнулся с некоторыми проблемами. Используя самые общие знания и понятия по VIPER, я вывел на данный момент оптимальные для себя паттерны написания экранов, включающие в себя таблицы или коллекции.
Простые и сложные ячейки
Я разделяю ячейки на простые и сложные.
Простые ячейки — это такие ячейки, которым для выполнения своего предназначения достаточно отображать некоторые данные (текст, картинка) и отвечать простыми действиями на действия пользователя.
Сложные ячейки — такие ячейки, которым для выполнения своего предназначения необходимо дополнительно подгружать данные, которые имеют сложную бизнесс-логику внутри себя.
В данной статье речь пойдет о таблице с простыми ячейками.
Проблема
Проблема заключается в том, что ячейку надо как-то собирать, как-то слушать ее события и делать это в правильном месте.
Решение
Для начала скажу, что некоторые могут предложить делать ячейки как отдельный модуль, но это очень не тривиальное и не особо оправданное решение, когда мы говорим о простых ячейках.
Будем разбирать все на примере. Пусть у нас есть список работников, у которых есть имя, специализация, фотография, места работы, ему можно написать письмо или же позвонить. Этот список мы хотим показать в UITableView в виде ячеек со всей описанной выше информацией и соответствующими кнопками для соответствующих действий.
Каждый работник будет одной секцией в таблице, каждый блок информации, красиво укладывающий в строку будет ячейкой этой секции.
Итак, что мы имеем:
- В секции есть ячейки, отображающие некоторую информацию
- В секции есть две кнопки, события которых необходимо обрабатывать
Очевидно, обрабатывать события должен Presenter нашего основного модуля. Идея заключается в следующем:
Interactor модуля получает данные в обычном для него формате, передает их Presenter’у. Presenter в свою очередь должен из этих данных собрать данные понятные для View, в качестве таких данных я беру массив моделей секций, содержащие массив моделей строк. Модель секции имеет делегатом нашего Presenter’а, это нам понадобится для обработки событий. В свою очередь модель ячейки с кнопкой имеет блок обработки события кнопки, задающийся моделью секции, в которой она лежит. Таким образом, нажатие на кнопку в ячейке вызовет блок, в котором как к делегату секции выполнится обращение к Presenter’у, который в конце концов все и обработает.
Итак, ячейка есть элемент таблицы, которая есть View нашего модуля. По моему мнению, нет ничего удивительного и неправильного в том, что ее события обрабатываются Presenter’ом того же модуля. Можно рассматривать модели ячеек и секций как вариант примитивнейшего Presenter’а нашей ячейки, которому ничего не надо подгружать и вся информация для работы ему дается извне. Тогда модуль ячейки это простейший модуль, состоящий только из View и Presenter. Реализация такого «модуля» будет немного не такой, как реализация нормального модуля: на то и я его так не называю.
Реализация будет построена на использовании полиморфизма через протоколы.
Начнем с протоколов, без которых все было бы не так красиво.
Протокол, который будут реализовывать все модели ячеек:
protocol CellIdentifiable {
var cellIdentifier: String { get }
var cellHeight: Float { get }
}
Протокол, который будут реализовывать все ячейки с моделями:
protocol ModelRepresentable {
var model: CellIdentifiable? { get set }
}
Протокол, который будут реализовывать все модели секций:
protocol SectionRowsRepresentable {
var rows: [CellIdentifiable] { get set }
}
Теперь создадим необходимые нам модели ячеек.
1. Так как все ячейки будут иметь автоматическую высоту, то сначала создадим базовый класс для всех моделей, где это укажем.
class EmployeeBaseCellModel: CellIdentifiable {
let automaticHeight: Float = -1.0
var cellIdentifier: String {
return ""
}
var cellHeight: Float {
return automaticHeight
}
}
2. Модель ячейки, отображающая фото, имя и специализацию работника.
class EmployeeBaseInfoCellModel: EmployeeBaseCellModel {
override var cellIdentifier: String {
return "EmployeeBaseInfoCell"
}
var name: String
var specialization: String
var imageURL: URL?
init(_ employee: Employee) {
name = employee.name
specialization = employee.specialization
imageURL = employee.imageURL
}
}
3. Модель ячейки, отображающая место работы работника.
class EmployeeWorkplaceCellModel: EmployeeBaseCellModel {
override var cellIdentifier: String {
return "EmployeeWorkplaceCell"
}
var workplace: String
init(_ workplace: String) {
self.workplace = workplace
}
}
4. Модель ячейки с кнопкой
class ButtonCellModel: EmployeeBaseCellModel {
typealias ActionHandler = () -> ()
override var cellIdentifier: String {
return "ButtonCell"
}
var action: ActionHandler?
var title: String
init(title: String, action: ActionHandler? = nil) {
self.title = title
self.action = action
}
}
С моделями ячеек закончили. Создадим классы ячеек.
1. Базовый класс
class EmployeeBaseCell: UITableViewCell, ModelRepresentable {
var model: CellIdentifiable? {
didSet {
updateViews()
}
}
func updateViews() {
}
}
Как видно по коду, настройка UI ячейки произойдет, как только ей отдадут ее модель.
2. Класс ячейки базовой информации работника.
class EmployeeBaseInfoCell: EmployeeBaseCell {
@IBOutlet weak var nameLabel: UILabel!
@IBOutlet weak var specializationLabel: UILabel!
@IBOutlet weak var photoImageView: UIImageView!
override func updateViews() {
guard let model = model as? EmployeeBaseInfoCellModel else {
return
}
nameLabel.text = model.name
specializationLabel.text = model.specialization
if let imagePath = model.imageURL?.path {
photoImageView.image = UIImage(contentsOfFile: imagePath)
}
}
}
3. Класс ячейки отображающей место работы
class EmployeeWorkplaceCell: EmployeeBaseCell {
@IBOutlet weak var workplaceLabel: UILabel!
override func updateViews() {
guard let model = model as? EmployeeWorkplaceCellModel else {
return
}
workplaceLabel.text = model.workplace
}
}
4. Класс ячейки с кнопкой
class ButtonCell: EmployeeBaseCell {
@IBOutlet weak var button: UIButton!
override func updateViews() {
guard let model = model as? ButtonCellModel else {
return
}
button.setTitle(model.title, for: .normal)
}
@IBAction func buttonAction(_ sender: UIButton) {
guard let model = model as? ButtonCellModel else {
return
}
model.action?()
}
}
Закончили с ячейками. Перейдем к модели секции.
protocol EmployeeSectionModelDelegate: class {
func didTapCall(withPhone phoneNumber: String)
func didTapText(withEmail email: String)
}
class EmployeeSectionModel: SectionRowsRepresentable {
var rows: [CellIdentifiable]
weak var delegate: EmployeeSectionModelDelegate?
init(_ employee: Employee) {
rows = [CellIdentifiable]()
rows.append(EmployeeBaseInfoCellModel(employee))
rows.append(contentsOf: employee.workplaces.map({ EmployeeWorkplaceCellModel($0) }))
let callButtonCellModel = ButtonCellModel(title: "Позвонить") { [weak self] in
self?.delegate?.didTapCall(withPhone: employee.phone)
}
let textButtonCellModel = ButtonCellModel(title: "Написать письмо") { [weak self] in
self?.delegate?.didTapText(withEmail: employee.email)
}
rows.append(contentsOf: [callButtonCellModel, textButtonCellModel])
}
}
Здесь и происходит связывания действий над ячейками с Presenter’ом.
Осталось самое простое — отобразить данные в таблице.
Для этого сначала создадим прототипы ячеек в нашей таблице и дадим им соотвествующие identifier’ы.
Результат будет выглядеть примерно так. Необходимо проставить всем ячейкам их классы и reuse identifier’ы и соединить все аутлеты.
Теперь соберем секции в Presenter’а на основе полученных данных от Interactor’а и отдадим массив секций View для отображения.
Так выглядит наш Presenter:
class EmployeeListPresenter: EmployeeListModuleInput, EmployeeListViewOutput, EmployeeListInteractorOutput {
weak var view: EmployeeListViewInput!
var interactor: EmployeeListInteractorInput!
var router: EmployeeListRouterInput!
func viewDidLoad() {
interactor.getEmployees()
}
func employeesDidReceive(_ employees: [Employee]) {
var sections = [EmployeeSectionModel]()
employees.forEach({
let section = EmployeeSectionModel($0)
section.delegate = self
sections.append(section)
})
view.updateForSections(sections)
}
}
extension EmployeeListPresenter: EmployeeSectionModelDelegate {
func didTapText(withEmail email: String) {
print("Will text to \(email)")
}
func didTapCall(withPhone phoneNumber: String) {
print("Will call to \(phoneNumber)")
}
}
И так прекрасно выглядит наш View:
class EmployeeListViewController: UITableViewController, EmployeeListViewInput {
var output: EmployeeListViewOutput!
var sections = [EmployeeSectionModel]()
override func viewDidLoad() {
super.viewDidLoad()
output.viewDidLoad()
}
func updateForSections(_ sections: [EmployeeSectionModel]) {
self.sections = sections
tableView.reloadData()
}
}
extension EmployeeListViewController {
override func numberOfSections(in tableView: UITableView) -> Int {
return sections.count
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return sections[section].rows.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let model = sections[indexPath.section].rows[indexPath.row]
let cell = tableView.dequeueReusableCell(withIdentifier: model.cellIdentifier, for: indexPath) as! EmployeeBaseCell
cell.model = model
return cell
}
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return CGFloat(sections[indexPath.section].rows[indexPath.row].cellHeight)
}
}
А вот так выглядит результат (я немного навел красоты, о которой здесь не написал):
Итог
Мы получили очень гибкую реализацию поставленной цели: модели дают возможность очень быстро убрать или добавить нужную ячейку, не трогая при этом View и меняя только небольшие и кусочки кода.
Можно расширять модели чем угодно, чтобы не загрязнять ваш View. Например, если вам надо отключить выделение только для конкретных ячеек, вы можете добавить соответствующую проперти в модель и настраивать впоследствии ячейку в описанном выше методе.
Это моя текущая реализация, если кто-то готов предложить что-то более красивое, правильное и удобное — я только рад! В следующих статьях постараюсь рассказать о реализации сложных ячеек (когда сам найду что-то удобное).