Статические Generic таблицы

в 17:30, , рубрики: iOS, swift, xcode, разработка под iOS
image

Всем нам часто приходится сталкиваться со статическими таблицами, они могут являться настройками нашего приложения, экранами авторизации, экранами «о нас» и многими другими. Но часто начинающие разработчики не применяют никакие паттерны разработки подобных таблиц и пишут все в одном классе немасштабируемую, негибкую систему.

О том, как я решаю данную проблему — под катом.

О чем речь?

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

image

Проблема

Для начала стоит определить проблему: почему мы не можем просто создать ViewController, который будет являться UITableViewDelegate и UITableViewDatasource и просто описать все нужные ячейки? Как минимум — тут возникают 5 проблем с нашей таблицей:

  1. Трудно масштабируемая
  2. Зависит от индексов
  3. Не гибкая
  4. Отсутвие переиспользования
  5. Требует много кода для инициализации

Решение

Метод решения проблемы основан на следующем фундаменте:

  1. Вынос отвественности конфигурации таблицы в отдельный класс (Constructor)
  2. Своя обертка над UITableViewDelegate и UITableViewDataSource
  3. Подключение ячеек к кастомным протоколам для переиспользования
  4. Создание своих моделей данных для каждой таблицы

Сначала я хочу показать, как это используется на практике — затем покажу как это все реализовано под капотом.

Реализация

Задача — создать таблицу с двумя текстовыми ячейками и между ними одна пустая.

Первым делом я создал обычный TextTableViewCell с UILabel.
Далее, к каждому UIViewController со статической таблицей нужен свой Constructor, давайте его создадим:

class ViewControllerConstructor: StaticConstructorContainer {
	typealias ModelType = <#type#>
}

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

Я для этого использую enum, так как это больше подходит для наших задач и тут начинается самое интересное. Мы будем наполнять контентом нашу таблицу с помощью протоколов, таких как: Titled, Subtitled, Colored, Fonted и так далее. Как вы можете догадаться — эти протоколы отвечают за отображение текста. Допустим, протокол Titled требует title: String?, и если наша ячейка поддерживает отображения title, он ее заполнит. Давайте посмотрим как это выглядит:

protocol Fonted {
	var font: UIFont? { get }
}

protocol FontedConfigurable {
	func configure(by model: Fonted)
}

protocol Titled {
	var title: String? { get }
}

protocol TitledConfigurable {
	func configure(by model: Titled)
}

protocol Subtitled {
	var subtitle: String? { get }
}

protocol SubtitledConfigurable {
	func configure(by model: Subtitled)
}

protocol Imaged {
	var image: UIImage? { get }
}

protocol ImagedConfigurable {
	func configure(by model: Imaged)
}

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

Наша ячейка (с текстом) поддерживает по сути следующие вещи: Шрифт текста, сам текст, цвет текста, цвет background'a ячейки и вообще любые вещи, приходящие вам на ум.

Нам понадобится пока что только title. Поэтому мы наследуем нашу модель от Titled. Внутри модели в case мы указываем какие типы ячеек у нас будут.

enum CellModel: Titled {
	case firstText
	case emptyMiddle
	case secondText
	
	var title: String? {
		switch self {
		case .firstText: return "Я - первый"
		case .secondText: return "Я - второй"
		default: return nil
		}
	}
}

Так как в средней (пустой ячейке) никакого label нет, то можно вернуть nil.
C ячейкой закончили и можно ее вставить в наш конструктор.

class ViewControllerConstructor: StaticConstructorContainer {
	typealias ModelType = CellModel
	
	var models: [CellModel] // Здесь мы должны выставить порядок и количество ячеек, отображаемых в коде
	
	func cellType(for model: CellModel) -> Self.StaticTableViewCellClass.Type {
		// здесь мы должны вернуть тип ячейки, которая принадлежит модели
	}
	
	func configure(cell: UITableViewCell, by model: CellModel) {
		 // Здесь мы можем конфигурировать ячейку вручную, если это необходимо, но можно оставить это пустым
	}
	
	func itemSelected(item: CellModel) {
		// аналог didSelect, не завязанный на индексах
	}
}

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

Ах да, чуть не забыл. Нужно наследовать нашу ячейку от протокола TitledConfigurable, чтобы она могла вставить в себя title. Ячейки поддерживают и динамичную высоту тоже.

extension TextTableViewCell: TitledConfigurable {
	func configure(by model: Titled) {
		label.text = model.title
	}
}

Как выглядит заполненный конструктор:

class ViewControllerConstructor: StaticConstructorContainer {
	typealias ModelType = CellModel
	
	var models: [CellModel] = [.firstText, .emptyMiddle, .secondText]
	
	func cellType(for model: CellModel) -> StaticTableViewCellClass.Type {
		switch model {
		case .emptyMiddle: return EmptyTableViewCell.self
		case .firstText, .secondText: return TextTableViewCell.self
		}
	}
	
	func configure(cell: UITableViewCell, by model: CellModel) {
		cell.selectionStyle = .none
	}
	
	func itemSelected(item: CellModel) {
		switch item {
		case .emptyMiddle: print("Нажата средняя ячейка")
		default: print("Нажата другая ячейка...")
		}
	}
}

Выглядит довольно компактным, не так ли?

Собственно, последнее что нам осталось сделать, это подключить это все во ViewController'e:

class ViewController: UIViewController {

	private let tableView: UITableView = {
		let tableView = UITableView()
		return tableView
	}()
	
	private let constructor = ViewControllerConstructor()
	private lazy var delegateDataSource = constructor.delegateDataSource()
	
	override func viewDidLoad() {
		super.viewDidLoad()
		constructor.setup(at: tableView, dataSource: delegateDataSource)
	}
}

Все готово, мы должны вынести delegateDataSource как отдельный проперти в наш класс, чтобы weak ссылка не разорвалась внутри какой-то функции.

Можем запускать и тестировать:

image

Как видите, все работает.

Теперь давайте подведем итоги и поймем чего мы добились:

  1. Если мы создадим новую ячейку и захотим подменить текущую на нее, то это мы делаем путем изменения одной переменной. У нас очень гибкая система таблицы
  2. Мы переиспользуем все ячейки. Чем больше ячеек вы подвязываете на эту таблицу, тем легче и проще с этим работать. Отлично подходит для больших проектов.
  3. Мы снизили количество кода для создания таблицы. И нам придётся писать его еще меньше, когда у нас будет много протоколов и статических ячеек в проекте.
  4. Мы вынесли построение статических таблиц из UIViewController в Constructor
  5. Мы перестали зависеть от индексов, мы можем спокойно менять местами ячейки в массиве и логика при этом не поломается.

Код на тестовый проект в конце статьи.

Как это работает изнутри?

Как работают протоколы мы уже обсудили. Теперь надо понять как работает весь конструктор и его сопуствующие классы.

Начнем с самого конструктора:

protocol StaticConstructorContainer {
	associatedtype ModelType
	var models: [ModelType] { get }
	func cellType(for model: ModelType) -> StaticTableViewCellClass.Type
	func configure(cell: UITableViewCell, by model: ModelType)
	func itemSelected(item: ModelType)
}

Это обычный протокол, который требует уже знакомые нам функции.

Более интересен его extension:

extension StaticConstructorContainer {
	typealias StaticTableViewCellClass = StaticCell & NibLoadable
	func delegateDataSource() -> StaticDataSourceDelegate<Self> {
		return StaticDataSourceDelegate<Self>.init(container: self)
	}
	
	func setup<T: StaticConstructorContainer>(at tableView: UITableView, dataSource: StaticDataSourceDelegate<T>) {
		models.forEach { (model) in
			let type = cellType(for: model)
			tableView.register(type.nib, forCellReuseIdentifier: type.name)
		}
		
		tableView.delegate = dataSource
		tableView.dataSource = dataSource
		dataSource.tableView = tableView
	}
}

Функция setup, которую мы вызывали в нашем ViewController регистрирует все ячейки для нас и делегирует dataSource и delegate.

А delegateDataSource() создает для нас обертку UITableViewDataSource и UITableViewDelegate. Давайте рассмотрим его:


class StaticDataSourceDelegate<Container: StaticConstructorContainer>: NSObject, UITableViewDelegate, UITableViewDataSource {
	private let container: Container
	weak var tableView: UITableView?
	
	init(container: Container) {
		self.container = container
	}
	
	func reload() {
		tableView?.reloadData()
	}
	
	func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
		let type = container.cellType(for: container.models[indexPath.row])
		return type.estimatedHeight ?? type.height
	}
	
	func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
		let type = container.cellType(for: container.models[indexPath.row])
		return type.height
	}
	
	func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
		return container.models.count
	}
	
	func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
		let model = container.models[indexPath.row]
		let type = container.cellType(for: model)
		let cell = tableView.dequeueReusableCell(withIdentifier: type.name, for: indexPath)
		
		if let typedCell = cell as? TitledConfigurable, let titled = model as? Titled {
			typedCell.configure(by: titled)
		}
		
		if let typedCell = cell as? SubtitledConfigurable, let subtitle = model as? Subtitled {
			typedCell.configure(by: subtitle)
		}
		
		if let typedCell = cell as? ImagedConfigurable, let imaged = model as? Imaged {
			typedCell.configure(by: imaged)
		}
		
		container.configure(cell: cell, by: model)
		return cell
	}
	
	func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
		let model = container.models[indexPath.row]
		container.itemSelected(item: model)
	}
}

Думаю, к функциям heightForRowAt, numberOfRowsInSection, didSelectRowAt вопросов нет, они всего лишь реализуют понятный функционал. Самый интересный здесь метод — cellForRowAt.

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

На этом логика заканчивается. Я не затрагивал сторонние утилитарные классы в этой системе, полностью с кодом вы можете ознакомиться по ссылке.

Спасибо за внимание!

Автор: hadevs

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js