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

    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.

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

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

    Спасибо за внимание!
    Поделиться публикацией

    Похожие публикации

    Комментарии 10

      0
      Хороший альтернативный вариант работы с индексами, спасибо за статью
        0
        Отлично БРО!
          0
          if let typedCell = cell as? TitledConfigurable, let titled = model as? Titled

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


          Сомнительная настройка ячеек, опять же разрастется для уникальных ячеек.
          Как например будет настраиваться cell.accessoryType, что часто нужно?


          Все эти Generic работают только в теории.
          На практике выкатывают такие требования, что 10 раз пожалеешь, что их использовал.


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


          вот парочка примеров: (они не идеальные)
          https://github.com/bonyadmitr/XcodeProjects/blob/master/Settings/Settings/Controllers/AboutController.swift
          (как раз реализовывает экран выше "О нас", только отличается accessoryType)


          https://github.com/bonyadmitr/XcodeProjects/blob/master/Settings/Settings/Controllers/SettingsController.swift

            0
            Посмотрите эту библиотеку github.com/ra1028/Former, очень классно реализованы статические таблицы.
              0
              использовать ScrollView + StackView + Views = пара строк кода для всего этого кейса без генетиков и прочего оверхеда
                0
                1) Мало переиспользования
                2) Нету доступных для таблиц действий (перемещение ячеек, удаление, accessoryType, selection, и так далее.)
                3) Неудобно пользоваться через код
                  0
                  1) что подразумевается под «мало переиспользования»? протокол с тремя методами — add, remove, update по модели и/или индексу и полетели
                  2) есть, в пару действий можно реализовать и перемещение и удаление и все что угодно, но речь идет о статичной (!) таблице, по этому данные кейсы не актуальны и излишни.
                  Для динамичного контента как раз и придуманы таблицы / коллекции, где так же можно обойтись без неистового оверхеда и генерик оверинжиниринга
                  3) Неудобно пользоваться через код [X]
                  ???
                  WHAT ??7
                  0

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


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


                  и вторая проблема которую я вижу: съедание памяти. для больших таблиц не очень круто будет, особенно если оно будет висеть в navVC в несколько экземпляров (аля регистрация длинная). но опять же, это достаточно редкие случаи.


                  ну и мини проблема это минимум iOS 9, однако мало кого осталось, кто ее поддерживает ниже нее.


                  есть вопрос: а можно в StackView просто сделать разные высоты для ячеек без навешивания констрейнта высоты на них, в том же коде?

                    0
                    данный кейс как раз рассматривает использование статичного экрана в табличной верстке, с помощью stack view, можно очень быстро это набросать —
                    views.forEach { stackView.addArrangedSubview($0) }
                    достать из xib и сконфигурить не составит труда конечно же. Верстать большую таблицу с помощью stackview — точно такой же антипаттерн.
                  0
                  ну и потом, если использовать для таблицы кастомные ячейки (ячейка с картинкой, полем и свитч — это же насколько огромный получится «универсальный» датасорс).
                  И для каждого нового типа ячейки — снова создавать свой протокол и ковырять класс

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                  Самое читаемое