Notice
Recent Posts
Recent Comments
Link
관리 메뉴

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

[iOS - swift] 2. 상단 탭과 하단 수평 스크롤 UI 구현 방법 - 하단 수평 스크롤 콘텐츠 구현 (UIPageViewController) 본문

UI 컴포넌트 (swift)

[iOS - swift] 2. 상단 탭과 하단 수평 스크롤 UI 구현 방법 - 하단 수평 스크롤 콘텐츠 구현 (UIPageViewController)

jake-kim 2023. 1. 17. 23:15

1. 상단 탭과 하단 수평 스크롤 UI 구현 방법 - 상단 TabHeaderView 구현 (UICollectionView)

2. 상단 탭과 하단 수평 스크롤 UI 구현 방법 - 하단 콘텐츠 구현 (UIPageViewController) <

구현 아이디어

* 1편 포스팅 글(상단 TabHeaderView 구현)에서 설명한 내용과 동일

  • 상단 스크롤은 collectionView를 사용하여 구현 (scrollView의 scrollDirection = .horizontal 사용)
    • UIScrollView도 사용할 수 있지만, 동일한 화면이 반복적으로 필요하고, 어떤 셀이 선택되었는지 쉽게 구하려면 UICollectionView를 사용하는 것이 편리
  • 하단 콘텐츠는 pageViewController를 사용하여 왼쪽, 오른쪽으로 ViewController들을 넘길 수 있도록 구현
  • 상단 스크롤, 하단 콘텐츠 모두 클라이언트 코드 쪽 한 곳에서 관리하도록 구현
    • 상태 관리를 여러곳에서 하지 않고 한 곳에서 해야, 상태가 변경되었을때 어디서 변경되는지 쉽게 확인할 수 있고 유지보수에 용이히기 때문
    • 뷰 상태 관리(isSelected 등)도 모두 클라이언트 코드에게 위임하도록 구현

구조

PageViewContoller 사용하여 구현

* 구현 내용 코드 다운로드 링크

  • PageViewController를 사용하여 구현할것인데, PageViewControllers는 상단 tabHeader의 하단 콘텐츠이므로, 이 곳에는 다른 콘텐츠도 추가되고 스크롤 될 가능성이 높으므로 UIScrollView로 wrapping하여 구현
    // ViewController.swift
    
    // MARK: UI
    private let tabHeaderView = TabHeaderView()
    private let scrollView = UIScrollView()
    private let stackView = UIStackView().then {
        $0.axis = .vertical
    }
    private let pageViewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal)
    
    private func setUpLayouts() {
        view.addSubviews(
            tabHeaderView,
            scrollView
        )
        scrollView.addSubviews(
            stackView
        )
        stackView.addArrangedSubviews(
            pageViewController.view
        )
        
        tabHeaderView.snp.makeConstraints {
            $0.top.equalTo(view.safeAreaLayoutGuide)
            $0.left.right.equalToSuperview().inset(Metric.headerViewHorizontalInset)
            $0.height.equalTo(Metric.headerHeight)
        }
        scrollView.snp.makeConstraints {
            $0.top.equalTo(tabHeaderView.snp.bottom)
            $0.left.right.equalToSuperview().inset(Metric.horizontalInset)
            $0.bottom.equalToSuperview()
        }
        stackView.snp.makeConstraints {
            $0.edges.width.equalToSuperview()
        }
        // height 지정 필수
        pageViewController.view.snp.makeConstraints {
            $0.height.equalTo(Metric.pageHeight)
        }
    }
  • 페이지 뷰 컨트롤러에 들어갈 프로퍼티 선언
    • 따로 전역으로 빼놓는 이유는 pageViewController가 스크롤 직전에 index값을 델리게이트에서 얻을 수 있는데, 이 값을 가지고 어떤 ViewController를 리턴해줄것인지 필요하기 때문
fileprivate var contentViewControllers = [UIViewController]()
  • 이 프로퍼티에 샘플로 사용할 ViewController 셋팅
    private var items = ["1", "jake", "iOS 앱 개발 알아가기", "2", "jake123"]
        .enumerated()
        .map { index, str in HeaderItemType(title: str, isSelected: index == 0) }    
    
    private func setViewControllers() {
        items
            .map(\.title)
            .forEach { title in
                let vc = LabelViewController() // 단순히 UILabel을 가지고 있는 VC
                vc.titleText = title
                contentViewControllers.append(vc)
            }
    }
  • pageViewController 델리게이트, 데이터소스, 뷰 셋팅
    private func setUpViews() {
        pageViewController.dataSource = self
        pageViewController.delegate = self
        
        addChild(pageViewController)
        pageViewController.didMove(toParent: self)
        pageViewController.setViewControllers([contentViewControllers[0]], direction: .forward, animated: false)
    }
  • 페이지 dataSource
    • 스크롤 직전에 호출되는 메소드에서 어떤 ViewController를 보여줄지 결정
extension ViewController: UIPageViewControllerDataSource {
    // left -> right 스와이프 하기 직전 호출 (다음 화면은 무엇인지 리턴)
    func pageViewController(
        _ pageViewController: UIPageViewController,
        viewControllerBefore viewController: UIViewController
    ) -> UIViewController? {
        guard let index = contentViewControllers.firstIndex(of: viewController) else { return nil }
        let previousIndex = index - 1
        guard previousIndex >= 0 else { return nil }
        return contentViewControllers[previousIndex]
    }
    
    // right -> left 스와이프 하기 직전 호출 (이전 화면은 무엇인지 리턴)
    func pageViewController(
        _ pageViewController: UIPageViewController,
        viewControllerAfter viewController: UIViewController
    ) -> UIViewController? {
        guard let index = contentViewControllers.firstIndex(of: viewController) else { return nil }
        let nextIndex = index + 1
        guard nextIndex < contentViewControllers.count else { return nil }
        return contentViewControllers[nextIndex]
    }
}
  • 페이지 delegate
    • 페이지가 변하면 상단의 tabHeaderCell도 업데이트 되어야 하므로 싱크 작업
extension ViewController: UIPageViewControllerDelegate {
    func pageViewController(
        _ pageViewController: UIPageViewController,
        didFinishAnimating finished: Bool,
        previousViewControllers: [UIViewController],
        transitionCompleted completed: Bool
    ) {
        guard completed else { return }
        updateTabIndex()
    }
    
    private func updateTabIndex() {
        guard
            let vc = (pageViewController.viewControllers?.first as? LabelViewController),
            let id = vc.id,
            let currentIndex = items.firstIndex(where: { id == $0.title })
        else { return }
        
        updateTapHeaderCell(currentIndex)
    }
}

(완료)

* 전체 코드: https://github.com/JK0369/ExTabPageViewController

Comments