Введение
Уже давным давно, во всех известных нам галактиках мобильные приложения представляют информацию в виде списков - будь то доставка еды на Татуине, имперская почта или обычный ежедневник джедая. С незапамятных времен мы писали UI на UITableView и не задумывались.
Копились бесчисленные баги и знания об устройстве этого инструмента и о лучших практиках. И когда мы получили очередной infinite scroll дизайн, мы поняли: пришло время задуматься и дать отпор тирании UITableViewDataSource и UITableViewDelegate.
Почему коллекция?
До сих пор коллекции пребывали в тени, многие побаивались их чрезмерной гибкости или считали их функционал избыточным.
В самом деле, почему бы просто не использовать стек или таблицу? Если для первого мы быстро упремся в низкую производительность, то со вторым в отсутсвие гибкости при реализации лейаута элементов.
Так ли страшны коллекции и какие подводные камни они в себе таят? Мы сравнили.
Ячейки в таблице содержат лишние элементы: content view, group editing view, slide actions view, accessory view.
Использование UICollectionView дает единообразность при работе с любыми списками объектов, так как ее API в целом схож с UITableView.
Коллекция позволяет применять нестандартные виды лейаута, а так же связанные с ним атрибуты анимированных транзишнов.
Так же у нас были некоторые опасения:
Возможность использовать Pull to refresh
Отсутсвие лагов при отрисовке
Возможность скролла в ячейках
Но в ходе реализации все они развеялись.
Избавившись от класса таблицы, мы смогли написать расширяемый для целого семейства списков адаптер с возможностью в любой момент безболезненно вернуться к таблице под капотом.
Адаптеры
Коллекции это, конечно, хорошо, но пробовали ли вы избавиться от привычного боилерплейта с датасорсами и делегатами, чтобы создание экранного списка занимало не больше 10 строк? Для сравнения, вспомним классическую реализацию экрана со списком на UITableView.
final class CurrencyViewController: UIViewController {
var tableView = UITableView()
var items: [ViewModel] = []
func setup() {
tableView.delegate = self
tableView.dataSource = self
tableView.backgroundColor = .white
tableView.rowHeight = 72.0
tableView.contentInset = .init(top: Constants.topSpacing, left: 0, bottom: Constants.bottomSpacing, right: 0)
tableView.reloadData()
}
}
extension CurrencyViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
output.didSelectBalance(at: indexPath.row)
}
}
extension CurrencyViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
items.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusable(cell: object.cellClass, at: indexPath)
cell.setup(with: object)
return cell
}
}
extension UITableView {
func dequeueReusable(cell type: UITableViewCell.Type, at indexPath: IndexPath) -> UITableViewCell {
if let cell: UITableViewCell = self.dequeueReusableCell(withIdentifier: type.name()) {
return cell
}
self.register(cell: type)
let cell: UITableViewCell = self.dequeueReusableCell(withIdentifier: type.name(), for: indexPath)
return cell
}
private func register(cell type: UITableViewCell.Type) {
let identifier: String = type.name()
self.register(type, forCellReuseIdentifier: identifier)
}
}
Приходят на помощь джедаи адаптеры.
Напомним, что паттерн адаптер наделяет исходный объект новым интерфейсом, с которым в данном контексте удобно работать. Наш адаптер конечно лишь этим не ограничился.
Ниже приведен пример такого использования.
private let listAdapter = CurrencyVerticalListAdapter()
private let collectionView = UICollectionView(
frame: .zero,
collectionViewLayout: UICollectionViewFlowLayout()
)
private var viewModel: BalancePickerViewModel
func setup() {
listAdapter.setup(collectionView: collectionView)
collectionView.backgroundColor = .c0
collectionView.contentInset = .init(top: Constants.topSpacing, left: 0, bottom: Constants.bottomSpacing, right: 0)
listAdapter.onSelectItem = output.didSelectBalance
listAdapter.heightMode = .fixed(height: 72.0)
listAdapter.spacing = 8.0
listAdapter.reload(items: viewModel.items)
}
Однако внутри адаптер представляет собой даже не один класс.
Рассмотрим для начала базовый (и вообще говоря абстрактный) класс адаптера списков:
public class ListAdapter<Cell> : NSObject, ListAdapterInput, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDragDelegate, UICollectionViewDropDelegate, UIScrollViewDelegate where Cell : UICollectionViewCell, Cell : DesignKit.AnimatedConfigurableView, Cell : DesignKit.RegistrableView {
public typealias Model = Cell.Model
public typealias ResizeCallback = (_ insertions: [Int], _ removals: [Int], _ skipNext: Bool) -> Void
public typealias SelectionCallback = ((Int) -> Void)?
public typealias ReadyCallback = () -> Void
public enum DragAndDropStyle {
case reorder
case none
}
public var dragAndDropStyle: DragAndDropStyle { get set }
internal var headerModel: ListHeaderView.Model?
public var spacing: CGFloat
public var itemSizeCacher: UICollectionItemSizeCaching?
public var onSelectItem: ((Int) -> Void)?
public var onDeselectItem: ((Int) -> Void)?
public var onWillDisplayCell: ((Cell) -> Void)?
public var onDidEndDisplayingCell: ((Cell) -> Void)?
public var onDidScroll: ((CGPoint) -> Void)?
public var onDidEndDragging: ((CGPoint) -> Void)?
public var onWillBeginDragging: (() -> Void)?
public var onDidEndDecelerating: (() -> Void)?
public var onDidEndScrollingAnimation: (() -> Void)?
public var onReorderIndexes: (((Int, Int)) -> Void)?
public var onWillBeginReorder: ((IndexPath) -> Void)?
public var onReorderEnter: (() -> Void)?
public var onReorderExit: (() -> Void)?
internal func subscribe(_ subscriber: AnyObject, onResize: @escaping ResizeCallback)
internal func unsubscribe(fromResize subscriber: AnyObject)
internal func subscribe(_ subscriber: AnyObject, onReady: @escaping ReadyCallback)
internal func unsubscribe(fromReady subscriber: AnyObject)
internal weak var collectionView: UICollectionView?
public internal(set) var items: [Model] { get set }
public func setup(collectionView: UICollectionView)
public func setHeader(_ model: ListHeaderView.Model)
public subscript(index: Int) -> Model? { get }
public func reload(items: [Model], needsRedraw: Bool = true)
public func insertItem(_ item: Model, at index: Int, allowDynamicModification: Bool = true)
public func appendItem(_ item: Model, allowDynamicModification: Bool = true)
public func deleteItem(at index: Int, allowDynamicModification: Bool = true)
public func deleteItemsIfNeeded(at range: PartialRangeFrom<Int>)
public func deleteItems(at indexes: [Int], allowDynamicModification: Bool = true)
public func updateItem(_ item: Model, at index: Int, allowDynamicModification: Bool = true)
public func reloadItems(_ newItems: [Model], at range: PartialRangeFrom<Int>, allowDynamicModification: Bool = true)
public func reloadItems(_ newItems: [Model], at indexes: [Int], allowDynamicModification: Bool = true)
public func reloadItems(_ newItems: [(index: Int, element: Model)], allowDynamicModification: Bool = true)
public func moveItem(at index: Int, to newIndex: Int)
public func performBatchUpdates(updates: @escaping (ListAdapter) -> Void, completion: ((Bool) -> Void)?)
public func performBatchUpdates(updates: () -> Void, completion: ((Bool) -> Void)?)
}
public typealias ListAdapterCellConstraints = UICollectionViewCell & RegistrableView & AnimatedConfigurableView
public typealias VerticalListAdapterCellConstraints = ListAdapterCellConstraints & HeightMeasurableView
public typealias HorizontalListAdapterCellConstraints = ListAdapterCellConstraints & WidthMeasurableView
Таким образом, внутри конкретного экрана нужно выполнить только минимальную настройку. Благодаря этому код станет проще для восприятия.
Как можно увидеть из примера выше: сначала идёт блок typealias'ов для того, чтобы определить ограничения на используемые типы.
DragAndDropStyle отвечает за возможность менять местами ячейки внутри коллекции.
headerModel - модель, которая представляет заголовок коллекции
spacing - расстояние между элементами
Дальше идёт блок замыканий, которые позволяют подписаться на определённые изменения в коллекции.
Методы для подписки onReady и onResize позволяют понять, когда коллекция адаптера стала готова к работе, и когда изменился размер коллекции из-за добавления или удаления объектов, соответственно.
collectionView, setup(collectionView:) - непосредственно используемый экземпляр коллекции и метод для её установки
items - набор моделей для отображения
setHeader - метод для установки заголовка коллекции
itemSizeCacher - класс, реализующий кеширование размеров элементов списка. Дефолтная реализация представлена ниже:
final class DefaultItemSizeCacher: UICollectionItemSizeCaching {
private var sizeCache: [IndexPath: CGSize] = [:]
func itemSize(cachedAt indexPath: IndexPath) -> CGSize? {
sizeCache[indexPath]
}
func cache(itemSize: CGSize, at indexPath: IndexPath) {
sizeCache[indexPath] = itemSize
}
func invalidateItemSizeCache(at indexPath: IndexPath) {
sizeCache[indexPath] = nil
}
func invalidate() {
sizeCache = [:]
}
}
Остальную часть интерфейса представляют методы для обновления элементов.
Также есть конкретные реализации, которые, например, заточены под определенное расположение ячеек по оси.
AnyListAdapter
До тех пор, пока мы работаем с динамическим контентом, все хорошо. Но во введении мы не зря говорили про infinite-scroll дизайн. Что делать, если в таблице нужно одновременно отображать и ячейки динамического контента(данные из сети) и статические вью? Для этого нам послужит AnyListAdapter.
public typealias AnyListSliceAdapter = ListSliceAdapter<AnyListCell>
public final class AnyListAdapter : ListAdapter<AnyListCell>, UICollectionViewDelegateFlowLayout {
public var dimensionCalculationMode: DesignKit.AnyListAdapter.DimensionCalculationMode
public let axis: Axis
public init<Cell>(dynamicCellType: Cell.Type) where Cell : UICollectionViewCell, Cell : DesignKit.AnimatedConfigurableView, Cell : DesignKit.HeightMeasurableView, Cell : DesignKit.RegistrableView
public init<Cell>(dynamicCellType: Cell.Type) where Cell : UICollectionViewCell, Cell : DesignKit.AnimatedConfigurableView, Cell : DesignKit.RegistrableView, Cell : DesignKit.WidthMeasurableView
}
public extension AnyListAdapter {
convenience public init<C1, C2>(dynamicCellTypes: (C1.Type, C2.Type)) where C1 : UICollectionViewCell, C1 : DesignKit.AnimatedConfigurableView, C1 : DesignKit.HeightMeasurableView, C1 : DesignKit.RegistrableView, C2 : UICollectionViewCell, C2 : DesignKit.AnimatedConfigurableView, C2 : DesignKit.HeightMeasurableView, C2 : DesignKit.RegistrableView
convenience public init<C1, C2, C3>(dynamicCellTypes: (C1.Type, C2.Type, C3.Type)) where C1 : UICollectionViewCell, C1 : DesignKit.AnimatedConfigurableView, C1 : DesignKit.HeightMeasurableView, C1 : DesignKit.RegistrableView, C2 : UICollectionViewCell, C2 : DesignKit.AnimatedConfigurableView, C2 : DesignKit.HeightMeasurableView, C2 : DesignKit.RegistrableView, C3 : UICollectionViewCell, C3 : DesignKit.AnimatedConfigurableView, C3 : DesignKit.HeightMeasurableView, C3 : DesignKit.RegistrableView
convenience public init<C1, C2>(dynamicCellTypes: (C1.Type, C2.Type)) where C1 : UICollectionViewCell, C1 : DesignKit.AnimatedConfigurableView, C1 : DesignKit.RegistrableView, C1 : DesignKit.WidthMeasurableView, C2 : UICollectionViewCell, C2 : DesignKit.AnimatedConfigurableView, C2 : DesignKit.RegistrableView, C2 : DesignKit.WidthMeasurableView
convenience public init<C1, C2, C3>(dynamicCellTypes: (C1.Type, C2.Type, C3.Type)) where C1 : UICollectionViewCell, C1 : DesignKit.AnimatedConfigurableView, C1 : DesignKit.RegistrableView, C1 : DesignKit.WidthMeasurableView, C2 : UICollectionViewCell, C2 : DesignKit.AnimatedConfigurableView, C2 : DesignKit.RegistrableView, C2 : DesignKit.WidthMeasurableView, C3 : UICollectionViewCell, C3 : DesignKit.AnimatedConfigurableView, C3 : DesignKit.RegistrableView, C3 : DesignKit.WidthMeasurableView
}
public extension AnyListAdapter {
public enum Axis {
case horizontal
case vertical
}
public enum DimensionCalculationMode {
case automatic
case fixed(constant: CGFloat? = nil)
}
}
Как не трудно догадаться, AnyListAdapter абстрагируется от конкретного типа ячейки. Его можно проинициализировать несколькими типами ячеек, но они все должны быть либо для горизонтального лейаута, либо вертикального. Условием здесь выступает удовлетворение протоколу HeightMeasurableView и WidthMeasurableView.
public protocol HeightMeasurableView where Self: ConfigurableView {
static func calculateHeight(model: Model, width: CGFloat) -> CGFloat
func measureHeight(model: Model, width: CGFloat) -> CGFloat
}
public protocol WidthMeasurableView where Self: ConfigurableView {
static func calculateWidth(model: Model, height: CGFloat) -> CGFloat
func measureWidth(model: Model, height: CGFloat) -> CGFloat
}
У списка так же фиксируется алгоритм подсчета высоты:
фиксированный(константа или статический метод расчета по модели)
автоматический (на основе лейаута).
Сила вся внутри ячейки-контейнера AnyListCell спрятана.
public class AnyListCell: ListAdapterCellConstraints {
// MARK: - ConfigurableView
public enum Model {
case `static`(UIView)
case `dynamic`(DynamicModel)
}
public func configure(model: Model, animated: Bool, completion: (() -> Void)?) {
switch model {
case let .static(view):
guard !contentView.subviews.contains(view) else { return }
clearSubviews()
contentView.addSubview(view)
view.layout {
$0.pin(to: contentView)
}
case let .dynamic(model):
model.configure(cell: self)
}
completion?()
}
// MARK: - RegistrableView
public static var registrationMethod: ViewRegistrationMethod = .class
public override func prepareForReuse() {
super.prepareForReuse()
clearSubviews()
}
private func clearSubviews() {
contentView.subviews.forEach {
$0.removeFromSuperview()
}
}
}
Такая ячейка конфигурируется двумя видами модели: статической и динамической.
Первая как раз отвечает за отображение в списке обычных вью.
Вторая же оборачивает в себя модель, конфигуратор и подсчет высоты, стирая при этом сам тип ячейки. В действительности отсюда и префикс в названии как ячейки, так и самого адаптера: Any.
struct DynamicModel {
public init<Cell>(model: Cell.Model,
cell: Cell.Type) {
// ...
}
func dequeueReusableCell(from collectionView: UICollectionView, for indexPath: IndexPath) -> UICollectionViewCell
func configure(cell: UICollectionViewCell)
func calcucalteDimension(otherDimension: CGFloat) -> CGFloat
func measureDimension(otherDimension: CGFloat) -> CGFloat
}
Ниже приведён пример наполнения списка результатов поиска разного рода данными: теги, операции и плейсхолдер для индикации отсутствия элементов.
private let listAdapter = AnyListAdapter(
dynamicCellTypes: (CommonCollectionViewCell.self, OperationCell.self)
)
func configureSearchResults(with model: OperationsSearchViewModel) {
var items: [AnyListCell.Model] = []
model.sections.forEach {
let header = VerticalSectionHeaderView().configured(with: $0.header)
items.append(.static(header))
switch $0 {
case .tags(nil), .operations(nil):
items.append(
.static(OperationsNoResultsView().configured(with: Localisation.feed_search_no_results))
)
case let .tags(models?):
items.append(
contentsOf: models.map {
.dynamic(.init(
model: $0,
cell: CommonCollectionViewCell.self
))
}
)
case .operations(let models?):
items.append(
contentsOf: models.map {
.dynamic(.init(
model: $0,
cell: OperationCell.self
))
}
)
}
}
UIView.performWithoutAnimation {
listAdapter.deleteItemsIfNeeded(at: 0...)
listAdapter.reloadItems(items, at: 0...)
}
}
Таким образом, стало легко строить экраны, на которых весь контент группируется в бесконечном списке, при этом не теряя производительности переиспользования ячеек.
Список по кускам
Только что мы рассмотрели экран, который натуральным образом поделен на секции. Возникает вопрос, на сколько удобно работать с секциями в плане индексации.
Сам по себе AnyListAdapter не дает удобного решения. Очень легко наткнуться на NSInternalInconsistencyException или удалить элемент не из той секции. Поиск причины этой ошибки может занять время.
Для того, чтобы обезопасить себя при работе с вставкой/удалением/обновлением элементов, мы используем концепцию слайсов по аналогии с ArraySlice, представленным в стандартной библиотеке языка Swift.
Целью было сделать похожий интерфейс для работы с секциями списка изолированно, например, в своем собственном контроллере.
Приведем пример сложного экрана.
let subjectsSectionHeader = SectionHeaderView(title: "Subjects")
let pocketsSectionHeader = SectionHeaderView(title: "Pockets")
let cardsSectionHeader = SectionHeaderView(title: "Cards")
let categoriesHeader = SectionHeaderView(title: "Categories")
let list = AnyListAdapter()
listAdapter.reloadItems([
.static(subjectsSectionHeader),
.static(pocketsSectionHeader)
.static(cardsSectionHeader),
.static(categoriesHeader)
])
Теперь распределим эти секции по контроллерам. Для простоты рассмотрим лишь один, так как остальные будут похожими на него.
class PocketsViewController: UIViewController {
var listAdapter: AnyListSliceAdapter! {
didSet {
reload()
}
}
var pocketsService = PocketsService()
func reload() {
pocketsService.fetch { pockets, error in
guard let pocket = pockets else { return }
listAdapter.reloadItems(
pockets.map { .dynamic(.init(model: $0, cell: PocketCell.self)) },
at: 1...
)
}
}
func didTapRemoveButton(at index: Int) {
listAdapter.deleteItemsIfNeeded(at: index)
}
}
let subjectsVC = PocketsViewController()
subjectsVC.listAdapter = list[1..<2]
На последней строчке мы и получаем кусок списка: в этот момент происходит определение его границ и привязка к событиям родительского списка.
public extension ListAdapter {
subscript(range: Range<Int>) -> ListSliceAdapter<Cell> {
.init(listAdapter: self, range: range)
}
init(listAdapter: ListAdapter<Cell>,
range: Range<Int>) {
self.listAdapter = listAdapter
self.sliceRange = range
let updateSliceRange: ([Int], [Int], Bool) -> Void = { [unowned self] insertions, removals, skipNextResize in
self.handleParentListChanges(insertions: insertions, removals: removals)
self.skipNextResize = skipNextResize
}
let enableWorkingWithSlice = { [weak self] in
self?.onReady?()
return
}
listAdapter.subscribe(self, onResize: updateSliceRange)
listAdapter.subscribe(self, onReady: enableWorkingWithSlice)
}
}
Теперь работать с секцией списка можно ничего не зная об оригинальном списке и не беспокоясь о правильности индексации.
Кроме данных о рендже слайса, интерфейс слайс адаптера мало чем отличается от оригинального ListAdapter.
public final class ListSliceAdapter<Cell> : ListAdapterInput where Cell : UICollectionViewCell, Cell : ConfigurableView, Cell : RegistrableView {
public var items: [Model] { get }
public var onReady: (() -> Void)?
internal private(set) var sliceRange: Range<Int> { get set }
internal init(listAdapter: ListAdapter<Cell>, range: Range<Int>)
convenience internal init(listAdapter: ListAdapter<Cell>, index: Int)
public subscript(index: Int) -> Model? { get }
public func reload(items: [Model], needsRedraw: Bool = true)
public func insertItem(_ item: Model, at index: Int, allowDynamicModification: Bool = true)
public func appendItem(_ item: Model, allowDynamicModification: Bool = true)
public func deleteItem(at index: Int, allowDynamicModification: Bool = true)
public func deleteItemsIfNeeded(at range: PartialRangeFrom<Int>)
public func deleteItems(at indexes: [Int], allowDynamicModification: Bool = true)
public func updateItem(_ item: Model, at index: Int, allowDynamicModification: Bool = true)
public func reloadItems(_ newItems: [Model], at range: PartialRangeFrom<Int>, allowDynamicModification: Bool = true)
public func reloadItems(_ newItems: [Model], at indexes: [Int], allowDynamicModification: Bool = true)
public func reloadItems(_ newItems: [(index: Int, element: Model)], allowDynamicModification: Bool = true)
public func moveItem(at index: Int, to newIndex: Int)
public func performBatchUpdates(updates: () -> Void, completion: ((Bool) -> Void)?)
}
Нетрудно догадаться, что внутри проксирующих методов происходит математика индексов.
public func deleteItemsIfNeeded(at range: PartialRangeFrom<Int>) {
guard canDelete(index: range.lowerBound) else { return }
let start = globalIndex(of: range.lowerBound)
let end = sliceRange.upperBound - 1
listAdapter.deleteItems(at: Array(start...end))
}
При этом ключевую роль играет поддержка кусков внутри самого ListAdapter.
public class ListAdapter {
// ...
var resizeSubscribers = NSMapTable<AnyObject, NSObjectWrapper<ResizeCallback>>.weakToStrongObjects()
}
extension ListAdapter {
public func appendItem(_ item: Model) {
let index = items.count
let changes = {
self.items.append(item)
self.handleSizeChange(insert: self.items.endIndex)
self.collectionView?.insertItems(at: [IndexPath(item: index, section: 0)])
}
if #available(iOS 13, *) {
changes()
} else {
performBatchUpdates(updates: changes, completion: nil)
}
}
func handleSizeChange(removal index: Int) {
notifyAboutResize(removals: [index])
}
func handleSizeChange(insert index: Int) {
notifyAboutResize(insertions: [index])
}
func notifyAboutResize(insertions: [Int] = [], removals: [Int] = [], skipNextResize: Bool = false) {
resizeSubscribers
.objectEnumerator()?
.allObjects
.forEach {
($0 as? NSObjectWrapper<ResizeCallback>)?.object(insertions, removals, skipNextResize)
}
}
func shiftSubscribers(after index: Int, by shiftCount: Int) {
guard shiftCount > 0 else { return }
notifyAboutResize(
insertions: Array(repeating: index, count: shiftCount),
skipNextResize: true
)
}
}
То есть при каждом добавлении и удалении элемента из исходного списка мы уведомляем всех подписчиков твиттера совета джедаев об изменении размера коллекции.
Выводы
Не помешает убедиться, что все это было не зря, поэтому освежим в памяти полученные бенифиты. Во-первых, мы получили единый интерфейс для разных видов списков. В том числе с разным лейаутом: горизонтальный и вертикальный. Если под капотом нас вдруг не устроит производительность (или баги новой iOS) у UICollectionView, то легко сможем поддержать тот же протокол и для таблиц.
И, что для ленивых самое важное - сетап экрана со списком занимает меньше 10 строк кода.
Если мы раньше боялись усложнять экран работой с таблицей для отображения разнородных данных, то сейчас смело пишем каждый третий экран( ~30%) на списках, вооружившись одним из нашего обширного арсенала адаптеров. А если хотите в модульную декомпозицию - то к вашим услугам адаптеры для куска списка.
Теперь вы с легкостью найдете любимый фрукт джогон в удобном поиске приложения доставки еды на Татуине, а если вы джедай - то без задержек доскролите до конца список заданий от магистра Йоды.
Gargo
исходники компонента?
Есть полный пример CurrencyVerticalListAdapter?
Почему не используете алгоритм diff, который позволяет добавлять/удалять элементы пачками?