관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] UI 컴포넌트 - ViewPager (뷰 페이저) (CollectionView + PageViewController) 본문

UI 컴포넌트 (swift)

[iOS - swift] UI 컴포넌트 - ViewPager (뷰 페이저) (CollectionView + PageViewController)

jake-kim 2021. 7. 21. 00:33

구현 아이디어

  • 상단에는 CollectionView를 통해 수평 스크롤뷰 생성
  • 아래에는 PageViewController를 통해 다수의 ViewController 존재
  • 두 인터렉션을 연결하여 하나의 ViewPager를 사용하는 경험을 주는 UI

ViewPager, CollectionView + PageViewController

사용되는 Component 참고

* PageViewController 참고: https://ios-development.tistory.com/623

 

[iOS - swift] PageViewController (페이지 뷰 컨트롤러)

PageViewController 구현 원리 ViewController가 들어있는 배열을 준비 첫번째 ViewController를 PageViewController에 set 초기화 나머지 ViewController 전환은 DataSource, Delegate에서 index값을 바꿔가며..

ios-development.tistory.com

 

주의) StackView + PageViewController로 구현하면 안되는 이유

  • HorizontalStackView + PageViewController로도 디자인만 구현할 수있지만 인터렉션에서 어떤 cell이 눌렸는지 이벤트 처리하기 쉽지 않기 때문

HorizontalStackView

CollectionView에 필요한 cell, model 정의

  • CollectionViewCell에서 사용될 Model 정의
struct MyCollectionViewModel {
    let title: Int
}
  • CollectionViewCell 정의
    • cell을 선택했을 때 이벤트가 발생하는게 아닌 새로 추가한 ContentsView에 이벤트 받도록 설정
    • isSelected를 override하여 선택된 cell에 음영 효과가 있도록 설정

선택된 cell 효과

import UIKit
import SnapKit
import RxSwift

class MyCollectionViewCell: UICollectionViewCell {

    static var id: String { NSStringFromClass(Self.self).components(separatedBy: ".").last ?? "" }
    var bag = DisposeBag()

    var model: MyCollectionViewModel? { didSet { bind() } }

    lazy var contentsView: UIView = {
        let view = UIView()
        view.backgroundColor = .orange.withAlphaComponent(0.5)

        return view
    }()

    lazy var titleLabel: UILabel = {
        let label = UILabel()

        return label
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)

        addSubviews()

        configure()
    }

    override var isSelected: Bool {
        didSet {
            contentsView.backgroundColor = isSelected ? .orange : .orange.withAlphaComponent(0.5)
        }
    }

    override func prepareForReuse() {
        super.prepareForReuse()

        bag = DisposeBag()
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError()
    }

    private func addSubviews() {
        addSubview(contentsView)
        contentsView.addSubview(titleLabel)
    }

    private func configure() {
        backgroundColor = .brown

        contentsView.snp.makeConstraints { make in
            make.top.bottom.equalToSuperview().inset(20)
            make.leading.trailing.equalToSuperview()
        }

        titleLabel.snp.makeConstraints { make in
            make.center.equalToSuperview()
        }
    }

    private func bind() {
        titleLabel.text = "\((model?.title ?? 0))"
    }
}

collectionView 구현

 

[iOS - swift] CollectionView(컬렉션 뷰) - 수평 스크롤 뷰 (UICollectionViewFlowLayout)

cf) ScrollView + StackView를 이용한 수평 스크롤 뷰: https://ios-development.tistory.com/617 [iOS - swift] UI 컴포넌트 - 수평 스크롤 뷰 (ScrollView + StackView) cf) collectionView를 이용한 수평 스크..

ios-development.tistory.com

  • collectionView를 사용할 ViewController 생성
import UIKit
import RxGesture // collectionView의 cell내부 contentsView tapGesture() rx 사용위해 import
import RxSwift 
import RxCocoa

class ViewController: UIViewController {
    
}
  • CollectionViewCell에 사용될 dataSource 정의
    var dataSource: [MyCollectionViewModel] = []
    
    // viewDidLoad()에서 호출
    private func setupDataSource() {
        for i in 0...10 {
            let model = MyCollectionViewModel(title: i)
            dataSource += [model]
        }
    }
  • collectionView 초기화
    lazy var collectionView: UICollectionView = {

        let flowLayout = UICollectionViewFlowLayout()
        flowLayout.minimumLineSpacing = 12
        flowLayout.scrollDirection = .horizontal

        let view = UICollectionView(frame: .zero, collectionViewLayout: flowLayout)
        view.backgroundColor = .lightGray

        return view
    }()
  • addSubviews()
    // viewDidLoad에서 호출
    private func addSubviews() {
        view.addSubview(collectionView)
    }
  • layout 세팅
    // viewDidLoad에서 호출
    private func configure() {
        collectionView.snp.makeConstraints { make in
            make.top.equalTo(view.safeAreaLayoutGuide)
            make.leading.trailing.equalToSuperview()
            make.height.equalTo(96)
        }
    }
  • delegate 설정, cell 등록
    private func setupDelegate() {
        collectionView.delegate = self
        collectionView.dataSource = self
    }

    private func registerCell() {
        collectionView.register(MyCollectionViewCell.self, forCellWithReuseIdentifier: MyCollectionViewCell.id)
    }
  • delegate 구현
    // ViewController 내부
    func didTapCell(at indexPath: IndexPath) {
    //    currentPage = indexPath.item
    }
}

extension ViewController: UICollectionViewDelegate, UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return dataSource.count
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: MyCollectionViewCell.id, for: indexPath)
        if let cell = cell as? MyCollectionViewCell {
            cell.model = dataSource[indexPath.item]

            cell.contentsView.rx.tapGesture(configuration: .none)
                .when(.recognized)
                .asDriver { _ in .never() }
                .drive(onNext: { [weak self] _ in
                    self?.didTapCell(at: indexPath)
                }).disposed(by: cell.bag)

        }
        return cell
    }
}

extension ViewController: UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        return CGSize(width: 60, height: collectionView.frame.height)
    }
}
  • 수평 스크롤이 되도록height를 collectionView의 height와 동일하도록 설정
extension ViewController: UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        return CGSize(width: 60, height: collectionView.frame.height)
    }
}

PageViewController 추가

  • pageViewController에 사용되는 dataSourceVC 정의
    var dataSourceVC: [UIViewController] = []
    
    // viewDidLoad에서 호출
    private func setupViewControllers() {

        var i = 0
        dataSource.forEach { _ in
            let vc = UIViewController()
            let red = CGFloat(arc4random_uniform(256)) / 255
            let green = CGFloat(arc4random_uniform(256)) / 255
            let blue = CGFloat(arc4random_uniform(256)) / 255

            vc.view.backgroundColor = UIColor(red: red, green: green, blue: blue, alpha: 1)

            let label = UILabel()
            label.text = "\(i)"
            label.font = .systemFont(ofSize: 56, weight: .bold)
            i += 1

            vc.view.addSubview(label)
            label.snp.makeConstraints { make in
                make.center.equalToSuperview()
            }
            dataSourceVC += [vc]
        }
    }
  • pageViewController 초기화
    lazy var pageViewController: UIPageViewController = {
        let vc = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)

        return vc
    }()
  • 레이아웃 설정
    private func configure() {
        // collectionView 레이아웃 (기존에 있던 코드)
        collectionView.snp.makeConstraints { make in
            make.top.equalTo(view.safeAreaLayoutGuide)
            make.leading.trailing.equalToSuperview()
            make.height.equalTo(96)
        }

        pageViewController.view.snp.makeConstraints { make in
            make.top.equalTo(collectionView.snp.bottom)
            make.leading.trailing.bottom.equalToSuperview()
        }

        pageViewController.didMove(toParent: self)
    }
  • delegate, dataSource 설정
    private func setupDelegate() {
    	// collectionViewController 관련 코드 - 원래 있던 코드
        collectionView.delegate = self
        collectionView.dataSource = self

        pageViewController.delegate = self
        pageViewController.dataSource = self
    }
  • firstViewController 설정
    // viewDidLoad에서 호출
    private func setViewControllersInPageVC() {
        if let firstVC = dataSourceVC.first {
            pageViewController.setViewControllers([firstVC], direction: .forward, animated: true, completion: nil)
        }
    }
  • delegate 구현
extension ViewController: UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        return CGSize(width: 60, height: collectionView.frame.height)
    }
}

extension ViewController: UIPageViewControllerDataSource, UIPageViewControllerDelegate {
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        guard let index = dataSourceVC.firstIndex(of: viewController) else { return nil }
        let previousIndex = index - 1
        if previousIndex < 0 {
            return nil
        }
        return dataSourceVC[previousIndex]
    }

    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        guard let index = dataSourceVC.firstIndex(of: viewController) else { return nil }
        let nextIndex = index + 1
        if nextIndex == dataSourceVC.count {
            return nil
        }
        return dataSourceVC[nextIndex]
    }
}

CollectionView와 PageViewController 연동

  • currentPage 정의: 현재 페이지가 collectionView나 pageViewController 둘 중 하나로 바뀌면 싱크를 맞추는 옵저버 프로퍼티
    var currentPage: Int = 0 {
        didSet {
            bind(oldValue: oldValue, newValue: currentPage)
        }
    }
    
    private func bind(oldValue: Int, newValue: Int) {

        // collectionView 에서 선택한 경우
        let direction: UIPageViewController.NavigationDirection = oldValue < newValue ? .forward : .reverse
        pageViewController.setViewControllers([dataSourceVC[currentPage]], direction: direction, animated: true, completion: nil)

        // pageViewController에서 paging한 경우
        collectionView.selectItem(at: IndexPath(item: currentPage, section: 0), animated: true, scrollPosition: .centeredHorizontally)
    }
  • viewDidAppear 시 0번 페이지가 선택되도록 설정
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        currentPage = 0
    }
  • ColelctionViewCell안에 contentsView가 tap될때 반응 이벤트 바인딩
    func didTapCell(at indexPath: IndexPath) {
        currentPage = indexPath.item
    }
  • pageViewController 애니메이션이 끝난 경우 호출되는 UICollectionViewDeleagteFlowLayout 델리게이트
    func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
        guard let currentVC = pageViewController.viewControllers?.first,
              let currentIndex = dataSourceVC.firstIndex(of: currentVC) else { return }
        currentPage = currentIndex
    }

* source code: https://github.com/JK0369/ViewPagerSample

Comments