After viewing Keynote WWDC 2019 and getting acquainted with SwiftUI , intended for the declarative description of the UI in the code, I would like to speculate on how you can declaratively fill tablets and collections. For example, like this:
enum Builder { static func widgets(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] in return [ 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 depicted as follows:
As it is known from authoritative sources : the typical iOS developer spends the vast majority of his time at work with tablets. If we assume that the developers on the project are sorely lacking and the plates are not simple, then there is no time left for the rest of the application. And with this we need to do something ... A possible solution would be the decomposition of the cells.
Cell decomposition means the replacement of a single cell with several smaller cells. With this replacement, visually nothing should change. As an example, you can consider posts from the VK news feed for iOS. One post can be represented both in the form of a single cell, and in the form of a group of cells - primitives .
Decomposing the cells will not always work. It will be difficult to break into primitives a cell that has a shadow or rounding on all sides. In this case, the source cell will be a primitive.
Using cell decomposition, 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 efficient 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 via CTFramesetter
).
On the other hand, work with data becomes more complicated. The data will be required for each primitive, and by the IndexPath
primitive it will be difficult to determine to which real cell it belongs. We will have to introduce new layers of abstraction or somehow solve these problems.
It is possible to talk for a long time about possible pros and cons of this undertaking, but it is better to try to describe the approach to cell decomposition.
Since UITableView
limited in its capabilities, and we, as already mentioned, have rather complicated tables, an adequate solution would be to use a UICollectionView
. About UICollectionView
also the speech in this publication will go.
Using a UICollectionView
encounter a situation where the base UICollectionViewFlowLayout
cannot form the required arrangement of collection elements (we do not take into account the new UICollectionViewCompositionalLayout
). At such times, it is usually decided to find some open-source UICollectionViewLayout
. But even among the ready-made solutions may not be suitable, as, for example, in the case of the dynamic home 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, it is necessary to decide how the collection will receive data. In addition to the usual approach, where an object (most often a UIViewController
) conforms to the UICollectionViewDataSource
protocol and provides data for a collection, the use of data-driven frameworks is gaining popularity. Vivid representatives of this approach are CollectionKit , IGListKit , RxDataSources and others. Using such frameworks simplifies working with collections and provides the ability to animate data changes, since The diffing algorithm is already present in the framework. For publishing purposes, the RxDataSources framework will be selected.
We introduce an intermediate data structure and call it a widget . We describe the basic properties that the widget should have:
IdentifiableType
in RxDataSources )UICollectionViewLayout
, it will only be UICollectionViewLayout
to correctly position the primitives according to the rules provided in advance.UICollectionViewCell
. Therefore, all the cell creation logic will be removed from the UICollectionViewDataSource
implementation and only the following will remain: let cell = widget.widgetCell(collectionView: collectionView, indexPath: indexPath) return cell
To be able to use the widget with the RxDataSources framework, it must comply with the Equatable
and IdentifiableType
protocols . Since the widget represents a primitive, it will be sufficient for publication purposes if the widget identifies itself to conform to the IdentifiableType
protocol. In practice, this will affect the fact that when a widget changes, it will not be a reboot of the primitive, but deletion and insertion. To do this, we introduce a new protocol WidgetIdentifiable
:
protocol WidgetIdentifiable: IdentifiableType { } extension WidgetIdentifiable { var identity: Self { return self } }
To match the WidgetIdentifiable
, the widget must conform to the Hashable
protocol. The data for compliance with the Hashable
protocol will be taken from the object that will describe the particular primitive. You can use AnyHashable
to "erase" an object type widget.
struct Widget: WidgetIdentifiable { let underlying: AnyHashable init(_ underlying: AnyHashable) { self.underlying = underlying } } extension Widget: Hashable { func hash(into hasher: inout Hasher) { self.underlying.hash(into: &hasher) } static func ==(lhs: Widget, rhs: Widget) -> Bool { return lhs.underlying == rhs.underlying } }
At this stage, the first two properties of the widget are executed. It is easy to check by gathering into the array several widgets with different types of objects.
let widgets = [Widget("Hello world"), Widget(100500)]
To implement the remaining properties, we introduce the new WidgetPresentable
protocol WidgetPresentable
protocol WidgetPresentable { func widgetCell(collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionViewCell func widgetSize(containerWidth: CGFloat) -> CGSize }
The widgetSize(containerWidth:)
function will be used in UICollectionViewLayout
when forming cell attributes, and widgetCell(collectionView:indexPath:)
will be used to get cells.
When the widget WidgetPresentable
protocol, the widget will execute all the properties indicated at the beginning of the publication. However, the object contained within the AnyHashable widget will have to be replaced with the composition WidgetPresentable
and WidgetHashable
, where WidgetHashable
will not have an associated value (as is the case with Hashable
) and the object type inside the widget will remain "erased":
protocol WidgetHashable { func widgetEqual(_ any: Any) -> Bool func widgetHash(into hasher: inout Hasher) }
In the final version, the widget will look like this:
struct Widget: WidgetIdentifiable { let underlying: WidgetHashable & WidgetPresentable init(_ underlying: WidgetHashable & WidgetPresentable) { self.underlying = underlying } } extension Widget: Hashable { func hash(into hasher: inout Hasher) { self.underlying.widgetHash(into: &hasher) } static func ==(lhs: Widget, rhs: Widget) -> Bool { return lhs.underlying.widgetEqual(rhs.underlying) } } extension Widget: WidgetPresentable { func widgetCell(collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionViewCell { return underlying.widgetCell(collectionView: collectionView, indexPath: indexPath) } func widgetSize(containerWidth: CGFloat) -> CGSize { return underlying.widgetSize(containerWidth: containerWidth) } }
Let's try to collect the simplest primitive, which will be an indent of a given height.
struct Spacing: Hashable { let height: CGFloat } class SpacingView: UIView { lazy var constraint = self.heightAnchor.constraint(equalToConstant: 1) init() { super.init(frame: .zero) self.constraint.isActive = true } } extension Spacing: WidgetHashable { func widgetEqual(_ any: Any) -> Bool { if let spacing = any as? Spacing { return self == spacing } return false } func widgetHash(into hasher: inout Hasher) { self.hash(into: &hasher) } } extension Spacing: WidgetPresentable { func widgetCell(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 } func widgetSize(containerWidth: CGFloat) -> CGSize { return CGSize(width: containerWidth, height: height) } }
WidgetCell<T>
is just a subclass of UICollectionViewCell
that accepts a UIView
and adds it as a subview. cellDequeueSafely(indexPath:)
is a function that registers a cell in the collection before being reused if the cell has not previously been registered in the collection. Spacing
will be used as described at the very beginning of the publication.
After receiving the array of widgets, it will only zabindit observerWidgets
:
typealias DataSource = RxCollectionViewSectionedAnimatedDataSource<WidgetSection> class Controller: UIViewController { private lazy var dataSource: DataSource = self.makeDataSource() var observerWidgets: (Observable<Widgets>) -> Disposable { return collectionView.rx.items(dataSource: dataSource) } func makeDataSource() -> DataSource { return DataSource(configureCell: { (_, collectionView: UICollectionView, indexPath: IndexPath, widget: Widget) in let cell = widget.widgetCell(collectionView: collectionView, indexPath: indexPath) return cell }) } }
In conclusion, I would like to show the real work of the collection, which is built entirely on widgets.
As you can see, the decomposition of UICollectionViewCell
feasible and, in appropriate situations, can simplify the life of the developer.
The code in the publication is very simplified and should not be collected. The goal was to describe the approach, rather than provide a ready-made solution.
The WidgetPresentable
protocol can be extended with other functions allowing optimization of the layout, for example, widgetSizeEstimated(containerWidth:)
or widgetSizePredefined(containerWidth:)
, which return the estimated and fixed size, respectively. It is worth noting that the widgetSize(containerWidth:)
function widgetSize(containerWidth:)
must return the size of the primitive even for demanding calculations, for example, for systemLayoutSizeFitting(_:)
. Such calculations can be cached through a Dictionary
, NSCache
, etc.
As you know, all cell types used by the UICollectionView
must be registered in the collection beforehand. However, in order to re-use widgets between different screens / collections and not to register in advance all cell identifiers / types, you need to acquire a mechanism that will register the cell immediately before its first use within each collection. The publication used the cellDequeueSafely(indexPath:)
function for this.
In the collection there can be no headers or footers. In their place will be primitives. The presence of the 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 the cells. In our case, data for auxiliary views can also be added to the array of widgets and drawn as primitives.
Within the same collection can be widgets with the same objects. For example, the same Spacing
at 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 will disappear. To make such objects unique, you can use special AnyHashable
tags, #file
and #line
places to create an object, etc.
Source: https://habr.com/ru/post/455421/
All Articles