前書き
初めまして、ラクマのTsurutaです。
ラクマのアプリでは「保存した検索条件」の一覧が、ホームのタブから見られるようになったことをご存知でしょうか?
この画面を構成するのにあたり、ラクマではiOS12以下のサポートを行っていないため、iOSの新しいAPIである Compositional Layouts を使って構成することにしました。
実装
① iOSアプリの構成
現状ラクマのアプリは MVP
をベースとした構成になっています。
UICollectionView
を実装する場合、Delegate
や DataSource
の実装が ViewController
に依存してしまう問題があります。
これは Compositional Layouts
を普通に実装した場合でも同じ問題に直面します。
そのため、ラクマのiOSでは以下のような構成で、疎結合な実装を実現しました。
抽象的なプロトコルとして書き出して共通化し、ViewController
に依存しないようにしています。
② レイアウトの抽象化
レイアウトに対して、以下のような粒度で抽象化します。
セクションに共通する処理を整理して抽象しています。 例として、抽象化するとこのようになります。
protocol SectionProtocol { // セクションのアイテム数 var numberOfItems: Int { get } // レイアウトの生成 func layoutSection(_ view: UIView) -> NSCollectionLayoutSection // セルの生成 func configureCell(_ view: UICollectionView, at indexPath: IndexPath) -> UICollectionViewCell }
これはただ抽象化しただけではなく、画面を構成するモデルの役割もはたします。
このプロトコルを各セクションごとに継承し、セクションごとに設定を記載していきます。
struct SectionA: SectionProtocol { let numberOfItems = 1 func layoutSection(_ view: UIView) -> NSCollectionLayoutSection { /* 略 */ return UICollectionViewCompositionalLayout(section: section) } func configureCell(_ view: UICollectionView, at indexPath: IndexPath) -> UICollectionViewCell { let cell = view.dequeueReusableCell( withReuseIdentifier: "your cell id", for: indexPath ) as! SectionACell /* 略 */ return cell } }
各セクションを別で定義することで、ViewController
からレイアウト部分を、別クラスとして分離することができます。
③ MVPでの実装
別クラスとして分離したので、ViewController
と Presenter
はこのようにすっきりとした形になります。
protocol Presentable: AnyObject { var sections: [SectionProtocol] { get } } final class Presenter: Presentable { private var sections: [SectionProtocol] }
final class ViewController: UIViewController { private(set) var presenter: Presentable! private lazy var collectionView: UICollectionView = { let collection = UICollectionView( frame: .zero, collectionViewLayout: compositionalLayout ) /* ~ 略 ~ */ return collection }() private lazy var compositionalLayout: UICollectionViewLayout = { return UICollectionViewCompositionalLayout { [weak self] section, _ in return self?.sections[section].layoutSection(self ?? .init()) } }() }
また、抽象化した他のプロパティやメソッドは、以下のように呼び出すことができます。
extension ViewController: UICollectionViewDataSource { func numberOfSections(in collectionView: UICollectionView) -> Int { presenter.sections.count } func collectionView( _ collectionView: UICollectionView, numberOfItemsInSection section: Int ) -> Int { presenter.sections[section].numberOfItems } func collectionView( _ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath ) -> UICollectionViewCell { presenter.sections[indexPath.section].configureCell(collectionView, at: indexPath) } }
抽象化されているため、呼び出しが集約されてとても綺麗な実装になっています。
詳しい実装は以下に掲載してありますので、興味があればご覧になっていただけると幸いです。
Compositional Layouts の不具合
今回の実装において苦労したこともあり、Compositional Layouts
でしか発生しない不具合に遭遇しました。
その1つとして、
iPad
特定の条件下
画面回転
この時に、アイテムが全て消えてしまうという現象がありました。
既存の UICollectionView
のレイアウト実装では発生せず、検索しても出てこないため、対応方針をどうしようか悩みました。
結論としては、画面回転時に画面をリセットしてから再描画することで対応することにしました。
ただ再描画するだけでは解決せず、リセットする(UICollectionView
が参照している Section
全てを空にしてリロードする)作業を行うことが、この問題を解決することのポイントになります。
ただし、この解決方法は、再描画するため回転前のポジションを維持できないという弊害もあります。内部的にポジションを保持して引き継ぐことも可能ですが、特定の条件下ということもあり、対応コストとメンテナンス性を考えて、ポジションの維持は行わない決断となりました。
終わりに
徐々に導入しているプロジェクトが増えてはきているものの、まだそんなに母数が多くないため、Compositional Layouts
のベストプラクティスな構成を見つけるのは難しいかもしれません。
また、SwiftUI
も台頭してきており、今後のことを見据えて疎結合な実装で分離しておくことは、プロジェクトにおいてメンテナンス性の高い実装になると思います。
その他
ラクマでは、User Firstをコア・バリューの一つに掲げ、一緒にアプリ開発をしてくれるメンバーを募集しています。
カジュアルな面談から、是非お待ちしております!