- GOF
- lineBreakStrategy
- github
- HIG
- iTerm
- iPad
- IOS
- 디자인패턴
- Split View
- Human Interface Guidelines
- Combine+UIKit
- 스위프트
- WWDC
- Keychain
- 앱개발
- 전달인자 레이블
- 야곰아카데미
- lineBreakMode
- TOSS
- Accessibility
- DiffableDataSource
- UILabel
- CollectionView
- 애플
- Swift
- orthogonalScrollingBehavior
- Apple
- LanguageGuide
- 애플사이다
- UIKit
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
- Today
- Total
애플사이다의 iOS 개발 일지
[CollectionView] 배너 하단에 PageControl 구현 - FooterView에 넣기 본문
저번 포스팅 [CollectionView] Diffable DataSource 이해하기 (3/3) - 상품 배너/목록/상세 화면을 구현한 예제코드에서 이미 다뤘지만,
글이 너무 길어져서 이 부분만 따로 포스팅하려고 한다.
아래 화면처럼 배너 하단에 PageControl을 구현해보자.
배너를 왼쪽 <-> 오른쪽으로 Scroll 하면, PageControl이 바뀌는 것을 볼 수 있다.
이번 예제코드에서는 CompositionalLayout, orthogonalScrollingBehavior, PageControl, RxSwift를 활용했다.
첫째, 배너를 Horizontal Scroll할 때 화면에 보이는 "현재 페이지 index"를 PageControl에 전달하는 작업,
그리고 둘째, PageControl의 위치를 잡는 것이 관건이다.
🧪 실패 기록 - scrollViewWillEndDragging 메서드는 못쓴다
화면을 보면 위의 banner section은 horizontal scroll을, 아래의 list section은 vertical scroll을 할 수 있다.
1개 CollectionView 내에서 Scroll 방향이 여러 가지인 것이다.
따라서 banner와 list 2개 section을 모두 구현하려면 scrollViewWillEndDragging 메서드를 쓸 수 없다.
scrollViewWillEndDragging에 아래처럼 코드를 작성하면,
의도했던 Horizontal Scroll 뿐만 아니라 Vertical Scroll을 할 때도 호출되기 때문에 PageControl이 비정상적으로 작동한다.
extension ProductDetailViewController: UICollectionViewDelegate {
func scrollViewWillEndDragging(_ scrollView: UIScrollView,
withVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer<CGPoint>) {
let sideInset: CGFloat = 30
let page = Int(targetContentOffset.pointee.x / (view.frame.width - sideInset))
productDetailScrollView.imageNumberPageControl.currentPage = page // PageControl에 현재 페이지 index를 전달함
}
}
🧪 실패 기록 - Cell이 PageControl을 갖도록 하면 나쁜 UX가 된다
처음에는 Cell에서 PageControl을 생성하고, ViewController가 Cell에게 페이지 수 및 현재 페이지를 전달하는 방법을 생각했었다.
하지만 Cell을 Scroll할 때, PageControl이 고정되어 있지 않고 Cell과 함께 이동하는 형태가 되어 어색하게 느껴졌다.
따라서 ViewController에서 PageControl을 생성하고,
그 PageControl을 banner section의 footerView에 넣는 게 적절하다고 판단했다.
따라서 PageControl을 FooterView에 배치하고,
section.visibleItemsInvalidationHandler에 클로저에서 배너의 현재 페이지를 받아오도록 했다.
코드로 하나씩 살펴보자.
1. FooterView 추가
import UIKit
import RxSwift
import RxCocoa
final class FooterView: UICollectionReusableView {
// MARK: - Properties
private let bannerPageControl: UIPageControl = {
let pageControl = UIPageControl()
pageControl.translatesAutoresizingMaskIntoConstraints = false
pageControl.pageIndicatorTintColor = .systemGray2
pageControl.currentPageIndicatorTintColor = CustomColor.darkGreenColor
pageControl.currentPage = 0
pageControl.isUserInteractionEnabled = false
return pageControl
}()
private let disposeBag = DisposeBag()
// MARK: - Initializers
override init(frame: CGRect) {
super.init(frame: frame)
configureUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Methods
func bind(input: Observable<Int>, indexPath: IndexPath, pageNumber: Int) {
bannerPageControl.numberOfPages = pageNumber
if indexPath.section == 1 {
self.isHidden = true
} else {
input
.subscribe(onNext: { [weak self] currentPage in
// ✅ pageControl의 현재 페이지를 조정
self?.bannerPageControl.currentPage = currentPage
})
.disposed(by: disposeBag)
}
}
private func configureUI() {
addSubview(bannerPageControl)
NSLayoutConstraint.activate([
bannerPageControl.topAnchor.constraint(equalTo: topAnchor),
bannerPageControl.bottomAnchor.constraint(equalTo: bottomAnchor),
bannerPageControl.leadingAnchor.constraint(equalTo: leadingAnchor),
bannerPageControl.trailingAnchor.constraint(equalTo: trailingAnchor)
])
}
}
- custom header / footer를 생성할 때는 UICollectionReusableView를 상속받도록 한다.
- footer 내부에서 PageControl을 생성하고, footer의 subview로 올렸다.
- bind 메서드를 통해 ViewController에서 변경된 현재 페이지 index (currentPage)를 받도록 했다.
2. CollectionView Layout에 FooterView 위치 잡기
import UIKit
import RxSwift
import RxCocoa
final class ProductListViewController: UIViewController {
// MARK: - Nested Types
private enum SupplementaryKind {
static let header = "header-element-kind"
static let footer = "footer-element-kind"
}
// MARK: - Properties
// ✅ footer에 전달될 정보
private let currentBannerPage = PublishSubject<Int>()
private func createLayout() -> UICollectionViewLayout {
let layout = UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ -> NSCollectionLayoutSection? in
// section 정보에 접근
guard let sectionKind = SectionKind(rawValue: sectionIndex) else {
self?.showUnknownSectionErrorAlert()
return nil
}
// item 설정
// Cell 내부 컨텐츠의 높이를 알아서 계산하여 반영하는 estimatedHeight를 적용
let screenWidth = UIScreen.main.bounds.width
let estimatedHeight = NSCollectionLayoutDimension.estimated(screenWidth)
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: estimatedHeight)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
// group 설정
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: estimatedHeight)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
subitem: item,
count: sectionKind.columnCount) // 1개 group에 나타낼 item 개수를 설정
// section 설정
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = sectionKind.orthogonalScrollingBehavior() // Section별 Scroll 방향을 할당
section.visibleItemsInvalidationHandler = { [weak self] _, contentOffset, environment in
let bannerIndex = Int(max(0, round(contentOffset.x / environment.container.contentSize.width)))
if environment.container.contentSize.height == environment.container.contentSize.width {
self?.currentBannerPage.onNext(bannerIndex)
}
}
// ✅ header 및 footer 설정
let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: estimatedHeight),
elementKind: SupplementaryKind.header,
alignment: .top
)
let sectionFooter = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: estimatedHeight),
elementKind: SupplementaryKind.footer,
alignment: .bottom
)
section.boundarySupplementaryItems = [sectionHeader, sectionFooter]
return section
}
return layout
}
- header / footer 이외 코드는 생략했다.
- custom footer를 CollectionView에 연결하기 위해
section.boundarySupplementaryItems 프로퍼티에 header / footer를 할당한다.
이때 alignment를 header는 .top, footer는 .bottom으로 설정한다.
검색 키워드 : collectionView, pageControl, banner, currentPage
how-to-show-page-indicator-with-compositional-layout
3. visibleItemsInvalidationHandler 활용
위 코드 중에서 visibleItemsInvalidationHandler와 관련된 내용을 자세히 보자.
// 위의 createLayout 메서드에 포함된 코드
section.visibleItemsInvalidationHandler = { [weak self] _, contentOffset, environment in
// 비교용 - Device의 Screen 크기
print("=== Screen Width/Height :", UIScreen.main.bounds.width, "/", UIScreen.main.bounds.height)
// ✅ contentOffset은 CollectionView의 bound를 기준으로 Scroll 결과 보여지는 컨텐츠의 Origin을 나타냄
// 배너 및 목록화면의 경우, Scroll하면 어디서 클릭해도 0부터 시작
// 상세화면의 경우, Scroll하면 어디서 클릭해도 약 -30부터 시작 (기기마다 다름, CollectionView의 bound를 기준으로 cell(이미지)의 leading이 왼쪽 (-30)에 위치하므로 음수임)
print("OffsetX :", contentOffset.x)
// ✅ environmnet는 collectionView layout 관련 정보를 담고 있음
// environment.container.contentSize는 CollectionView 중에서 현재 Scroll된 Group이 화면에 보이는 높이를 나타냄
print("environment Width :", environment.container.contentSize.width) // Device의 스크린 너비와 동일
print("environment Height :", environment.container.contentSize.height) // Horizontal Scroll하면 스크린 너비와 같고, Vertical Scroll하면 그보다 커짐
let bannerIndex = Int(max(0, round(contentOffset.x / environment.container.contentSize.width))) // 음수가 되는 것을 방지하기 위해 max 사용
if environment.container.contentSize.height == environment.container.contentSize.width { // ❗Horizontal Scroll 하는 조건
self?.currentBannerPage.onNext(bannerIndex) // 클로저가 호출될 때마다 pageControl의 currentPage로 값을 보냄
}
}
- visibleItemsInvalidationHandler에 할당되는 클로저를 뜯어보자.
- Horizontal Scroll 했을 때 바뀐 Banner 페이지의 index를 pageControl의 currentPage로 지정하는 기능이다.
- contentOffset, environment.container.contentSize에 대한 이해가 필요하다. 직접 프린트를 찍어보면 쉽다.
- contentOffset은 CollectionView의 bound를 기준으로 Scroll 결과 보여지는 컨텐츠의 Origin을 나타낸다.
오른쪽으로 Horizontal Scroll 하면, contentOffset.x의 값은 계속해서 커진다. - environment.container.contentSize는 공식문서에 명확히 나와있지 않아서 실험이 필요했다.
일단 environmnet는 collectionView layout 관련 정보를 담고 있다.
environment.container.contentSize는 CollectionView 중에서 현재 Scroll된 Group이 화면에 보이는 높이를 나타낸다.
이 contentSize의 height를 찍어보면 Horizontal Scroll할 때는 스크린 너비와 같고, Vertical Scroll할 때는 그보다 커진다.
(아래 gif 참고)
-> 따라서 이 특징을 활용하여 if문의 조건으로 Horizontal Scroll하는 경우를 찾은 뒤, PageControl에 현재 페이지를 보내줬다.
- contentOffset은 CollectionView의 bound를 기준으로 Scroll 결과 보여지는 컨텐츠의 Origin을 나타낸다.
검색 키워드 : orthogonalScrollingBehavior, page control
How can I detect orthogonal scroll events when using `UICollectionViewCompositionalLayout`?
(근데 이 클로저도 Vertical Scroll할 때 호출되므로 한계가 있어 보였지만,다른 해결방법을 찾지 못했다.)
4. FooterView 등록 및 DataSource에 연결
Custom Footer를 등록하고, DataSource에 연결시키면 된다.
Custom Cell을 처리하는 과정과 비슷하다.
// ✅ typealias 활용
private typealias HeaderRegistration = UICollectionView.SupplementaryRegistration<HeaderView>
private typealias FooterRegistration = UICollectionView.SupplementaryRegistration<FooterView>
private func configureSupplementaryViewRegistrationAndDataSource() {
// ✅ header, footer 등록
let headerRegistration = HeaderRegistration(elementKind: SupplementaryKind.header) { view, _, indexPath in
view.apply(indexPath)
}
let footerRegistration = FooterRegistration(elementKind: SupplementaryKind.footer) { [weak self] view, _, indexPath in
guard let self = self else { return }
view.bind(input: self.currentBannerPage.asObservable(),
indexPath: indexPath,
pageNumber: Content.bannerCount)
}
// ✅ dataSource에 header, footer 연결
dataSource.supplementaryViewProvider = { [weak self] _, kind, index in
switch kind {
case SupplementaryKind.header:
return self?.collectionView.dequeueConfiguredReusableSupplementary(using: headerRegistration,
for: index)
case SupplementaryKind.footer:
return self?.collectionView.dequeueConfiguredReusableSupplementary(using: footerRegistration,
for: index)
default:
return UICollectionReusableView()
}
}
}
- SupplementaryRegistration의 제네릭 매개변수로 Custom header / footer 타입 (HeaderView / FooterView)을 전달한다.
- header / footer를 등록하고, dataSource에 연결하면 끝이다.
- dataSource를 snapshot에 반영하는 과정은 생략했다.
전체 코드는 GitHub 링크를 참고
- Reference
- 예제코드 전체 : GitHub > MVVM-RX-OpenMarket
- Apple > Collection View > Sample Code - Updating Collection Views Using Diffable Data Sources
- Apple > WWDC 2019 > Advances in UI Data Sources
- Stackoverflow > how-can-i-detect-orthogonal-scroll-events-when-using-uicollectionviewcompositio
- Blog > how-to-show-page-indicator-with-compositional-layout
- 관련 포스트
🍎 포스트가 도움이 되었다면, 공감🤍 / 구독🍹 / 공유🔗 / 댓글✏️ 로 응원해주세요. 감사합니다.
'iOS' 카테고리의 다른 글
[Redirect] 업데이트 버튼 탭하면 사용자에게 AppStore 앱 띄우기 (간단) (0) | 2022.11.11 |
---|---|
[인디 앱개발] ✨ 앱 기획부터 출시까지 참고한 링크 - 앱 기획, 디자인, 개발, 배포 준비 (2) | 2022.07.20 |
[CollectionView] estimatedHeight 사용 시 is stuck in its update/layout loop 에러 발생 (0) | 2022.06.13 |
야곰아카데미 <iOS 커리어 스타터 캠프> 후기 - iOS 앱개발 부트캠프 (6) | 2022.05.01 |
[WWDC 2019] Accessibility Inspector - 앱의 Accessibility 구멍을 찾아보자 (0) | 2022.01.04 |