Einführung
In allen uns bekannten Galaxien präsentieren mobile Anwendungen lange Zeit Informationen in Form von Listen - sei es die Lieferung von Lebensmitteln auf Tatooine, die kaiserliche Post oder ein reguläres Jedi-Tagebuch. Seit jeher schreiben wir UI auf UITableView und haben nie darüber nachgedacht.
Unzählige Fehler und Kenntnisse über das Design dieses Tools und Best Practices haben sich angesammelt. Und als wir ein weiteres unendliches Scroll-Design bekamen, wurde uns klar: Es ist Zeit, über die Tyrannei von UITableViewDataSource und UITableViewDelegate nachzudenken und sie zu bekämpfen.
Warum sammeln?
Bisher standen Sammlungen im Schatten, viele hatten Angst vor übermäßiger Flexibilität oder hielten ihre Funktionalität für überflüssig.
Warum nicht einfach einen Stapel oder eine Tabelle verwenden? Wenn wir beim ersten schnell auf eine geringe Leistung stoßen, haben wir beim zweiten einen Mangel an Flexibilität bei der Implementierung des Layouts der Elemente.
Sind Sammlungen so beängstigend und welche Fallstricke verbergen sie in sich selbst? Wir haben verglichen.
Zellen in der Tabelle enthalten unnötige Elemente: Inhaltsansicht, Gruppenbearbeitungsansicht, Folienaktionsansicht, Zubehöransicht.
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%) , . - .
, - .