前書き
長い間、私たちが知っているすべての銀河で、モバイルアプリケーションは、タトゥイーン、帝国郵便局、または通常のジェダイ日記での食品配達であるかどうかにかかわらず、リストの形式で情報を提示します。太古の昔から、私たちはUITableViewでUIを作成してきましたが、それについて考えたことはありませんでした。
このツールの設計とベストプラクティスに関する無数のバグと知識が蓄積されています。そして、別の無限スクロールデザインを手に入れたとき、私たちは気づきました。UITableViewDataSourceとUITableViewDelegateの専制政治を考えて反撃する時が来ました。
なぜコレクション?
これまで、コレクションは影に隠れていました。多くの人は、過度の柔軟性を恐れたり、機能が冗長であると考えていました。
確かに、なぜスタックやテーブルを使用しないのですか?最初のものですぐにパフォーマンスが低下する場合、2番目のものでは、要素のレイアウトの実装に柔軟性がありません。
コレクションはとても怖いですか、そしてそれらは彼ら自身にどんな落とし穴を隠しますか?比較しました。
テーブルのセルには、コンテンツビュー、グループ編集ビュー、スライドアクションビュー、アクセサリビューなどの不要な要素が含まれています。
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%) , . - .
, - .