애플사이다의 iOS 개발 일지

[CollectionView] 배너 하단에 PageControl 구현 - FooterView에 넣기 본문

iOS

[CollectionView] 배너 하단에 PageControl 구현 - FooterView에 넣기

Applecider 2022. 6. 15. 01:30

저번 포스팅 [CollectionView] Diffable DataSource 이해하기 (3/3) - 상품 배너/목록/상세 화면을 구현한 예제코드에서 이미 다뤘지만,

글이 너무 길어져서 이 부분만 따로 포스팅하려고 한다.

 

아래 화면처럼 배너 하단에 PageControl을 구현해보자.

배너를 왼쪽 <-> 오른쪽으로 Scroll 하면, 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로 지정하는 기능이다.
  • contentOffsetenvironment.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에 현재 페이지를 보내줬다.

Scroll 방향에 따라 달라지는 offSetX 및 environment height 확인

검색 키워드 : 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

 

🍎 포스트가 도움이 되었다면, 공감🤍 / 구독🍹 / 공유🔗 / 댓글✏️ 로 응원해주세요. 감사합니다.

Comments