Lister les approches sur UICollectionView

introduction

Depuis longtemps, dans toutes les galaxies que nous connaissons, les applications mobiles présentent des informations sous forme de listes - qu'il s'agisse de livraison de nourriture sur Tatooine, de la poste impériale ou d'un journal Jedi ordinaire. Depuis des temps immémoriaux, nous écrivons l'interface utilisateur sur UITableView et n'y avons jamais pensé.





D'innombrables bugs et connaissances sur la conception de cet outil et les meilleures pratiques se sont accumulés. Et quand nous avons eu un autre design de défilement infini, nous avons réalisé: il est temps de réfléchir et de combattre la tyrannie de UITableViewDataSource et UITableViewDelegate.





Pourquoi collection?

Jusqu'à présent, les collections étaient dans l'ombre, beaucoup avaient peur de leur flexibilité excessive ou considéraient leur fonctionnalité redondante.





En effet, pourquoi ne pas simplement utiliser une pile ou une table? Si pour le premier nous nous heurterons rapidement à de faibles performances, alors avec le second, nous manquons de flexibilité dans la mise en œuvre de la disposition des éléments.





Les collections sont-elles si effrayantes et quels pièges se cachent-elles? Nous avons comparé.





  • Les cellules du tableau contiennent des éléments inutiles: vue de contenu, vue d'édition de groupe, vue d'actions de diapositive, vue d'accessoires.





  • 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