[iOS] Compositional Layout의 SectionFooter에 PageControl 구현

kyuchulkim
3 min readOct 4, 2023

--

첫번째 배너 섹션에 pageControl
  • 다중 섹션을 가진 Compositional Layout에서 특정 섹션에 PageControl를 구현해보았다.
  • 위를 보면 첫번째 섹션에만 PageControl이 존재하는 걸 볼 수 있는데 Compositional Layout의 visibleitemsinvalidationhandler를 활용했다.

Section에 Footer 추가하기

private func getLayoutMainBannerSection() -> NSCollectionLayoutSection {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)

let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(0.7))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 16, trailing: 0)
section.orthogonalScrollingBehavior = .groupPagingCentered

section.visibleItemsInvalidationHandler = { [weak self] (visibleItems, offset, env) in
let currentPage = Int(max(0, round(offset.x / env.container.contentSize.width)))
self?.currentBannerPage.onNext(currentPage)
}

// footer 추가
let footerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(40))
let footerSupplementary = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: footerSize, elementKind: UICollectionView.elementKindSectionFooter, alignment: .bottomTrailing)

section.boundarySupplementaryItems = [footerSupplementary]
return section
}
  • NSCollectionLayoutSection을 리턴하는 메서드에서 첫번째로 봐야할 것은 footer를 추가하는 코드다.
  • NSCollectionLayoutBoundarySupplementaryItemelementKindUICollectionView.elementKindSectionFooter 으로 설정해야한다.
  • 그리고 alignment는 꼭 .bottom이 들어간 친구로 설정해야 한다.
.top로 설정 시
  • top으로 설정하면 위로 가버린다
  • 그리고 section.boundarySupplementaryItems 에 설정한 푸터를 설정해준다.

viewForSupplementaryElementOfKind 설정

func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
switch kind {
case UICollectionView.elementKindSectionHeader:
let header = collectionView.dequeueHeaderView(type: SectionHeaderReusableView.self, forIndexPath: indexPath)
header.configureHeaderView(title: MainSectionType.allCases[indexPath.section].title)
return header

// 여기부터 footer 설정
case UICollectionView.elementKindSectionFooter:
let section = MainSectionType(rawValue: indexPath.section)
if section == .mainBanner {
let footer = collectionView.dequeueFooterView(type: PageControlFooterReusableView.self, forIndexPath: indexPath)
footer.bind(input: currentBannerPage, pageNumber: mainBannerArray.count)
return footer
} else {
let footer = collectionView.dequeueFooterView(type: SectionFooterReusableView.self, forIndexPath: indexPath)
return footer
}
default: return UICollectionReusableView()
}
}
  • 이 코드에선 DiffableDataSource를 사용하지 않아서 기본적인 UICollectionView의 DataSource 메서드인 viewForSupplementaryElementOfKind 에서 설정했다.
  • case UICollectionView.elementKindSectionFooter 에서 CompositionalLayout의 섹션을 case화 해둔 Enum의 rawValue를 indexPath.section을 활용해서 footer를 설정한 섹션을 찾았다.
  • if section == .mainBanner 첫번째 mainBanner에만 footer를 return했다.
dataSource.supplementaryViewProvider
  • DiffableDataSource를 활용할 때도 위의 코드와 똑같이 supplementaryViewProvider에만 사용하면 된다.

visibleItemsInvalidationHandler 로 CurrentPage 방출하기

// ✅ footer pageControl에 전달
private let currentBannerPage = PublishSubject<Int>()

section.visibleItemsInvalidationHandler = { [weak self] (visibleItems, offset, env) in
let currentPage = Int(max(0, round(offset.x / env.container.contentSize.width)))
self?.currentBannerPage.onNext(currentPage)
}
  • visibleItemsInvalidationHandler 을 활용해서 사용자의 섹션 스크롤을 모니터링하고 항목이 표시되도록 할 수 있다. (다음 글에서 자세하게 설명해봐야겠다.)**
  • let currentPage = Int(max(0, round(offset.x / env.container.contentSize.width))) 해당 코드는 다음과 같다.
  • 화면의 스크롤 위치(offset.x)를 콜렉션뷰 컨테이너의 너비(env.container.contentSize.width)로 나누고 소수점을 반올림한 후, 최솟값을 0으로 설정하여 현재 페이지를 계산한다.
  • 이렇게 하면 사용자가 섹션을 페이징 할 때마다 현재 페이지의 번호(currentPage)가 계산된다.
  • 해당 currentPage를 Subject에 방출한다. currentPage를 FooterReusableView에서 사용해야하기 때문이다.

FooterView의 PageControl 설정

final class PageControlFooterReusableView: UICollectionReusableView {
private let bannerPageControl: UIPageControl = {
let pageControl = UIPageControl()
pageControl.currentPage = 0
pageControl.isUserInteractionEnabled = true
pageControl.backgroundStyle = .minimal
return pageControl
}()

...
...
...

func bind(input: Observable<Int>, pageNumber: Int) {
bannerPageControl.numberOfPages = pageNumber
input
.subscribe(onNext: { [weak self] currentPage in
self?.bannerPageControl.currentPage = currentPage
})
.disposed(by: disposeBag)
}
  • FooterView의 bind메서드에서 PageControl의 numberOfPages와 currentPage를 설정한다.
  • 해당 메서드 파라미터의 input을 subscribe하여 onNext로 방출되는 currentPage를 PageControl.currentPage 로 설정한다.
  • visibleItemsInvalidationHandler 에서 현재 페이지 index를 가지고있는 currentBannerPage Subject를 활용할 것이기 때문이다.

footer의 bind메서드에 currentPage를 방출받는 Subject 연결

func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
switch kind {
case UICollectionView.elementKindSectionFooter:
let section = MainSectionType(rawValue: indexPath.section)
if section == .mainBanner {
let footer = collectionView.dequeueFooterView(type: PageControlFooterReusableView.self, forIndexPath: indexPath)
// footer의 bind 메서드
footer.bind(input: currentBannerPage, pageNumber: mainBannerArray.count)
return footer
} else {
let footer = collectionView.dequeueFooterView(type: SectionFooterReusableView.self, forIndexPath: indexPath)
return footer
}
default: return UICollectionReusableView()
}
}
  • 다시 viewForSupplementaryElementOfKind 로 돌아와서 footer의 bind 메서드를 연결한다.
  • visibleItemsInvalidationHandler 에서 currentPage를 방출받는currentBannerPage Subject를 써준다.
  • 이렇게되면 사용자가 페이징할 때마다 페이지 컨트롤도 배너의 inex와 같은 index를 설정할 수 있다.

https://stackoverflow.com/questions/64081863/trying-to-hook-up-compositional-layout-collectionview-with-pagecontrol-visiblei

--

--