Decomposing a UICollectionViewCell

After watching Keynote WWDC 2019 and getting to know SwiftUI , which is designed to declaratively describe UIs in code, I want to speculate on how declaratively fill-in tablets and collections can be. For example, like this:


enumBuilder{
    staticfuncwidgets(objects: Objects) -> [Widget] {
        let header = [
            Spacing(height: 25).widget,
            Header(string: "Выберите страну").widget,
            Spacing(height: 10, separator: .bottom).widget
        ]
        let body = objects
            .flatMap({ (object: Object) -> [Widgets] inreturn [
                    Title(object: object).widget,
                    Spacing(height: 1, separator: .bottom).widget
                ]
            })
        return header + body
    }
}
let objects: [Object] = ...
Builder
    .widgets(objects: objects)
    .bind(to: collectionView)

In the collection, this is rendered as follows:

image


Introduction


As you know from authoritative sources : the vast majority of their time, a typical iOS developer spends working with tablets. If we assume that the developers on the project are sorely lacking and the tablets are not simple, then there is absolutely no time left for the rest of the application. And something needs to be done with this ... A possible solution would be cell decomposition.


Cell decomposition means replacing a single cell with multiple smaller cells. With such a replacement, nothing should visually change. As an example, consider posts from the VK news feed for iOS. One post can be represented both as a single cell, or as a group of cells - primitives .


Decomposing cells is not always possible. It will be difficult to break a cell into primitives that has a shadow or rounding on all sides. In this case, the original cell will be a primitive.


Advantages and disadvantages


Using the decomposition of cells, tables / collections begin to consist of primitives that will often be reused: a primitive with text, a primitive with a picture, a primitive with a background, etc. The calculation of the height of a single primitive is much simpler and more effective than a complex cell with a large number of states. If desired, the dynamic height of the primitive can be calculated or even drawn in the background (for example, text through CTFramesetter).


On the other hand, working with data becomes more complicated. Data will be required for each primitive and by the IndexPathprimitive it will be difficult to determine which real cell it refers to. We will have to introduce new layers of abstraction or somehow solve these problems.


You can talk for a long time about the possible pros and cons of this venture, but it is better to try to describe the approach to the decomposition of cells.


Choosing Tools


Since it is UITableViewlimited in its capabilities, and we, as already mentioned, have rather complex plates, then an adequate solution would be to use it UICollectionView. That is what UICollectionViewwill be discussed in this publication.


Using it, you UICollectionViewcome across a situation where the base UICollectionViewFlowLayoutone cannot form the required arrangement of the elements of the collection ( UICollectionViewCompositionalLayoutwe don’t take the new into account). At such moments, the decision is usually made to find some kind of open-source UICollectionViewLayout. But even among ready-made solutions, there may not be a suitable one, as, for example, in the case of the dynamic main page of a large online store or social network. We assume the worst, so we will create our own universal UICollectionViewLayout.


In addition to the difficulties with choosing a layout, you need to decide how the collection will receive data. In addition to the usual approach, where an object (most often UIViewController) complies with the protocol UICollectionViewDataSourceand provides data for the collection, the use of data-driven frameworks is gaining popularity. Bright representatives of this approach are CollectionKit , IGListKit , RxDataSources and others. The use of such frameworks simplifies the work with collections and provides the ability to animate data changes, because The diffing algorithm is already present in the framework. For publication purposes, the RxDataSources framework will be selected .


Widget and its properties


We introduce an intermediate data structure and call it a widget . We describe the main properties that a widget should have:


  1. The widget must comply with the necessary protocols for using the data-driven framework. Such protocols usually contain an associated value (for example, IdentifiableTypein RxDataSources )
  2. It should be possible to assemble widgets for different primitives into an array. To achieve this, the widget should not have associated values. For these purposes, you can use the mechanism of type erasure or something like that.
  3. The widget should be able to count the size of the primitive. Then, when forming UICollectionViewLayout, it remains only to correctly place the primitives according to the previously provided rules.
  4. The widget must be a factory for UICollectionViewCell. Therefore, UICollectionViewDataSourceall the logic for creating cells will be removed from the implementation and all that remains is:
    let cell = widget.widgetCell(collectionView: collectionView, indexPath: indexPath)
    return cell

Widget implementation


In order to be able to use the widget with the RxDataSources framework , it must comply with theEquatable and protocolsIdentifiableType . Since the widget represents a primitive, it will be sufficient for publishing purposes if the widget identifies itself to comply with the protocol IdentifiableType. In practice, this will affect the fact that when the widget changes, the primitive will not be reloaded, but deleted and inserted. To do this, we introduce a new protocol WidgetIdentifiable:


protocolWidgetIdentifiable: IdentifiableType{
}
extensionWidgetIdentifiable{
    var identity: Self {
        returnself
    }
}

To fit WidgetIdentifiable, the widget needs to match the protocol Hashable. The Hashablewidget will take data for compliance with the protocol from the object that will describe the particular primitive. It can be used AnyHashableto "erase" the type of an object with a widget.


structWidget: WidgetIdentifiable{
    let underlying: AnyHashableinit(_ underlying: AnyHashable) {
        self.underlying = underlying
    }
}
extensionWidget: Hashable{
    funchash(into hasher: inout Hasher) {
        self.underlying.hash(into: &hasher)
    }
    staticfunc ==(lhs: Widget, rhs: Widget) -> Bool {
        return lhs.underlying == rhs.underlying
    }
}

At this stage, the first two properties of the widget are executed. This is not difficult to check by collecting several widgets with different types of objects in an array.


let widgets = [Widget("Hello world"), Widget(100500)]

To implement the remaining properties, we introduce a new protocol WidgetPresentable


protocolWidgetPresentable{
    funcwidgetCell(collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionViewCellfuncwidgetSize(containerWidth: CGFloat) -> CGSize
}

The function widgetSize(containerWidth:)will be used in the UICollectionViewLayoutformation of cell attributes, and widgetCell(collectionView:indexPath:)- to obtain cells.


If the widget complies with the protocol WidgetPresentable, the widget will fulfill all the properties indicated at the beginning of publication. However, the object contained within the AnyHashable widget will have to be replaced with composition WidgetPresentableand WidgetHashable, where WidgetHashableit will not have an associated value (as in the case with Hashable), and the type of the object inside the widget will remain "erased":


protocolWidgetHashable{
    funcwidgetEqual(_ any: Any) -> BoolfuncwidgetHash(into hasher: inout Hasher)
}

In the final version, the widget will look like this:


structWidget: WidgetIdentifiable{
    let underlying: WidgetHashable & WidgetPresentableinit(_ underlying: WidgetHashable & WidgetPresentable) {
        self.underlying = underlying
    }
}
extensionWidget: Hashable{
    funchash(into hasher: inout Hasher) {
        self.underlying.widgetHash(into: &hasher)
    }
    staticfunc ==(lhs: Widget, rhs: Widget) -> Bool {
        return lhs.underlying.widgetEqual(rhs.underlying)
    }
}
extensionWidget: WidgetPresentable{
    funcwidgetCell(collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionViewCell {
        return underlying.widgetCell(collectionView: collectionView, indexPath: indexPath)
    }
    funcwidgetSize(containerWidth: CGFloat) -> CGSize {
        return underlying.widgetSize(containerWidth: containerWidth)
    }
}

Primitive object


Let's try to assemble the simplest primitive, which will be the indentation of a given height.


structSpacing: Hashable{
    let height: CGFloat
}
classSpacingView: UIView{
    lazyvar constraint = self.heightAnchor.constraint(equalToConstant: 1)
    init() {
        super.init(frame: .zero)
        self.constraint.isActive = true
    }
}
extensionSpacing: WidgetHashable{
    funcwidgetEqual(_ any: Any) -> Bool {
        iflet spacing = any as? Spacing {
            returnself == spacing
        }
        returnfalse
    }
    funcwidgetHash(into hasher: inout Hasher) {
        self.hash(into: &hasher)
    }
}
extensionSpacing: WidgetPresentable{
    funcwidgetCell(collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionViewCell {
        let cell: WidgetCell<SpacingView> = collectionView.cellDequeueSafely(indexPath: indexPath)
        if cell.view == nil {
            cell.view = SpacingView()
        }
        cell.view?.constraint.constant = height
        return cell
    }
    funcwidgetSize(containerWidth: CGFloat) -> CGSize {
        returnCGSize(width: containerWidth, height: height)
    }
}

WidgetCell<T>- this is just a subclass UICollectionViewCellthat accepts UIViewand adds it as a subview. cellDequeueSafely(indexPath:)- this is a function that registers a cell in a collection before reuse if it has not previously been registered in a cell. It Spacingwill be used as described at the very beginning of the publication.


After receiving the array of widgets, it remains only to bind to observerWidgets:


typealiasDataSource = RxCollectionViewSectionedAnimatedDataSource<WidgetSection>
classController: UIViewController{
    privatelazyvar dataSource: DataSource = self.makeDataSource()
    var observerWidgets: (Observable<Widgets>) -> Disposable {
        return collectionView.rx.items(dataSource: dataSource)
    }
    funcmakeDataSource() -> DataSource {
        returnDataSource(configureCell: { (_, collectionView: UICollectionView, indexPath: IndexPath, widget: Widget) inlet cell = widget.widgetCell(collectionView: collectionView, indexPath: indexPath)
            return cell
        })
    }
}

results


In conclusion, I would like to show the real work of the collection, which is built entirely on widgets.


image

As you can see, decomposition is UICollectionViewCellfeasible and in suitable situations can simplify the life of the developer.


Remarks


The code given in the publication is very simplified and should not be compiled. The goal was to describe the approach, not to provide a turnkey solution.


The protocol WidgetPresentablecan be expanded with other functions that optimize the layout, for example, widgetSizeEstimated(containerWidth:)or widgetSizePredefined(containerWidth:), which return the estimated and fixed size, respectively. It is worth noting that the function widgetSize(containerWidth:)should return the size of the primitive even for demanding calculations, for example, for systemLayoutSizeFitting(_:). Similar calculations can be cached through Dictionary, NSCacheetc.


As you know, all types of cells used UICollectionViewmust be pre-registered in the collection. However, in order to reuse widgets between different screens / collections and not register all cell identifiers / types in advance, you need to acquire a mechanism that will register a cell immediately before its first use within each collection. The publication used a function for this cellDequeueSafely(indexPath:).


There can be no headers or footers in the collection. In their place will be primitives. The presence of supplementary in the collection will not give any special bonuses in the current approach. Usually they are used when the data array strictly corresponds to the number of cells and it is required to show additional views before, after or between cells. In our case, data for auxiliary views can also be added to the widget array and drawn as primitives.


Within the same collection, widgets with the same objects can be located. For example, the same Spacingat the beginning and at the end of the collection. The presence of such non-unique objects will lead to the fact that the animation in the collection disappears. To make such objects unique, you can use special AnyHashabletags, #fileand #lineplaces to create an object, etc.


Also popular now: