Nachdem ich Keynote WWDC 2019 gesehen und SwiftUI kennengelernt habe , mit dem Benutzeroberflächen deklarativ im Code beschrieben werden sollen, möchte ich darüber spekulieren, wie Platten und Sammlungen deklarativ ausgefüllt werden können. Zum Beispiel so:
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 der Sammlung wird dies wie folgt gerendert:
Einführung
Wie Sie aus maßgeblichen Quellen wissen: Die meiste Zeit verbringt ein typischer iOS-Entwickler die Arbeit mit Tablets. Wenn wir davon ausgehen, dass die Entwickler des Projekts sehr fehlen und die Tablets nicht einfach sind, bleibt keine Zeit für den Rest der Anwendung. Und damit muss etwas getan werden ... Eine mögliche Lösung wäre die Zellzersetzung.
Zellzerlegung bedeutet, eine Zelle durch mehrere kleinere Zellen zu ersetzen. Mit diesem Ersatz sollte sich optisch nichts ändern. Betrachten Sie als Beispiel Beiträge aus dem VK-Newsfeed für iOS. Ein Beitrag kann sowohl als einzelne Zelle als auch als Gruppe von Zellen - Primitiven - dargestellt werden.
Das Zerlegen von Zellen funktioniert nicht immer. Es wird schwierig sein, eine Zelle in Grundelemente zu zerlegen, die auf allen Seiten einen Schatten oder eine Rundung aufweisen. In diesem Fall ist die ursprüngliche Zelle ein Grundelement.
Vorteile und Nachteile
Durch die Zerlegung von Zellen bestehen Tabellen / Sammlungen aus Grundelementen, die häufig wiederverwendet werden: einem Grundelement mit Text, einem Grundelement mit Bild, einem Grundelement mit Hintergrund usw. Die Berechnung der Höhe eines einzelnen Grundelements ist viel einfacher und effektiver als eine komplexe Zelle mit einer großen Anzahl von Zuständen. Falls gewünscht, kann die dynamische Höhe des CTFramesetter
berechnet oder sogar im Hintergrund gezeichnet werden (z. B. Text über CTFramesetter
).
Andererseits wird das Arbeiten mit Daten komplizierter. Für jedes IndexPath
Daten benötigt, und gemäß dem IndexPath
es schwierig zu bestimmen, zu welcher realen Zelle es gehört. Wir müssen neue Abstraktionsebenen einführen oder diese Probleme irgendwie lösen.
Sie können lange über die möglichen Vor- und Nachteile dieses Unternehmens sprechen, aber es ist besser, den Ansatz zur Zellzersetzung zu beschreiben.
Werkzeuge auswählen
Da UITableView
in seinen Funktionen begrenzt ist und wir, wie bereits erwähnt, ziemlich komplexe Platten haben, wäre die Verwendung von UICollectionView
eine geeignete Lösung. Es geht um UICollectionView
, das in dieser Veröffentlichung behandelt wird.
Bei Verwendung von UICollectionView
mit einer Situation konfrontiert, in der das Basis- UICollectionViewFlowLayout
nicht die gewünschte Anordnung der Elemente der Sammlung bilden kann (das neue UICollectionViewCompositionalLayout
nicht berücksichtigt). In solchen Momenten wird normalerweise die Entscheidung getroffen, ein Open-Source- UICollectionViewLayout
. Aber selbst unter vorgefertigten Lösungen gibt es möglicherweise keine geeignete, wie zum Beispiel auf der dynamischen Hauptseite eines großen Online-Shops oder eines sozialen Netzwerks. Wir gehen vom Schlimmsten aus und erstellen unser eigenes universelles UICollectionViewLayout
.
Zusätzlich zu den Schwierigkeiten bei der Auswahl eines Layouts müssen Sie entscheiden, wie die Sammlung Daten empfangen soll. Zusätzlich zu dem üblichen Ansatz, bei dem ein Objekt (meistens ein UIViewController
) dem UICollectionViewDataSource
Protokoll entspricht und Daten für eine Sammlung bereitstellt, wird die Verwendung datengesteuerter Frameworks immer beliebter. Helle Vertreter dieses Ansatzes sind CollectionKit , IGListKit , RxDataSources und andere. Die Verwendung solcher Frameworks vereinfacht die Arbeit mit Sammlungen und bietet die Möglichkeit, Datenänderungen zu animieren, weil Der unterschiedliche Algorithmus ist bereits im Framework vorhanden. Für Veröffentlichungszwecke wird das RxDataSources- Framework ausgewählt.
Widget und seine Eigenschaften
Wir führen eine Zwischendatenstruktur ein und nennen sie ein Widget . Wir beschreiben die Haupteigenschaften, die ein Widget haben sollte:
- Das Widget muss den erforderlichen Protokollen für die Verwendung des datengesteuerten Frameworks entsprechen. Solche Protokolle enthalten typischerweise einen zugeordneten Wert (z. B.
IdentifiableType
Typ in RxDataSources ). - Es sollte möglich sein, Widgets für verschiedene Grundelemente zu einem Array zusammenzufassen. Um dies zu erreichen, sollte dem Widget keine Werte zugeordnet sein. Für diese Zwecke können Sie den Mechanismus der Typlöschung oder ähnliches verwenden.
- Das Widget sollte in der Lage sein, die Größe des Grundelements zu zählen. Beim Bilden des
UICollectionViewLayout
müssen dann nur noch die UICollectionViewLayout
gemäß den vordefinierten Regeln korrekt positioniert werden. - Das Widget muss die Factory für die
UICollectionViewCell
. Daher wird bei der Implementierung von UICollectionViewDataSource
gesamte Logik zum Erstellen von Zellen entfernt und alles, was übrig bleibt, ist:
let cell = widget.widgetCell(collectionView: collectionView, indexPath: indexPath) return cell
Widget-Implementierung
Um das Widget mit dem RxDataSources- Framework verwenden zu können, muss es den Protokollen Equatable
und IdentifiableType
. Da das Widget ein Grundelement darstellt, ist es für Veröffentlichungszwecke ausreichend, wenn sich das Widget identifiziert, um dem IdentifiableType
Protokoll zu entsprechen. In der Praxis wirkt sich dies auf die Tatsache aus, dass das Primitiv beim Ändern des Widgets nicht neu geladen, sondern gelöscht und eingefügt wird. WidgetIdentifiable
Sie dazu das neue WidgetIdentifiable
Protokoll ein:
protocol WidgetIdentifiable: IdentifiableType { } extension WidgetIdentifiable { var identity: Self { return self } }
Um dem WidgetIdentifiable
zu entsprechen, WidgetIdentifiable
das Widget dem Hashable
Protokoll entsprechen. Hashable
Widget übernimmt Daten zur Einhaltung des Protokolls von dem Objekt, das ein bestimmtes Hashable
beschreibt. Mit AnyHashable
können Sie das Objekttyp-Widget "löschen".
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 } }
In dieser Phase werden die ersten beiden Eigenschaften des Widgets ausgeführt. Dies ist nicht schwer zu überprüfen, indem mehrere Widgets mit unterschiedlichen Objekttypen in einem Array gesammelt werden.
let widgets = [Widget("Hello world"), Widget(100500)]
Um die verbleibenden Eigenschaften zu implementieren, führen wir ein neues Protokoll WidgetPresentable
protocol WidgetPresentable { func widgetCell(collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionViewCell func widgetSize(containerWidth: CGFloat) -> CGSize }
Die Funktion widgetSize(containerWidth:)
wird im UICollectionViewLayout
beim Generieren der Attribute der Zellen und widgetCell(collectionView:indexPath:)
, um die Zellen widgetCell(collectionView:indexPath:)
.
Wenn das Widget WidgetPresentable
Protokoll WidgetPresentable
das Widget alle zu Beginn der Veröffentlichung angegebenen Eigenschaften. Das im Widget AnyHashable enthaltene Objekt muss jedoch durch die Komposition WidgetPresentable
und WidgetHashable
, wobei WidgetHashable
keinen zugeordneten Wert hat (wie im Fall von Hashable
) und der Typ des Objekts im Widget "gelöscht" bleibt:
protocol WidgetHashable { func widgetEqual(_ any: Any) -> Bool func widgetHash(into hasher: inout Hasher) }
In der endgültigen Version sieht das Widget folgendermaßen aus:
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) } }
Primitives Objekt
Versuchen wir, das einfachste Grundelement zusammenzusetzen, das die Einrückung einer bestimmten Höhe ist.
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>
ist nur eine Unterklasse von UICollectionViewCell
, die eine UIView
akzeptiert und als UIView
hinzufügt. cellDequeueSafely(indexPath:)
ist eine Funktion, die eine Zelle in einer Sammlung vor der Wiederverwendung registriert, wenn eine Zelle in der Sammlung zuvor nicht registriert wurde. Spacing
wird wie zu Beginn der Veröffentlichung beschrieben verwendet.
Nach dem Empfang des Array von Widgets bleibt nur die Bindung an 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 }) } }
Ergebnisse
Abschließend möchte ich die eigentliche Arbeit der Sammlung zeigen, die vollständig auf Widgets basiert.
Wie Sie sehen können, ist die Zerlegung von UICollectionViewCell
möglich und kann in geeigneten Situationen das Leben des Entwicklers vereinfachen.
Bemerkungen
Der in der Veröffentlichung angegebene Code ist sehr vereinfacht und sollte nicht kompiliert werden. Ziel war es, den Ansatz zu beschreiben und keine schlüsselfertige Lösung bereitzustellen.
Das WidgetPresentable
Protokoll kann um andere Funktionen erweitert werden, die das Layout optimieren, z. B. widgetSizeEstimated(containerWidth:)
oder widgetSizePredefined(containerWidth:)
, die die geschätzte bzw. feste Größe zurückgeben. Es ist erwähnenswert, dass die Funktion widgetSize(containerWidth:)
die Größe des systemLayoutSizeFitting(_:)
auch für anspruchsvolle Berechnungen zurückgeben sollte, z. B. für systemLayoutSizeFitting(_:)
. Solche Berechnungen können über Dictionary
, NSCache
usw. zwischengespeichert werden.
Wie Sie wissen, müssen alle von UICollectionView
verwendeten UICollectionView
in der Sammlung UICollectionView
sein. Um jedoch Widgets zwischen verschiedenen Bildschirmen / Sammlungen wiederzuverwenden und nicht alle Zellkennungen / -typen im Voraus zu registrieren, müssen Sie einen Mechanismus erwerben, der eine Zelle unmittelbar vor ihrer ersten Verwendung in jeder Sammlung registriert. In der Publikation wurde hierfür die Funktion cellDequeueSafely(indexPath:)
verwendet.
Die Sammlung darf keine Kopf- oder Fußzeilen enthalten. An ihrer Stelle werden Primitive stehen. Das Vorhandensein von Ergänzungsmitteln in der Sammlung bietet im aktuellen Ansatz keine besonderen Boni. Normalerweise werden sie verwendet, wenn das Datenarray genau der Anzahl der Zellen entspricht und Sie zusätzliche Ansichten vor, nach oder zwischen Zellen anzeigen möchten. In unserem Fall können Daten für Hilfsansichten auch zum Widget-Array hinzugefügt und als Grundelemente gezeichnet werden.
Innerhalb derselben Sammlung können Widgets mit denselben Objekten gefunden werden. Zum Beispiel der gleiche Spacing
am Anfang und am Ende der Sammlung. Das Vorhandensein solcher nicht eindeutiger Objekte führt dazu, dass die Animation in der Sammlung verschwindet. Um solche Objekte einzigartig zu machen, können Sie spezielle AnyHashable
Tags, #file
und #line
denen das Objekt erstellt wurde usw.