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 기반으로 스크롤 목록을 사용하기 위한 생성자를 생성할 수 있는 범용 클래스를 생성하는 사례가 고려되었습니다.


이 접근 방식의 동기는 매우 간단합니다. 우리는 루틴의 양을 줄이는 동시에 유연성을 잃지 않는 범용 도구를 만들어 상용구 코드의 양을 줄이고 싶습니다.


이 기사에서는 다음을 사용하여 유사한 작업을 계속 고려할 것입니다. UICollectionViewCompositionalLayout , iOS 13 이상에서 지원되며 이 프레임워크가 가져오는 미묘한 차이를 확인하세요.


이전에 했던 것처럼 이 문제를 4단계로 해결하겠습니다.


  1. 스크롤 요소의 데이터 유형 추상화 작성
  2. 스크롤 가능한 요소에 대한 기본 클래스 작성
  3. 목록 구현 작성
  4. 사용 사례


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) } }


UICollectionViewCompositionalLayout UICollectionViewFlowLayout 사용하는 것과 비교하여 대리자 메서드에서 레이아웃 본문으로 셀, 머리글 및 바닥글 레이아웃 구성을 전송할 수 있습니다.

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 기반으로 스크롤 목록을 생성할 때 상용구 코드 작성을 없앴습니다.



소스코드를 볼 수 있습니다 여기 .


주저하지 말고 저에게 연락주세요 트위터 만약 질문이 있다면. 또한 언제든지 가능합니다. 나에게 커피를 사다 .