Controller, но не Massive: реализуем список карточек для iOS 13 и не только с учетом практик чистого кода

Привет, Хабр! Меня зовут Евгений, я ведущий iOS-разработчик в Туту. В нашем продукте список карточек используется неоднократно, а в проекте можно встретить несколько вариантов реализации для разных версий SDK:

  • Через старую добрую UITableView.

  • С использованием UICollectionView и UICollectionViewFlowLayout под iOS 11+.

  • На связке UICollectionView и UICollectionViewCompositionalLayout для iOS 13+.

Не так давно я решил резюмировать накопленный опыт реализации списка карточек и поделиться наработками в виде исходников на Github (их вы найдете в конце статьи).

Первый заход: UITableView

Одна из первых реализаций списка карточек в приложении выглядит следующим образом:

  1. Берём UITableView.

  2. Размещаем в ячейке на заднем фоне RoundedView, для которой можно установить цвет фона, параметры закругления и тени. В ТableView(_:cellForRowAt:) задаём значение СornerRaduis и направление тени для первой и последней ячейки в секции.

  3. В низ ячейки добавляем сепаратор — вьюшку, залитую цветом с фиксированной высотой, которую будем скрывать в ТableView(_:cellForRowAt:), если элемент является последним.

В результате получаем составную карточку:

Как это представлено в коде?

// Один из вариантов реализации tableView(_:cellForRowAt:)
func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath
) -> UITableViewCell { let item = dataSource[indexPath.section][indexPath.row] let cell: TripCell = tableView.dequeueReusableCell(for: indexPath) var cellPosition: TripCell.CellPosition let totalRows = tableView.numberOfRows(inSection: indexPath.section) if totalRows == 1 { cellPosition = .single } else if indexPath.row == 0 { cellPosition = .top } else if indexPath.row == totalRows - 1 { cellPosition = .bottom } else { cellPosition = .middle } cell.render(item, cellPosition: cellPosition) return cell
}
// Устанавливаем параметры для корректного отображения // карточки внутри ячейки
class TripCell: UITableViewCell { // ... override func layoutSubviews() { super.layoutSubviews() switch cellPosition { case .top: addTopCorners(withRadius: 15) _addSeparator() case .middle: addTopCorners(withRadius: 0) addBottomCorners(withRadius: 0) _addSeparator() case .bottom: addBottomCorners(withRadius: 15) case .one: addTopAndBottomCorners(withRadius: 15) } }
}

Наша первая реализация имеет ряд ограничений, в частности:

  1. ViewController является делегатом и датасорсом для таблицы, что противоречит open–closed principle. Если нам потребуется изменить класс ячейки или добавить новый вид ячеек, придётся залезать внутрь контроллера.

Принцип декларирует, что программные сущности (классы, модули, функции и т.п.) должны быть открыты для расширения, но закрыты для изменения.

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

Don’t Repeat Youself — принцип заключается в том, что нужно избегать повторений одного и того же кода.

  1. Достаточно сложно подобрать правильные параметры тени для карточки, так как она не является цельной как у дизайнеров в Figma. Также нужно учитывать, что тень карточки находится внутри ячейки и необходимо предоставить место для её отображения, чтобы её не обрезало.

  2. Одним из преимуществ UITableView над UICollectionView является возможность удаления элемента списка свайпом. В текущем подходе у нас возникли проблемы с закруглениями для первой и последней ячейки.

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

Стоит отметить и преимущества данного решения: работает для всех версий SDK и просто в реализации.

Предпосылки эволюции

Предыдущее решение является простым, но недостаточно гибким и удобным.

Из первого ограничения следует желание вынести DataSource в отдельную сущность, тем самым разгрузив контроллер от лишней ответственности. Яркими примерами реализации подхода являются RxDataSource и UITableViewDiffableDataSource /UICollectionViewDiffableDataSource.

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

Решение для третьей проблемы вытекает из предыдущего пункта. Если карточка является цельной, то и настройки для неё задаются один раз. На сдачу мы можем более точно отрисовать тень карточки в соответствии с дизайном.

Что касается удаления свайпом, то им пришлось пожертвовать. Возможность удаления свайпом в UICollectionView открывается для iOS 14+.

В результате должно получиться следующее представление:

Реализуем список карточек на UICollectionView под iOS 11+

Второй этап эволюции — карточки для списка заказов. Решение по своей концепции похоже на представленное ниже для iOS 13, с небольшими отличиями в инструментах.

Вместо эплового DiffableDataSource используем RxDataSource, а UICollectionViewCompositionalLayout заменяет UICollectionViewFlowLayout в паре с декораторами, представленными UICollectionViewLayoutAttributes.

Реализуем список карточек на UICollectionView под iOS 13+

Довольно часто на практике, несмотря на использование различных архитектурных паттернов, ViewController всё равно выглядит достаточно массивным. Причиной этого является нарушение принципа single responsibility . Но если всё же строго следовать принципу, то для отображения списка карточек нам потребуется 20+ строчек кода:

final class PassengersViewController: UIViewController { private lazy var _passengersView = PassengersView() private var _dataSource: PassengersDataSource init(dataSourceFactory: DataSourceFactory) { self._dataSource = dataSourceFactory( _passengersView.collectionView ) super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func loadView() { view = _passengersView }
}

Из сниппета следует следующее:

  • Контроллер не занимается вёрсткой, вёрстка представлена рутовой вью.

  • Контроллер не датасорсит коллекцию, используем выделенную сущность PassengersDataSource.

  • Реализуем dependency inversion посредством инъекции датасорса для уменьшения связанности объектов.

Выделение вёрстки в отдельный класс UIView не является обязательным и используется нечасто. Но, несомненно, такой простой подход может значительно снизить нагрузку на контроллер. Всё, что нам нужно для отображения списка, представлено в PassengersView:

final class PassengersView: UIView { let collectionView: UICollectionView = { let collectionView = UICollectionView( frame: .zero, collectionViewLayout: .passengersLayout ) collectionView.backgroundColor = UIColor(named: .backgroundQuaternary) return collectionView }() override init(frame: CGRect) { super.init(frame: frame) _setupSubviews() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func _setupSubviews() { addSubview(collectionView, constraints: .allAnchors) }
}

Однако Single Responsibility приводит к появлению дополнительных сущностей:          PassengersDataSource и PassengersLayout:

PassengersLayout Как должен выглядеть список? Какого размера должны быть элементы списка? За это отвечает UICollectionViewLayout. Помимо этого c iOS 13+ UICollectionViewLayout позволяет добавлять декораторы как для элемента группы, так и для всей секции. В нашем случае декораторами являются: карточка, сепаратор и заголовок секции.  

Для создания PassengersLayout используется фабричный метод, позволяющий управлять созданием лайаута вдали от нашего вью контроллера: 

extension UICollectionViewLayout { static var passengersLayout: UICollectionViewLayout { let item = _makeItem() let group = _makeGroup(for: item) let section = _makeSection(for: group) let layout = _makeLayout(for: section) return layout }
}

Для отображения списка карточек нам необходимо: 

private func _makeItem() -> NSCollectionLayoutItem { let itemSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1.0), heightDimension: NSCollectionLayoutDimension.estimated(44) ) return NSCollectionLayoutItem( layoutSize: itemSize, // Для каждого элемента добавляем сепаратор supplementaryItems: [.separator] )
}
private func _makeSection( for group: NSCollectionLayoutGroup
) -> NSCollectionLayoutSection { let section = NSCollectionLayoutSection(group: group) section.interGroupSpacing = 1 // Добавляем карточку на задний фон секции section.decorationItems = [ .background( elementKind: PassengersSupplementaryViewKind.background ) ] // Добавляем заголовок для секции section.boundarySupplementaryItems = [.header] return section
}
private func _makeSection( for group: NSCollectionLayoutGroup
) -> NSCollectionLayoutSection { let section = NSCollectionLayoutSection(group: group) section.interGroupSpacing = 1 // Добавляем карточку на задний фон секции section.decorationItems = [ .background( elementKind: PassengersSupplementaryViewKind.background ) ] // Добавляем заголовок для секции section.boundarySupplementaryItems = [.header] return section
}

Используемые декораторы можно разделить на два типа:  

Коллекция позволяет максимально гибко работать с SupplementaryView. Всё, что мы сообщили про наши SupplementaryView-коллекции, это позицию вью, размер и идентификатор. Конкретную реализацию будет предоставлять наш PassengersDataSource. 

PassengersDataSource 

В iOS 13 на помощь к MassiveViewController приходит UITableViewDiffableDataSource и UICollectionViewDiffableDataSource. Помимо преследования SRP, Apple значительно упростил обновление элементов списка.  

Для создания DataSource нам потребуется коллекция и провайдер ячеек init(collectionView :cellProvider:). 

PassengersDataSource является алиасом для UICollectionViewDiffableDataSource с заданным типом модели для заголовка PassengerSectionHeaderView .ViewState и для ячейки PassengerCell.ViewState.  

typealias PassengersDataSource = UICollectionViewDiffableDataSource< PassengerSectionHeaderView.ViewState, PassengerCell.ViewState
>

Осталось сообщить датасорсу, как создавать ячейки, карточку и сепаратор.  

extension PassengersDataSource { convenience init( collectionView: UICollectionView ) { self.init( collectionView: collectionView, cellProvider: PassengerCellProviderImpl(), supplementaryViewProvider: PassengersViewProviderImpl.init ) }
}

Продолжая разделять ответственность, получаем ещё две cущности:  

PassengerCellProvider и PassengersViewProvider:

PassengerCellProvider 

Структуру провайдера ячеек нам задаёт сам DataSource в виде замыкания:

![ezgif.com-gif-maker-4.gif](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/f2e952ac-71b3-43a7-a783-491a958dcde3/ezgif.com-gif-maker-4.gif)

Чаще всего регистрация ячеек находится во вью контроллере, что в нашем случае приведёт к нарушению принципа подстановки Барбары Лисков. Если мы добавим новый тип ячеек, то получим знакомый краш в рантайме. Поэтому регистрацию ячеек необходимо проводить непосредственно перед созданием провайдера:

struct PassengerCellProviderImpl: PassengersCellProvider { func make( for collectionView: UICollectionView ) -> PassengersDataSource.CellProvider { // Регистрируем ячейку collectionView.register(class: PassengerCell.self) // Возвращаем провайдер return { collectionView, indexPath, viewState in let cell: PassengerCell = collectionView.dequeueReusableCell( for: indexPath ) cell.render(viewState) return cell } }
}

PassengersViewProvider 

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

struct HeaderPassengersViewProvider: ChildPassengersViewProvider { private let _dataSource: DataSource var kind: String { PassengersSupplementaryViewKind.header } // Для конфигурации заголовка нам нужен доступ к dataSource init(dataSource: DataSource) { _dataSource = dataSource } func make( for collectionView: UICollectionView ) -> DataSource.SupplementaryViewProvider { // Регистрируем вью заголовка collectionView.register( class: PassengerSectionHeaderView.self, forSupplementaryViewOfKind: kind ) // Возвращаем провайдер return { collectionView, kind, indexPath in let header: PassengerSectionHeaderView = collectionView .dequeueReusableSupplementaryView( ofKind: kind, for: indexPath ) // Получаем стейт для заголовка по индексу секции let viewState = _dataSource.snapshot() .sectionIdentifiers[indexPath.section] // Конфигурируем вьюшку header.render(viewState) return header } }
}
struct SeparatorPassengersViewProvider: ChildPassengersViewProvider { // Устанавливаем тип вьюшки var kind: String { PassengersSupplementaryViewKind.separator } func make( for collectionView: UICollectionView ) -> DataSource.SupplementaryViewProvider { // Регистрируем вью сепоратора collectionView.register( class: PassengerSeparatorView.self, forSupplementaryViewOfKind: kind ) // Возвращаем провайдер return { collectionView, kind, indexPath in let header: PassengerSeparatorView = collectionView .dequeueReusableSupplementaryView( ofKind: kind, for: indexPath ) let itemsInSection = collectionView.numberOfItems( inSection: indexPath.section ) // Скрываем сепоратор, если в секции только 1 элемент header.isHidden = itemsInSection - 1 == indexPath.row return header } }
}

Вспомним, что наш DataSource принимает один вариант SupplementaryViewProvider, но у нас их несколько. Для этого воспользуемся паттерном Composite и создадим композитный провайдер:

struct CompositePassengersViewProvider: PassengersViewProvider { private var _providers: [String: PassengersViewProvider] init(providers: [ChildPassengersViewProvider]) { // Доступ к провайдеру будем осуществлять по его типу _providers = Dictionary(grouping: providers, by: { $0.kind }) .compactMapValues(\.first) } func make( for collectionView: UICollectionView ) -> DataSource.SupplementaryViewProvider { return { collectionView, kind, indexPath in // Ищем провайдер по типу вьюшки guard let provider = _providers[kind] else { fatalError("Unimplemented provider for \(kind)") } // Проксируем вызов дочернему элементу return provider.make(for: collectionView)( collectionView, kind, indexPath ) } }
}

Если для отрисовки списка потребуется больше одной ячейки, аналогичным образом  заведём CompositePassengerCellsProvider. Почему не сделать сразу? Потому что Keep it simple, stupid. 

В результате наше решение выглядит следующим образом: 

 Исходники на github.com.

Читайте так же:

  • Ретейл-2030: угрозы для новой реальностиРетейл-2030: угрозы для новой реальности Пандемия оказала влияние на многие сферы экономики. Розничная торговля в спешном порядке переместилась в онлайн, произошёл взрывной спрос на услуги курьеров и служб доставки товаров. Привычные цепочки поставок изменились, и эти изменения не были предсказаны традиционными логистическими […]
  • [recovery mode] У вас нет интересных проектов, а мне нечем гордиться. Но мы поладим[recovery mode] У вас нет интересных проектов, а мне нечем гордиться. Но мы поладим Первые 3 года бытности моей веб-разработчиком проекты казались мне сложными, а еще через 3 года они стали интересными. Но исключительно с точки зрения академического интереса: возможно ли реализовать требования заказчика в установленных сроках и бюджетах. Что на языке не-академическом […]
  • Клавиатуры, которые постигла неудачаКлавиатуры, которые постигла неудача Найти идеальный баланс между ценой и качеством пытаются дизайнеры и разработчики любого производителя. Но часто случается, что излишняя оптимизация или «гениальный» дизайн выливаются в кучу проблем для пользователя. Сегодня мы расскажем вам про самые спорные модели клавиатур. Клавиатура […]
  • iFixit отметила прогресс в ремонтопригодности новых MacBook ProiFixit отметила прогресс в ремонтопригодности новых MacBook Pro Специалисты iFixit разобрали новый MacBook Pro. Они отметили ряд улучшений в плане ремонтопригодности макбука. MacBook Pro получил 4 балла из десяти по шкале ремонтопригодности. 16-дюймовый MacBook Pro 2019 года получал от iFixit всего 1 балл.Авторы разбора отметили, что в новом макбуке […]