Après avoir regardé Keynote WWDC 2019 et appris à connaître SwiftUI , qui est conçu pour décrire de manière déclarative les interfaces utilisateur dans le code, je veux spéculer sur la façon de remplir de manière déclarative des plaques et des collections. Par exemple, comme ceci:
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)
Dans la collection, ceci est rendu comme suit:
Présentation
Comme vous le savez de sources faisant autorité : la grande majorité de leur temps, un développeur iOS typique passe à travailler avec des tablettes. Si nous supposons que les développeurs du projet manquent cruellement et que les tablettes ne sont pas simples, alors il ne reste absolument plus de temps pour le reste de l'application. Et quelque chose doit être fait avec cela ... Une solution possible serait la décomposition des cellules.
La décomposition cellulaire signifie remplacer une cellule par plusieurs cellules plus petites. Avec ce remplacement, visuellement, rien ne devrait changer. Par exemple, considérons les publications du flux d'actualités VK pour iOS. Un message peut être présenté à la fois comme une seule cellule ou comme un groupe de cellules - les primitives .
Les cellules en décomposition ne fonctionneront pas toujours. Il sera difficile de diviser une cellule en primitives qui ont une ombre ou un arrondi de tous les côtés. Dans ce cas, la cellule d'origine sera une primitive.
Avantages et inconvénients
En utilisant la décomposition des cellules, les tableaux / collections commencent à se composer de primitives qui seront souvent réutilisées: une primitive avec du texte, une primitive avec une image, une primitive avec un fond, etc. Le calcul de la hauteur d'une primitive unique est beaucoup plus simple et plus efficace qu'une cellule complexe avec un grand nombre d'états. Si vous le souhaitez, la hauteur dynamique de la primitive peut être calculée ou même dessinée en arrière-plan (par exemple, du texte via CTFramesetter
).
D'un autre côté, travailler avec des données devient plus compliqué. Des données seront nécessaires pour chaque primitive, et selon l' IndexPath
primitive, il sera difficile de déterminer à quelle cellule réelle elle appartient. Nous devrons introduire de nouvelles couches d'abstraction ou résoudre en quelque sorte ces problèmes.
Vous pouvez parler longtemps des avantages et des inconvénients possibles de cette entreprise, mais il vaut mieux essayer de décrire l'approche de la décomposition cellulaire.
Choisir des outils
Étant donné que UITableView
limité dans ses capacités et, comme déjà mentionné, nous avons des plaques plutôt complexes, une solution appropriée serait d'utiliser UICollectionView
. Il s'agit d' UICollectionView
qui sera abordé dans cette publication.
En utilisant UICollectionView
confronté à une situation où la base UICollectionViewFlowLayout
ne peut pas former la disposition souhaitée des éléments de la collection (nous ne prenons pas en compte la nouvelle UICollectionViewCompositionalLayout
). À de tels moments, la décision est généralement prise de trouver un UICollectionViewLayout
open source. Mais même parmi les solutions toutes faites, il peut ne pas y avoir de solution appropriée, comme, par exemple, dans le cas de la page principale dynamique d'une grande boutique en ligne ou d'un réseau social. Nous supposons le pire, nous allons donc créer notre propre UICollectionViewLayout
universel.
En plus de la difficulté de choisir une mise en page, vous devez décider comment la collection recevra les données. En plus de l'approche habituelle, lorsqu'un objet (le plus souvent un UIViewController
) est conforme au protocole UICollectionViewDataSource
et fournit des données pour une collection, l'utilisation de frameworks pilotés par les données gagne en popularité. Les brillants représentants de cette approche sont CollectionKit , IGListKit , RxDataSources et autres. L'utilisation de tels cadres simplifie le travail avec les collections et offre la possibilité d'animer les modifications de données, car L'algorithme différent est déjà présent dans le framework. À des fins de publication, le cadre RxDataSources sera sélectionné.
Widget et ses propriétés
Nous introduisons une structure de données intermédiaire et appelons cela un widget . Nous décrivons les principales propriétés qu'un widget devrait avoir:
- Le widget doit respecter les protocoles nécessaires pour utiliser le framework piloté par les données. Ces protocoles contiennent généralement une valeur associée (par exemple,
IdentifiableType
dans RxDataSources ) - Il devrait être possible d'assembler des widgets pour différentes primitives dans un tableau. Pour ce faire, le widget ne doit pas avoir de valeurs associées. À ces fins, vous pouvez utiliser le mécanisme d'effacement de type ou quelque chose comme ça.
- Le widget doit pouvoir compter la taille de la primitive. Ensuite, lors de la formation de l'
UICollectionViewLayout
, il ne reste plus qu'à positionner correctement les primitives selon des règles prédéfinies. - Le widget doit être la fabrique de l'
UICollectionViewCell
. Par conséquent, à partir de l'implémentation de UICollectionViewDataSource
toute la logique de création de cellules sera supprimée et il ne restera plus que:
let cell = widget.widgetCell(collectionView: collectionView, indexPath: indexPath) return cell
Implémentation du widget
Afin de pouvoir utiliser le widget avec le framework RxDataSources , il doit respecter les protocoles Equatable
et IdentifiableType
. Étant donné que le widget représente une primitive, il suffira à des fins de publication si le widget s'identifie pour se conformer au protocole IdentifiableType
. En pratique, cela affectera le fait que lorsque le widget change, la primitive ne sera pas rechargée, mais supprimée et insérée. Pour ce faire, introduisez le nouveau protocole WidgetIdentifiable
:
protocol WidgetIdentifiable: IdentifiableType { } extension WidgetIdentifiable { var identity: Self { return self } }
Pour se conformer au WidgetIdentifiable
, le widget doit se conformer au protocole Hashable
. Hashable
widget Hashable
prendra les données pour la conformité au protocole de l'objet qui décrira une primitive particulière. Vous pouvez utiliser AnyHashable
pour "effacer" le widget de type d'objet.
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 } }
À ce stade, les deux premières propriétés du widget sont exécutées. Ce n'est pas difficile à vérifier en collectant plusieurs widgets avec différents types d'objets dans un tableau.
let widgets = [Widget("Hello world"), Widget(100500)]
Pour implémenter les propriétés restantes, nous introduisons un nouveau protocole WidgetPresentable
protocol WidgetPresentable { func widgetCell(collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionViewCell func widgetSize(containerWidth: CGFloat) -> CGSize }
La fonction widgetSize(containerWidth:)
sera utilisée dans UICollectionViewLayout
lors de la génération des attributs des cellules, et widgetCell(collectionView:indexPath:)
- pour obtenir les cellules.
Si le widget WidgetPresentable
protocole WidgetPresentable
, le widget remplira toutes les propriétés indiquées au début de la publication. Cependant, l'objet contenu dans le widget AnyHashable devra être remplacé par la composition WidgetPresentable
et WidgetHashable
, où WidgetHashable
n'aura pas de valeur associée (comme dans le cas de Hashable
) et le type de l'objet à l'intérieur du widget restera "effacé":
protocol WidgetHashable { func widgetEqual(_ any: Any) -> Bool func widgetHash(into hasher: inout Hasher) }
Dans la version finale, le widget ressemblera à ceci:
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) } }
Objet primitif
Essayons d'assembler la primitive la plus simple, qui sera l'indentation d'une hauteur donnée.
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>
n'est qu'une sous-classe de UICollectionViewCell
qui accepte une UIView
et l'ajoute en tant que sous-vue. cellDequeueSafely(indexPath:)
est une fonction qui enregistre une cellule dans une collection avant réutilisation si une cellule de la collection n'a pas été précédemment enregistrée. Spacing
sera utilisé comme décrit au tout début de la publication.
Après avoir reçu le tableau de widgets, il ne reste plus qu'à se lier à 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 }) } }
Résultats
En conclusion, je voudrais montrer le vrai travail de la collection, qui est entièrement construite sur des widgets.
Comme vous pouvez le voir, la décomposition de UICollectionViewCell
possible et dans des situations appropriées peut simplifier la vie du développeur.
Remarques
Le code donné dans la publication est très simplifié et ne doit pas être compilé. Le but était de décrire l'approche, pas de fournir une solution clé en main.
Le protocole WidgetPresentable
peut être étendu avec d'autres fonctions qui optimisent la disposition, par exemple, widgetSizeEstimated(containerWidth:)
ou widgetSizePredefined(containerWidth:)
, qui renvoient respectivement la taille estimée et fixe. Il convient de noter que la fonction widgetSize(containerWidth:)
doit renvoyer la taille de la primitive même pour des calculs exigeants, par exemple, pour systemLayoutSizeFitting(_:)
. Ces calculs peuvent être mis en cache via Dictionary
, NSCache
, etc.
Comme vous le savez, tous les types de cellules utilisés par UICollectionView
doivent être pré-enregistrés dans la collection. Cependant, afin de réutiliser des widgets entre différents écrans / collections et de ne pas enregistrer tous les identifiants / types de cellules à l'avance, vous devez acquérir un mécanisme qui enregistrera une cellule immédiatement avant sa première utilisation dans chaque collection. Dans la publication, la fonction cellDequeueSafely(indexPath:)
utilisée pour cela.
Il ne peut y avoir aucun en-tête ou pied de page dans la collection. À leur place seront des primitifs. La présence de supplément dans la collection ne donnera aucun bonus spécial dans l'approche actuelle. Ils sont généralement utilisés lorsque le tableau de données correspond strictement au nombre de cellules et qu'il est nécessaire d'afficher des vues supplémentaires avant, après ou entre les cellules. Dans notre cas, les données des vues auxiliaires peuvent également être ajoutées au tableau de widgets et dessinées en tant que primitives.
Dans la même collection, des widgets avec les mêmes objets peuvent être localisés. Par exemple, le même Spacing
au début et à la fin de la collection. La présence de tels objets non uniques entraînera la disparition de l'animation dans la collection. Pour rendre ces objets uniques, vous pouvez utiliser des balises AnyHashable
spéciales, #file
et #line
où l'objet a été créé, etc.