List Approaches on UICollectionView

Introduction

For a long time, in all galaxies known to us, mobile applications present information in the form of lists - whether it is food delivery on Tatooine, the Imperial post office, or a regular Jedi diary. Since time immemorial, we have been writing UI on UITableView and never thought about it.





Countless bugs and knowledge about the design of this tool and best practices have accumulated. And when we got another infinite scroll design, we realized: it's time to think and fight back the tyranny of UITableViewDataSource and UITableViewDelegate.





Why collection?

Until now, collections were in the shadows, many were afraid of their excessive flexibility or considered their functionality redundant.





Indeed, why not just use a stack or a table? If for the first one we will quickly run into low performance, then with the second one we will have a lack of flexibility in the implementation of the layout of the elements.





Are collections so scary and what pitfalls do they conceal in themselves? We compared.





  • Cells in the table contain unnecessary elements: 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%) , . - .





, - .








All Articles