Доброго времени суток!

Недавно я начал переводить приложение, написанное по плохому MVC на VIPER. Это был мой первый опыт с VIPER-архитектурой и в силу того, что информации на просторах интернета на данный момент по этой архитектуре мало, я столкнулся с некоторыми проблемами. Используя самые общие знания и понятия по VIPER, я вывел на данный момент оптимальные для себя паттерны написания экранов, включающие в себя таблицы или коллекции.

Простые и сложные ячейки


Я разделяю ячейки на простые и сложные.

Простые ячейки — это такие ячейки, которым для выполнения своего предназначения достаточно отображать некоторые данные (текст, картинка) и отвечать простыми действиями на действия пользователя.

Сложные ячейки — такие ячейки, которым для выполнения своего предназначения необходимо дополнительно подгружать данные, которые имеют сложную бизнесс-логику внутри себя.

В данной статье речь пойдет о таблице с простыми ячейками.

Проблема


Проблема заключается в том, что ячейку надо как-то собирать, как-то слушать ее события и делать это в правильном месте.

Решение


Для начала скажу, что некоторые могут предложить делать ячейки как отдельный модуль, но это очень не тривиальное и не особо оправданное решение, когда мы говорим о простых ячейках.

Будем разбирать все на примере. Пусть у нас есть список работников, у которых есть имя, специализация, фотография, места работы, ему можно написать письмо или же позвонить. Этот список мы хотим показать в UITableView в виде ячеек со всей описанной выше информацией и соответствующими кнопками для соответствующих действий.
Каждый работник будет одной секцией в таблице, каждый блок информации, красиво укладывающий в строку будет ячейкой этой секции.

Итак, что мы имеем:

  • В секции есть ячейки, отображающие некоторую информацию
  • В секции есть две кнопки, события которых необходимо обрабатывать

Очевидно, обрабатывать события должен Presenter нашего основного модуля. Идея заключается в следующем:

Interactor модуля получает данные в обычном для него формате, передает их Presenter'у. Presenter в свою очередь должен из этих данных собрать данные понятные для View, в качестве таких данных я беру массив моделей секций, содержащие массив моделей строк. Модель секции имеет делегатом нашего Presenter'а, это нам понадобится для обработки событий. В свою очередь модель ячейки с кнопкой имеет блок обработки события кнопки, задающийся моделью секции, в которой она лежит. Таким образом, нажатие на кнопку в ячейке вызовет блок, в котором как к делегату секции выполнится обращение к Presenter'у, который в конце концов все и обработает.

image

Итак, ячейка есть элемент таблицы, которая есть 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'ы и соединить все аутлеты.

image

Теперь соберем секции в 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)
    }
}

А вот так выглядит результат (я немного навел красоты, о которой здесь не написал):

image

Итог


Мы получили очень гибкую реализацию поставленной цели: модели дают возможность очень быстро убрать или добавить нужную ячейку, не трогая при этом View и меняя только небольшие и кусочки кода.

Можно расширять модели чем угодно, чтобы не загрязнять ваш View. Например, если вам надо отключить выделение только для конкретных ячеек, вы можете добавить соответствующую проперти в модель и настраивать впоследствии ячейку в описанном выше методе.
Это моя текущая реализация, если кто-то готов предложить что-то более красивое, правильное и удобное — я только рад! В следующих статьях постараюсь рассказать о реализации сложных ячеек (когда сам найду что-то удобное).

Ссылка на репозиторий проекта

Комментарии (2)


  1. Adnako
    23.01.2018 23:09

    А почему user interaction обрабатывается presenter’ом?
    Получается, что Presenter знает про UIKit.


  1. Gallion
    24.01.2018 10:05

    Презентер ничего и не знает о представлении. Таким же образом вью контроллер, который по сути является вью, общается с презентером через ViewOutput. Здесь презентер также реализует протокол EmployeeSectionModelDelegate и просто может реагировать на события из вью, в данном случае ячейки таблицы, но сам презентер ничего о представлении не знает.