Introdução
Por muito tempo, em todas as galáxias que conhecemos, os aplicativos móveis apresentam informações na forma de listas - seja entrega de comida no Tatooine, nos correios imperiais ou em um diário Jedi regular. Desde tempos imemoriais, escrevemos UI no UITableView e nunca pensamos nisso.
Incontáveis bugs e conhecimento sobre o design desta ferramenta e melhores práticas foram acumulados. E quando obtivemos outro design de rolagem infinita, percebemos: é hora de pensar e lutar contra a tirania de UITableViewDataSource e UITableViewDelegate.
Por que coleção?
Até agora, as coleções estavam na sombra, muitos temiam sua flexibilidade excessiva ou consideravam sua funcionalidade redundante.
Na verdade, por que não usar apenas uma pilha ou uma mesa? Se para o primeiro iremos rapidamente cair em baixo desempenho, então com o segundo teremos uma falta de flexibilidade na implementação do layout dos elementos.
As coleções são tão assustadoras e que armadilhas elas escondem em si mesmas? Nós comparamos.
As células na tabela contêm elementos desnecessários: visualização de conteúdo, visualização de edição de grupo, visualização de ações de slide, visualização de acessório.
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%) , . - .
, - .