[iOS] Compositional Layout의 SectionFooter에 PageControl 구현
3 min readOct 4, 2023
- 다중 섹션을 가진 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를 추가하는 코드다.
NSCollectionLayoutBoundarySupplementaryItem
의elementKind
를UICollectionView.elementKindSectionFooter
으로 설정해야한다.- 그리고 alignment는 꼭 .bottom이 들어간 친구로 설정해야 한다.
- 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를 설정할 수 있다.