paint-brush
プロトコル指向設計と UICollectionViewCompositionalLayout を使用してスクロール可能なリストを作成する方法@bugorbn
701 測定値
701 測定値

プロトコル指向設計と UICollectionViewCompositionalLayout を使用してスクロール可能なリストを作成する方法

Boris Bugor19m2024/06/18
Read on Terminal Reader

長すぎる; 読むには

このアプローチの動機は非常に単純で、ユニバーサル ツールを作成することで定型コードの量を削減したいというものです。この問題は 4 つの段階で解決します。スクロール要素のデータ型の抽象化の作成、スクロール可能な要素の基本クラスの作成、リストの実装の作成、リストの実装の作成です。
featured image - プロトコル指向設計と UICollectionViewCompositionalLayout を使用してスクロール可能なリストを作成する方法
Boris Bugor HackerNoon profile picture
0-item

この記事は、大規模なコード ベースを持つプロジェクトをスケーリングするときにプロトコル指向のアプローチを使用する方法に関するシリーズの続きです。


もしまだ読んでいないなら前のこの記事を読んで、この記事で紹介されているアプローチと結論をよく理解しておくことを強くお勧めします。簡単に言うと、 UICollectionViewFlowLayoutに基づいてスクロール リストを使用するためのコンストラクターを作成できるユニバーサル クラスの作成について検討しました。


このアプローチの動機は非常に単純で、ルーチンの量を減らし、同時に柔軟性を失わないユニバーサルツールを作成することで、定型コードの量を減らしたいということです。


この記事では、同様のタスクを引き続き検討します。 UIコレクションビュー構成レイアウトiOS 13 以降でサポートされている を試して、このフレームワークがどのようなニュアンスをもたらすかを確認してください。


以前と同様に、この問題を 4 つの段階で解決します。


  1. スクロール要素のデータ型の抽象化を記述します。
  2. スクロール可能な要素の基本クラスを記述する。
  3. リストの実装を書く;
  4. ユースケース


1. 抽象的なスクロール要素

抽象化の作成は、間違いなく設計の最も重要な段階です。スケーリング可能なシステムの基盤を築くには、スクロール要素の質的および量的特性を抽象化する必要があります。また、同じタイプのレイアウトの要件に準拠することも重要です。


このような概念をセクションとして紹介しましょう。セクションとは、同じレイアウトを持つ 1 つ以上の要素です。


スクロール可能な要素の抽象化としてセクションを使用します。

 protocol BaseSection { var numberOfElements: Int { get } func registrate(collectionView: UICollectionView) func cell(for collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionViewCell func header(for collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionReusableView func footer(for collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionReusableView func section() -> NSCollectionLayoutSection func select(row: Int) }


レイアウトの設定の責任はセクションに移管します。ヘッダーやフッターなどの補助ビューの存在もそこで決定されます。


2. スクロールリスト

基本クラスはスクロール可能なリストとして使用されます。基本クラスのタスクは、BaseSection の抽象データを取得してレンダリングすることです。この場合、 UICollectionViewUICollectionViewCompositionalFlowLayoutが視覚化ツールとして使用されます。


 class SectionView: UIView { override init(frame: CGRect) { super.init(frame: frame) commonInit() } required init?(coder: NSCoder) { super.init(coder: coder) commonInit() } private func commonInit() { collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] collectionView.frame = bounds addSubview(collectionView) } private(set) lazy var flowLayout: UICollectionViewCompositionalLayout = { let layout = UICollectionViewCompositionalLayout { [weak self] index, env in self?.sections[index].section() } return layout }() private(set) lazy var collectionView: UICollectionView = { let collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout) collectionView.backgroundColor = .clear collectionView.delegate = self collectionView.dataSource = self return collectionView }() private var sections: [BaseSection] = [] public func set(sections: [BaseSection], append: Bool) { sections.forEach { $0.registrate(collectionView: collectionView) } if append { self.sections.append(contentsOf: sections) } else { self.sections = sections } collectionView.reloadData() } public func set(contentInset: UIEdgeInsets) { collectionView.contentInset = contentInset } } extension SectionView: UICollectionViewDataSource { func numberOfSections(in collectionView: UICollectionView) -> Int { sections.count } func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { sections[section].numberOfElements } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { sections[indexPath.section].cell(for: collectionView, indexPath: indexPath) } func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { kind == UICollectionView.elementKindSectionHeader ? sections[indexPath.section].header(for: collectionView, indexPath: indexPath) : sections[indexPath.section].footer(for: collectionView, indexPath: indexPath) } } extension SectionView: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { sections[indexPath.section].select(row: indexPath.row) } }


UICollectionViewCompositionalLayoutUICollectionViewFlowLayoutを使用する場合と比較して、セル、ヘッダー、フッターのレイアウト構成をデリゲート メソッドからレイアウト本体に転送できます。

3. スクロール可能な要素の実装

フッターとヘッダーを表示する機能を含むセクションが抽象化として採用されたという事実に基づいて、実装クラスでもこれを考慮する必要があります。


この場合、セルの要件は次のようになります。

 protocol SectionCell: UICollectionViewCell { associatedtype CellData: SectionCellData func setup(with data: CellData) -> Self static func groupSize() -> NSCollectionLayoutGroup } protocol SectionCellData { var onSelect: VoidClosure? { get } } typealias VoidClosure = () -> Void


セル サイズの構成をセルの責任領域に移動し、任意のセルをタップしてアクションを受信する可能性も考慮します。


ヘッダーとフッターの要件は次のようになります。

 protocol SectionHeader: UICollectionReusableView { associatedtype HeaderData func setup(with data: HeaderData?) -> Self static func headerItem() -> NSCollectionLayoutBoundarySupplementaryItem? } protocol SectionFooter: UICollectionReusableView { associatedtype FooterData func setup(with data: FooterData?) -> Self static func footerItem() -> NSCollectionLayoutBoundarySupplementaryItem? }


スクロール要素の要件に基づいて、セクションの実装を設計できます。

 class Section<Cell: SectionCell, Header: SectionHeader, Footer: SectionFooter>: BaseSection { init(items: [Cell.CellData], headerData: Header.HeaderData? = nil, footerData: Footer.FooterData? = nil) { self.items = items self.headerData = headerData self.footerData = footerData } private(set) var items: [Cell.CellData] private let headerData: Header.HeaderData? private let footerData: Footer.FooterData? var numberOfElements: Int { items.count } func registrate(collectionView: UICollectionView) { collectionView.register(Cell.self) collectionView.registerHeader(Header.self) collectionView.registerFooter(Footer.self) } func cell(for collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionViewCell { collectionView .dequeue(Cell.self, indexPath: indexPath)? .setup(with: items[indexPath.row]) ?? UICollectionViewCell() } func header(for collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionReusableView { collectionView .dequeueHeader(Header.self, indexPath: indexPath)? .setup(with: headerData) ?? UICollectionReusableView() } func footer(for collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionReusableView { collectionView .dequeueFooter(Footer.self, indexPath: indexPath)? .setup(with: footerData) ?? UICollectionReusableView() } func section() -> NSCollectionLayoutSection { let section = NSCollectionLayoutSection(group: Cell.groupSize()) if let headerItem = Header.headerItem() { section.boundarySupplementaryItems.append(headerItem) } if let footerItem = Footer.footerItem() { section.boundarySupplementaryItems.append(footerItem) } return section } func select(row: Int) { items[row].onSelect?() } }


要件を実装するジェネリックは、セル、ヘッダー、またはフッター タイプとして機能します。


一般的に、実装は完了していますが、定型コードの量をさらに削減するヘルパーをいくつか追加したいと思います。特に、実際には、フッターやヘッダーが常に使用されるわけではないという単純な理由から、このような汎用セクションを用意することが常に役立つとは限りません。


ここで、同様のケースを考慮したセクションを追加しましょう。

 class SectionWithoutHeaderFooter<Cell: SectionCell>: Section<Cell, EmptySectionHeader, EmptySectionFooter> {} class EmptySectionHeader: UICollectionReusableView, SectionHeader { func setup(with data: String?) -> Self { self } static func headerItem() -> NSCollectionLayoutBoundarySupplementaryItem? { nil } } class EmptySectionHeader: UICollectionReusableView, SectionHeader { func setup(with data: String?) -> Self { self } static func headerItem() -> NSCollectionLayoutBoundarySupplementaryItem? { nil } }


これで設計は完了したとみなせるので、ユースケース自体に進むことを提案します。

4. ユースケース

固定サイズのセルセクションを作成し、画面に表示してみましょう。

 class ColorCollectionCell: UICollectionViewCell, SectionCell { func setup(with data: ColorCollectionCellData) -> Self { contentView.backgroundColor = data.color return self } static func groupSize() -> NSCollectionLayoutGroup { let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(0.5)) let item = NSCollectionLayoutItem(layoutSize: itemSize) let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitem: item, count: 2) group.interItemSpacing = .fixed(16) return group } } class ColorCollectionCellData: SectionCellData { let onSelect: VoidClosure? let color: UIColor init(color: UIColor, onSelect: VoidClosure? = nil) { self.onSelect = onSelect self.color = color } }


ヘッダーとフッターの実装を作成しましょう。

 class DefaultSectionHeader: UICollectionReusableView, SectionHeader { let textLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 32, weight: .bold) return label }() override init(frame: CGRect) { super.init(frame: frame) commonInit() } required init?(coder: NSCoder) { super.init(coder: coder) commonInit() } private func commonInit() { addSubview(textLabel) textLabel.numberOfLines = .zero textLabel.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ textLabel.topAnchor.constraint(equalTo: topAnchor), textLabel.bottomAnchor.constraint(equalTo: bottomAnchor), textLabel.leftAnchor.constraint(equalTo: leftAnchor), textLabel.rightAnchor.constraint(equalTo: rightAnchor) ]) } func setup(with data: String?) -> Self { textLabel.text = data return self } static func headerItem() -> NSCollectionLayoutBoundarySupplementaryItem? { let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(20)) let header = NSCollectionLayoutBoundarySupplementaryItem( layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top, absoluteOffset: .zero ) header.pinToVisibleBounds = true return header } } class DefaultSectionFooter: UICollectionReusableView, SectionFooter { let textLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12, weight: .light) return label }() override init(frame: CGRect) { super.init(frame: frame) commonInit() } required init?(coder: NSCoder) { super.init(coder: coder) commonInit() } private func commonInit() { addSubview(textLabel) textLabel.numberOfLines = .zero textLabel.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ textLabel.topAnchor.constraint(equalTo: topAnchor), textLabel.bottomAnchor.constraint(equalTo: bottomAnchor), textLabel.leftAnchor.constraint(equalTo: leftAnchor), textLabel.rightAnchor.constraint(equalTo: rightAnchor) ]) } func setup(with data: String?) -> Self { textLabel.text = data return self } static func footerItem() -> NSCollectionLayoutBoundarySupplementaryItem? { let footerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(20)) let footer = NSCollectionLayoutBoundarySupplementaryItem( layoutSize: footerSize, elementKind: UICollectionView.elementKindSectionFooter, alignment: .bottom, absoluteOffset: .zero ) return footer } }


スクロール リストに新しいセクションを追加してみましょう。

 class ViewController: UIViewController { let sectionView = SectionView() override func loadView() { view = sectionView } override func viewDidLoad() { super.viewDidLoad() sectionView.backgroundColor = .white sectionView.set( sections: [ Section<ColorCollectionCell, DefaultSectionHeader, DefaultSectionFooter>( items: [ .init(color: .blue) { print(#function) }, .init(color: .red) { print(#function) }, .init(color: .yellow) { print(#function) }, .init(color: .green) { print(#function) }, .init(color: .blue) { print(#function) } ], headerData: "COLOR SECTION", footerData: "footer text for color section" ) ], append: false ) } }


合計で、わずか数行のコードで、画面に比例したサイズの 5 つの多色セル、ヘッダー、フッターのセクションを実装しました。




動的にサイズが変更されるセルに同様のアプローチを使用してみましょう。

 class DynamicCollectionCell: UICollectionViewCell, SectionCell { let textLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) commonInit() } required init?(coder: NSCoder) { super.init(coder: coder) commonInit() } private func commonInit() { contentView.addSubview(textLabel) textLabel.numberOfLines = .zero textLabel.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ textLabel.topAnchor.constraint(equalTo: contentView.topAnchor), textLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), textLabel.leftAnchor.constraint(equalTo: contentView.leftAnchor), textLabel.rightAnchor.constraint(equalTo: contentView.rightAnchor) ]) } func setup(with data: DynamicCollectionCellData) -> Self { textLabel.text = data.text return self } static func groupSize() -> NSCollectionLayoutGroup { let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(20)) let item = NSCollectionLayoutItem(layoutSize: itemSize) let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item]) return group } } class DynamicCollectionCellData: SectionCellData { let text: String var onSelect: VoidClosure? init(text: String) { self.text = text } } class ViewController: UIViewController { ... override func viewDidLoad() { super.viewDidLoad() ... sectionView.set( sections: [ SectionWithoutHeaderFooter<DynamicCollectionCell>( items: [ .init(text: "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s"), .init(text: "when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged."), .init(text: "It was popularised"), .init(text: "the 1960s with the release of Letraset sheets containing"), .init(text: "Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.") ] ), ... ], append: false ) } }


その結果、 UICollectionViewCompositionalLayoutに基づいてスクロール リストを作成するときに、定型コードを記述する必要がなくなりました。



ソースコードは閲覧可能ここ


お気軽にご連絡くださいツイッターご質問があればいつでもご連絡ください。コーヒーを買って