Продолжаем избавляться от шаблонного кода: переиспользуемый делегат табличных экранов
Мы продолжаем сражаться с шаблонным кодом в табличных экранах iOS-приложений.
В предыдущих статьях мы описали мотивы и подход, используемый для решения проблемы дублирования кода из контроллера в контроллер. Также мы показали детальную реализацию и возможности использования источника и провайдера данных для таблиц, которые позволяют ускорять разработку табличных экранов за счет переиспользования реализации протокола `UITableViewDataSource` в соответствии с принципами SOLID.
Однако, помимо источника данных для таблиц, почти в каждом экране надо реализовывать делегат (табличный делегат или делегат коллекции). Его методы вызываются при взаимодействии пользователя с таблицей или коллекцией, например при нажатии на ячейку. А значит, он тоже, как и источник данных, является причиной насыщения проектов шаблонным кодом.
В этой статье рассмотрим, как избавиться и от такого кода.
Цикл статей:
Карта соответствия
Обзервер
Коллекции
...
Реализация абстрактного делегата
Рассмотрим возможность избавления от шаблонного кода табличного делегата, взяв за аналогию переиспользуемый источник данных. В качестве примера будем использовать создание переиспользуемого табличного (UITableViewDelegate) делегата, поскольку он используется разработчиками чаще, чем UICollectionViewDelegate, и проще в понимании.
Наши существующие реализации табличных экранов в 85% случаях реализуются только методом обработки нажатия пользователя на ячейку. Соответственно, необходимым и достаточным функционалом будущего переиспользуемого делегата будет являться обработка нажатия на ячейку. Напомним, что наша цель — не пытаться создать универсальный делегат, покрывающий все возможные случаи, а создать делегат с функционалом, который используется наиболее часто. Потратив 20% усилий, получить 80% результата.
В прошлых статьях мы уже создали два табличных представления. В этот раз предварительной подготовки не потребуется.
В чём состоит задача
Составим небольшой список требований:
Нажатия должны обрабатываться только в определенных ячейках.
Контроллер представления должен содержать только уникальный код, без шаблонного поиска нажатой ячейки и разграничения действий для ячеекю.
Для первого требования используем практику, уже применённую при создании источника данных: будем регистрировать тип ячейки, нажатие на которую хотим обрабатывать.
Итак, при реализации метода нажатия на ячейку нужно передать контроллеру представления, какая именно ячейка нажата. Воспользуемся механизмом ResponderChain + Selector — техникой, детально разобранной в этой статье.
В результате должно получиться следующее: фабрика экрана создаёт контроллер представления с таблицей и наш делегат. В делегате регистрируются типы ячеек, нажатия на которые хотим обрабатывать. Делегат передаёт контроллеру представления, где последний присваивает его таблице. При нажатии на ячейку проверяется, зарегистрирован ли селектор для текущей ячейки, и отправляется сообщение с зарегистрированным селектором от неё.
Создадим класс будущего переиспользуемого делегата:
public final class TableViewDelegate: NSObject {
// MARK: - Property
private let dataProvider: ViewModelDataProvider
private let itemViewModelClassToSelectorMap: [String: Selector]
// MARK: - Initialization
public init(dataProvider: ViewModelDataProvider, map: [String: Selector]) {
self.dataProvider = dataProvider
self.itemViewModelClassToSelectorMap = map
}
}
Определим два свойства: уже известный из предыдущих статей провайдер данных и карту соответствия типа вьюмодели ячейки на селектор. Эта карта позволит нам выбирать соответствующий action-селектор, чтобы выполнить его без лишних проверок и приведений типов.
Подпишем созданный класс под UITableViewDelegate и реализуем наш популярный метод.
При нажатии на ячейку попробуем получить для неё вьюмодель из провайдера данных.
extension TableViewDelegate: UITableViewDelegate {
public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let viewModel = dataProvider.itemForRow(atIndexPath: indexPath),
let selector = itemViewModelClassToSelectorMap["\(type(of: viewModel))"]
else { return }
let cell = tableView.cellForRow(at: indexPath)
let target = tableView.target(forAction: selector, withSender: cell) as? NSObject
target?.perform(selector, with: cell)
}
}
Если она получена, можем приступать к поиску селектора в карте.
Если селектор нашёлся, его необходимо выполнить. Для этого найдём ячейку для текущего indexPath, с которой взаимодействовал пользователь. Эта ячейка понадобится, чтобы передать её параметром в вызываемый селектор по правилу определения action-селекторов. Параметр необязателен, но сильно поможет вызванному коду определить, откуда он был вызван, чтобы, например, получить данные из вызывающего кода.
Далее в цепочке ответчиков необходимо найти объект, который выполнит селектор и передаст в него ячейку. Для этого вызовем метод таблицы target(forAction:withSender:), который, собственно, и творит всю магию по поиску селектора в цепочке ответчиков. Поскольку метод возвращает опциональный Any, преобразуем его в опциональный NSObject: только потомки NSObject могут пользоваться динамическим механизмом Responder Chain. К счастью, все классы слоя представления — слоя пользовательского интерфейса — являются потомками NSObject.
Теперь можем выполнить последний вызов с селектором.
Обратим внимание, что вызов опциональный и не упадёт, если на какой-то из предыдущих стадий что-то пойдёт не по плану. В таких случаях селектор просто не вызовется. Как уже упоминалось в предыдущих статьях, мы предпочитаем писать безопасный код, который не падает в нестандартных ситуациях.
Применение переиспользуемого табличного делегата
Откроем учебный пример, который использовали ранее. Его необходимо доработать. В протокол базового контроллера представления добавим свойство для хранения делегата, по аналогии с источником данных. В базовом контроллере представления реализуем свойства делегата, тоже по аналогии с источником данных.
public var delegate: UITableViewDelegate? {
didSet {
guard isViewLoaded else {
return
}
tableView.delegate = delegate
}
}
В методе жизненного цикла viewDidLoad также добавим установку делегата в таблицу.
override public func viewDidLoad() {
super.viewDidLoad()
tableView.delegate = delegate
tableView.dataSource = dataSource
}
На этом разработка универсального делегата завершена. Да, вот так просто. Можно встраивать делегат на уже существующие экраны. У нас есть фабрика для создания источников данных, добавим в неё методы для создания делегатов: один для плоского списка, другой — для секционного.
extension FirstDataSourceFactory: FirstDelegateFactoryProtocol {
func makePlainListDelegate() -> UITableViewDelegate {
let map = makeMap()
let delegate = TableViewDelegate(dataProvider: plainDataProvider, map: map)
return delegate
}
func makeSectionDevidedDelegate() -> UITableViewDelegate {
let map = makeMap()
let delegate = TableViewDelegate(dataProvider: sectionDataProvider, map: map)
return delegate
}
func makeMap() -> [String: Selector] {
let firstSelector = #selector(UIViewController.showAlert(sender:))
let secondSelector = #selector(UITableViewController.showActionSheet(sender:))
let thirdSelector = #selector(UITabBarController.selectSecondTab(sender:))
let map = [
"TextViewModel": firstSelector,
"ValueSettingViewModel": secondSelector,
"SwitchedSettingViewModel" : thirdSelector,
]
return map
}
}
В методе определён словарь соотношения вьюмодели ячейки и селектора. Он вызывается при нажатии на соответствующую ячейку. Соответствий задано три — для каждого типа используемой ячейки.
Помимо словаря, в делегат необходимо передать тот же провайдер данных, который используется в источнике данных. Напомним, провайдер данных, собственно, и предоставляет модели ячеек. Его использует и источник данных, и делегат. Теперь мы переиспользуем работу с данными в двух классах.
Селекторы определяем как действия в расширениях контроллеров.
extension UITableViewController {
@IBAction func showActionSheet(sender: AnyObject) {
guard let cell = sender as? DetailedTextTableViewCell else { return }
let alert = UIAlertController(
title: "Нажата ячейка",
message: cell.viewModel?.text,
preferredStyle: .actionSheet
)
alert.addAction(.init(title: "Ok", style: .default, handler: nil))
present(alert, animated: true, completion: nil)
}
}
extension UIViewController {
@IBAction func showAlert(sender: AnyObject) {
guard let cell = sender as? TextTableViewCell else { return }
let alert = UIAlertController(
title: "Нажата ячейка",
message: cell.viewModel?.text,
preferredStyle: .alert
)
}
}
extension UITabBarController {
@IBAction func selectSecondTab(sender: Any) {
selectedIndex = 1
}
}
Внимание: мы можем определить действия в UIViewController, UITableViewController и даже в UITabBarController. Нам абсолютно не нужен конкретный контроллер для определения действия. Механизм Responder Chain отлично работает с механизмом наследования. Благодаря ему мы можем при необходимости переиспользовать код во всех потомках контроллеров, а это уменьшает дублирование кода и необходимость заводить роутеры и таскать их из контроллера в контроллер.
Кроме того, заметьте: мы создали два почти одинаковых делегата для двух различных представлений (плоский и секционный) экрана и обошлись без дублирования действий в каждом из них. Каждый экран работает лишь с одной реализацией действия. Не пришлось создавать и новые классы-роутеры с кучей связей между ними. Нет даже класса табличных контроллеров, выполняющих действия пользователя. Это сильно упрощает работу с навигацией и деревом контроллеров в приложении iOS.
В примере мы задали показ Alert’a и ActionSheet’а при нажатии на первые два типа ячеек и переключение на вторую вкладку панели вкладок, если нажата ячейка третьего типа.
Обращаем внимание: все действия содержатся в отдельных файлах и в отдельной папке, что позволяет легко находить их в проекте.
Осталось лишь установить делегат в таблицу. Перейдём в контроллер FirstTableViewController и в методе prepare(for:sender:) установим делегат.
case "plainListDataSegue":
destinationTableViewController.delegate = factory.makePlainListDelegate()
destinationTableViewController.dataSource = factory.makePlainListDataSource()
case "sectionDevidedDataSegue":
destinationTableViewController.delegate = factory.makeSectionDevidedDelegate()
destinationTableViewController.dataSource = factory.makeSectionDevidedDataSource()
Вот что получилось:
Так выглядит результат, если пользователи выделят ячейки первого и второго типа. Если нажата ячейка третьего типа, обе таблицы перейдут на вторую вкладку панели вкладок.
Графическое представление
Отобразим полученные компоненты и связи на диаграмме классов. Можно отметить сходство с аналогичной схемой для источника данных. Отличие в том, что здесь у нас делегат. А ещё добавлены селекторы, которые определены в контроллере представления и известны делегату. Диаграмма классов с переиспользуемым табличным делегатом выглядит так:
На следующем рисунке дополним диаграмму классов селекторами, которые хранятся внутри универсального делегата. Розовые стрелки показывают ссылки делегата на селекторы функций внутри любых компонентов слоя представления (слой пользовательского интерфейса). Не следует путать розовые ссылки с сильными и слабыми ссылками на диаграмме классов.
Недостатки
Из недостатков этого подхода (как и у переиспользуемого источника данных) стоит отметить невозможность использования нескольких различных селекторов для одной и той же модели представления в рамках одной таблицы.
Избавиться от этого недостатка можно аналогично: заменить текущую реализацию соответствия класса вьюмодели и селектора на более гибкую реализацию карты соответствий, о которой мы подробно поговорим в одной из будущих статей.
Заключение
Теперь у нас есть переиспользуемый делегат, в котором заключён весь шаблонный код, а непосредственная логика обработки действий осталась в местах использования.
Благодаря механизму Respnder Chain, код не пронизан ненужными связями между компонентами, нет необходимости заводить лишние классы-роутеры.
Это простое и функциональное решение повышает скорость разработки всех экранов. А если вспомнить, что экран в работе почти никогда не бывает единственным, то общее ускорение очень заметно.
Наша реализация табличного делегата ни в коем случае не претендует стать ключом от всех дверей. Это решение для общих случаев. В частных случаях вы всегда можете реализовать конкретный делегат. Переиспользуемый делегат покрывает оставшиеся 80% типичных случаев, для которых больше не нужен повторяющийся код.
Представленная реализация всё ещё не позволяет использовать несколько различных представлений и действий для одной и той же модели представления. Но в следующей статье мы рассмотрим, как модифицировать карты соответствия для этой цели.
Если хотите погрузиться в тему подробнее, добро пожаловать в учебный репозиторий.