Как стать автором
Обновить

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

Время на прочтение7 мин
Количество просмотров5.1K
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.

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

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

Спасибо за внимание!
Теги:
Хабы:
Всего голосов 11: ↑11 и ↓0+11
Комментарии10

Публикации

Истории

Работа

iOS разработчик
24 вакансии
Swift разработчик
31 вакансия

Ближайшие события