UICollectionViewのアプローチを一覧表示します

前書き

長い間、私たちが知っているすべての銀河で、モバイルアプリケーションは、タトゥイーン、帝国郵便局、または通常のジェダイ日記での食品配達であるかどうかにかかわらず、リストの形式で情報を提示します。太古の昔から、私たちは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%) , . - .





, - .








All Articles