Enumerar enfoques en UICollectionView

Introducción

Durante mucho tiempo, en todas las galaxias que conocemos, las aplicaciones móviles presentan información en forma de listas, ya sea a domicilio en Tatooine, la oficina de correos imperial o un diario Jedi normal. Desde tiempos inmemoriales, hemos estado escribiendo UI en UITableView y nunca pensamos en ello.





Se han acumulado innumerables errores y conocimientos sobre el diseño de esta herramienta y las mejores prácticas. Y cuando obtuvimos otro diseño de desplazamiento infinito, nos dimos cuenta: es hora de pensar y luchar contra la tiranía de UITableViewDataSource y UITableViewDelegate.





¿Por qué colección?

Hasta ahora, las colecciones estaban en la sombra, muchos temían su excesiva flexibilidad o consideraban su funcionalidad redundante.





De hecho, ¿por qué no usar una pila o una mesa? Si para el primero nos encontraremos rápidamente con un bajo rendimiento, entonces con el segundo tenemos una falta de flexibilidad en la implementación del diseño de los elementos.





¿Son las colecciones tan aterradoras y qué trampas ocultan en sí mismas? Comparamos.





  • Las celdas de la tabla contienen elementos innecesarios: vista de contenido, vista de edición de grupos, vista de acciones de diapositivas, vista de accesorios.





  • 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