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.

  • Using the UICollectionView provides consistency across any list of objects, as its API is generally similar to the UITableView.

  • The collection allows you to apply non-standard layout views, as well as related attributes of animated transitions.

We also had some concerns:

  • Ability to use Pull to refresh

  • Lack of lags when rendering

  • The ability to scroll in cells

But in the course of implementation, they all disappeared.

By getting rid of the table class, we were able to write an adapter that is extensible for a whole family of lists, with the ability to painlessly return to the table under the hood at any time.

Adapters

Collections are good, of course, but have you tried to get rid of the usual boilerplate with datasources and delegates so that the creation of the on-screen list takes no more than 10 lines? For comparison, let’s remember the classic UITableView implementation of the combo box.

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)
     }
}

Come to the rescue Jedi adapters.

Recall that the pattern adapter gives the original object a new interface that is convenient to work with in this context. Our adapter was certainly not limited to this.

Below is an example of such use.

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)
}

However, internally, the adapter is not even one class.

Let’s start with a basic (and generally abstract) list adapter class:

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

Thus, within a particular screen, only the minimum setting needs to be done. This will make the code easier to read.

As you can see from the example above: first comes the typealias block in order to define the constraints on the types to be used.

DragAndDropStyle is responsible for the ability to swap cells within the collection.

headerModel – the model that represents the header of the collection

spacing – the distance between elements

Next comes the block of closures that allow you to subscribe to specific changes in the collection.

The onReady and onResize subscription methods let you understand when the adapter collection is ready for use and when the collection has changed in size due to the addition or removal of objects, respectively.

collectionView, setup (collectionView 🙂 – directly used collection instance and method for setting it

items – a set of models to display

setHeader – method for setting the header of the collection

itemSizeCacher is a class that implements caching of the sizes of list items. The default implementation is presented below:

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 = [:]
    }
    
}

The rest of the interface is represented by methods for updating elements.

There are also specific implementations, which, for example, are sharpened for a specific arrangement of cells along the axis.

AnyListAdapter

As long as we work with dynamic content, everything is fine. But in the introduction, we didn’t talk about infinite-scroll design in vain. What if you want to display both dynamic content cells (data from the web) and static views in a table at the same time? AnyListAdapter will serve for this.

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)
    }
}

As you might guess, AnyListAdapter abstracts from the specific cell type. It can be initialized with several types of cells, but they must all be either for a horizontal layout or for a vertical one. The condition here is the satisfaction of the HeightMeasurableView and WidthMeasurableView protocol.

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
}

The list also has a fixed height calculation algorithm:

  • fixed (constant or static model calculation method)

  • automatic (based on layout).

The entire force inside the cell-container AnyListCell is hidden.

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()
        }
    }
    
}

Such a cell is configured with two types of model: static and dynamic.

The first is just responsible for displaying regular views in the list.

The second one wraps the model, configurator and height calculation, while erasing the cell type itself. In fact, hence the prefix in the name of both the cell and the adapter itself: 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
}

Below is an example of filling the list of search results with various kinds of data: tags, operations and a placeholder to indicate the absence of elements.

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...)
    }
}

Thus, it became easy to build screens where all content is grouped in an infinite list, while not losing the performance of reusing cells.

Chunk list

We have just looked at a screen that is naturally divided into sections. The question arises, how convenient it is to work with sections in terms of indexing.

AnyListAdapter itself does not provide a convenient solution. It’s very easy to stumble upon an NSInternalInconsistencyException or remove an element from the wrong section. Finding the cause of this error can take time.

In order to be safe when working with insertion / deletion / updating of elements, we use the concept of slices by analogy with ArraySlice, presented in the standard library of the Swift language.

The goal was to make a similar interface for working with list sections in isolation, for example, in your own controller.

Let’s give an example of a complex screen.

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)
])

Now let’s distribute these sections to controllers. For simplicity, we will consider only one, since the rest will be similar to it.

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]

On the last line, we get a piece of the list: at this moment, its boundaries are determined and bind to the events of the parent list.

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)
    }
}

Now you can work with a section of a list without knowing anything about the original list and without worrying about the correct indexing.

Apart from the slice range data, the slice adapter interface is not much different from the original 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)?)
}

It’s not hard to guess that the index math takes place inside the proxying methods.

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))
}

In this case, the support of chunks within the ListAdapter itself plays a key role.

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
        )
    }
}

That is, every time we add and remove an item from the original list, we notify all subscribers Jedi Council Twitter about changing the size of the collection.

conclusions

It doesn’t hurt to make sure that all this was not in vain, so let’s refresh our memory of the received benifits. First, we got a unified interface for different types of lists. Including with different layouts: horizontal and vertical. If under the hood we are suddenly not satisfied with the performance (or bugs of the new iOS) in UICollectionView, then we can easily support the same protocol for tables.

So what for the lazy Most importantly, the list screen setup takes less than 10 lines of code.

If earlier we were afraid to complicate the screen by working with a table to display heterogeneous data, now we can safely write every third screen (~ 30%) on the lists, armed with one of our extensive arsenal of adapters. And if you want modular decomposition, then adapters for a piece of the list are at your service.

Now you can easily find your favorite Jogon fruit in the convenient search of the food delivery application on Tatooine, and if you are a Jedi, then quickly scroll to the end of the list of tasks from Master Yoda.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *