[Разработка под iOS, Разработка мобильных приложений, Swift] Подходы к спискам на UICollectionView
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
ВведениеУже давным давно, во всех известных нам галактиках мобильные приложения представляют информацию в виде списков - будь то доставка еды на Татуине, имперская почта или обычный ежедневник джедая. С незапамятных времен мы писали 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%) на списках, вооружившись одним из нашего обширного арсенала адаптеров. А если хотите в модульную декомпозицию - то к вашим услугам адаптеры для куска списка.Теперь вы с легкостью найдете любимый фрукт джогон в удобном поиске приложения доставки еды на Татуине, а если вы джедай - то без задержек доскролите до конца список заданий от магистра Йоды.
===========
Источник:
habr.com
===========
Похожие новости:
- [Разработка мобильных приложений, Законодательство в IT, Здоровье, IT-компании] Apple и Google заблокировали обновление британского приложения для отслеживания заражённых коронавирусом
- [Разработка под iOS, Разработка мобильных приложений, Разработка под Android, Swift, Flutter] Онлайн митап по мобильной кросс-платформе 15 апреля
- [Разработка мобильных приложений, Законодательство в IT, Софт, IT-компании] Бумажные документы заменят электронными «Госдоками»
- [Программирование, Разработка под iOS, Objective C, Swift] Связанные не явные выражения в Swift 5.4 (перевод)
- [Разработка мобильных приложений, Разработка под Android, Kotlin, Дизайн мобильных приложений] Реализация Undo в Snackbar на Jetpack Compose (перевод)
- [Разработка мобильных приложений, Разработка под Android, Разработка под e-commerce, Управление продуктом] История одного личного кабинета, который помог нам сделать 15 000 курьеров и сборщиков немного счастливее
- [Программирование, Разработка мобильных приложений, Учебный процесс в IT, Карьера в IT-индустрии] Апрельский дайджест: приглашаем на онлайн-практикумы и митапы
- [Разработка мобильных приложений, Смартфоны, Игры и игровые приставки] Sony планирует портировать игры PlayStation на смартфоны
- [PHP, Разработка под iOS, API, Dart, Flutter] Уродливый API
- [JavaScript, Разработка под iOS, Разработка мобильных приложений, Разработка под Android, ERP-системы] Cordova. Опыт Enterprise-проекта
Теги для поиска: #_razrabotka_pod_ios (Разработка под iOS), #_razrabotka_mobilnyh_prilozhenij (Разработка мобильных приложений), #_swift, #_swift, #_uikit, #_ios, #_uicollectionview, #_uitableview, #_spiski (списки), #_pattern_adapter (паттерн адаптер), #_blog_kompanii_vivid_money (
Блог компании Vivid Money
), #_razrabotka_pod_ios (
Разработка под iOS
), #_razrabotka_mobilnyh_prilozhenij (
Разработка мобильных приложений
), #_swift
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 13:35
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
ВведениеУже давным давно, во всех известных нам галактиках мобильные приложения представляют информацию в виде списков - будь то доставка еды на Татуине, имперская почта или обычный ежедневник джедая. С незапамятных времен мы писали UI на UITableView и не задумывались. Копились бесчисленные баги и знания об устройстве этого инструмента и о лучших практиках. И когда мы получили очередной infinite scroll дизайн, мы поняли: пришло время задуматься и дать отпор тирании UITableViewDataSource и UITableViewDelegate.Почему коллекция?До сих пор коллекции пребывали в тени, многие побаивались их чрезмерной гибкости или считали их функционал избыточным. В самом деле, почему бы просто не использовать стек или таблицу? Если для первого мы быстро упремся в низкую производительность, то со вторым в отсутсвие гибкости при реализации лейаута элементов.Так ли страшны коллекции и какие подводные камни они в себе таят? Мы сравнили.
АдаптерыКоллекции это, конечно, хорошо, но пробовали ли вы избавиться от привычного боилерплейта с датасорсами и делегатами, чтобы создание экранного списка занимало не больше 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 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) } } 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 }
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() } } } 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) } } 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)) } 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 ) } } =========== Источник: habr.com =========== Похожие новости:
Блог компании Vivid Money ), #_razrabotka_pod_ios ( Разработка под iOS ), #_razrabotka_mobilnyh_prilozhenij ( Разработка мобильных приложений ), #_swift |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 13:35
Часовой пояс: UTC + 5