티스토리 뷰

상단 탭바를 통해 페이지를 좌우로 전환하는 화면을 구현할때 어떻게 해야할까요?

UICollectionView, UIPageViewController, UIScrollView 등을 이용해볼 수 있겠습니다.

 

저는 CollectionView로 구현하기엔 너무 복잡해질거 같기도하고 Page요소는 보통 동적으로 바뀌는 부분은 아니라 굳이 CollectionView가 필요하지 않다는 생각이 들었습니다.

해서 UIPageViewController로 먼저 구현을 해보았는데 결론적으로는 UIScrollView로 마저 구현하게 되었습니다.

 

왜 UIPageViewController를 사용하지 못했는지 공유하고자 포스팅 올립니다!

 

UIPageViewController


우선 저는 프로젝트에 RxSwift를 사용하고 있었기 때문에 UIPageViewController를 Rx로 래핑해주었습니다.

 

전체코드는 이렇습니다.

public protocol PageType: Hashable {
    var viewController: UIViewController { get }
}

open class RxPageViewController<Page: PageType>: UIPageViewController, UIPageViewControllerDataSource {
    // MARK: Event
    private let _onMove = PublishSubject<Page>()
    /// Subject that emits the current page after the page change is completed
    public var onMove: Observable<Page> {
        return _onMove.asObservable()
    }
    
    /// The index of the currently displayed page
    public var selectedPage: Binder<Page> {
        return Binder(self) { (pageViewController: RxPageViewController, page: Page) in
            guard let pageIndex = pageViewController.pages.firstIndex(of: page) else { return }
            pageViewController.moveToPage(at: pageIndex)
        }
    }
    
    // MARK: Private property
    /// A page enum list injected
    private var pages: [Page] = [] {
        didSet {
            viewControllersDict.removeAll()
            pages.forEach { page in
                viewControllersDict[page] = page.viewController
            }
        }
    }
    
    /// The View Controller generated by the computed property is accessed once and cached so that it is not regenerated.
    private var viewControllersDict: [Page: UIViewController] = [:]
    
    /// It need to determine the animation direction when changing pages from the outside(user drag).
    private var currentPageIndex: Int?
    
    private var isSetupFirstPage: Bool = false
    
    // MARK: DisposeBag
    private let disposeBag = DisposeBag()
    
    // MARK: Initializer
    public init(
        pages: [Page],
        firstPage: Page,
        transitionStyle style: UIPageViewController.TransitionStyle = .scroll,
        navigationOrientation: UIPageViewController.NavigationOrientation = .horizontal,
        disabledScrollGesture: Bool = true
    ) {
        super.init(
            transitionStyle: style,
            navigationOrientation: navigationOrientation,
            options: nil
        )
        
        if pages.isEmpty {
            fatalError("You must provide at least one page.")
        }
        
        if !pages.contains(firstPage) {
            fatalError("The first page must be included in the page list.")
        }
        
        if pages.count != Set(pages).count {
            fatalError("There are duplicate pages.")
        }
        
        updatePages(pages)
        setupDeleagte()
        setupFirstPage(page: firstPage)
        setupBind()
        
        if disabledScrollGesture {
            disableScrollGesture()
        }
    }
    
    public required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // MARK: Public interface
    public func updatePages(_ pages: [Page]) {
        if pages.count != Set(pages).count {
            fatalError("There are duplicate pages.")
        }
        
        self.pages = pages
    }
    
    // MARK: Setup
    private func setupDeleagte() {
        self.dataSource = self
    }
    
    private func setupFirstPage(page: Page) {
        if let firstViewController = viewControllersDict[page],
            let firstPageIndex = pages.firstIndex(of: page) {
            setViewControllers([firstViewController], direction: .forward, animated: true, completion: nil)
            currentPageIndex = firstPageIndex
        }
    }
    
    private func setupBind() {
        self.rx.didFinishAnimating
            .subscribe(onNext: { [weak self] (_, _, transitionCompleted) in
                guard let self = self, transitionCompleted else { return }
                if let currentViewController = self.viewControllers?.first,
                   let currentPage = self.pages.first(where: {
                       self.viewControllersDict[$0] == currentViewController
                   }) {
                    self.currentPageIndex = self.pages.firstIndex(of: currentPage)
                    
                    self._onMove.onNext(currentPage)
                }
            })
            .disposed(by: disposeBag)
        
        self.rx.willTransitionTo
            .subscribe(onNext: { [weak self] pendingViewControllers in
                guard let self = self,
                      let nextViewController = pendingViewControllers.first,
                      let nextPage = self.pages.first(where: {
                          self.viewControllersDict[$0] == nextViewController
                      }) else { return }
                
                self.currentPageIndex = self.pages.firstIndex(of: nextPage)
                self._onMove.onNext(nextPage)
            })
            .disposed(by: disposeBag)
    }
    
    // MARK: Data source
    public func pageViewController(
        _ pageViewController: UIPageViewController,
        viewControllerBefore viewController: UIViewController
    ) -> UIViewController? {
        guard let currentIndex = currentPageIndex, currentIndex > 0
        else { return nil }

        let destination = pages[currentIndex - 1]
        
        currentPageIndex = currentIndex - 1
        
        return findViewController(for: destination)
    }
    
    public func pageViewController(
        _ pageViewController: UIPageViewController,
        viewControllerAfter viewController: UIViewController
    ) -> UIViewController? {
        guard let currentIndex = currentPageIndex, currentIndex < (pages.count - 1)
        else { return nil }
        
        let destination = pages[currentIndex + 1]

        currentPageIndex = currentIndex + 1
        
        return findViewController(for: destination)
    }
    
    // MARK: Helper mothod
    private func findViewController(for page: Page) -> UIViewController {
        if let viewController = viewControllersDict[page] {
            return viewController
        } else {
            let newViewController = page.viewController
            viewControllersDict[page] = newViewController
            return newViewController
        }
    }
    
    /// Move to specific page
    private func moveToPage(at index: Int) {
        let selectedPage = pages[index].viewController
        let direction: UIPageViewController.NavigationDirection = index 
            > (currentPageIndex ?? 0) ? .forward : .reverse
        
        setViewControllers([selectedPage], direction: direction, animated: true, completion: nil)
        
        currentPageIndex = index
    }
    
    private func disableScrollGesture() {
        for view in self.view.subviews {
            if let scrollView = view as? UIScrollView {
                scrollView.isScrollEnabled = false
            }
        }
    }
}

// MARK: - Delegate extension
extension Reactive where Base: UIPageViewController {
    private var delegate: DelegateProxy<UIPageViewController, UIPageViewControllerDelegate> {
        return RxPageViewControllerProxy.proxy(for: self.base)
    }
    
    public var didFinishAnimating: Observable<(
        finished: Bool,
        previousViewControllers: [UIViewController],
        transitionCompleted: Bool
    )> {
        return delegate
            .methodInvoked(
                #selector(
                    UIPageViewControllerDelegate.pageViewController(
                        _:didFinishAnimating:previousViewControllers:transitionCompleted:
                    )
                )
            )
            .map { parameters in
                let finished = try castOrThrow(Bool.self, parameters[1])
                let previousViewControllers = try castOrThrow([UIViewController].self, parameters[2])
                let transitionCompleted = try castOrThrow(Bool.self, parameters[3])
                
                return (finished, previousViewControllers, transitionCompleted)
            }
    }
    
    public var willTransitionTo: Observable<[UIViewController]> {
        return delegate
            .methodInvoked(
                #selector(
                    UIPageViewControllerDelegate.pageViewController(_:willTransitionTo:)
                )
            )
            .map { parameters in
                return try castOrThrow([UIViewController].self, parameters[1])
            }
    }
    
    private func castOrThrow<T>(_ resultType: T.Type, _ object: Any) throws -> T {
        guard let returnValue = object as? T else {
            throw RxCocoaError.castingError(object: object, targetType: resultType)
        }

        return returnValue
    }
}

// MARK: - Delegate Proxy
final class RxPageViewControllerProxy: DelegateProxy<UIPageViewController, UIPageViewControllerDelegate>, DelegateProxyType, UIPageViewControllerDelegate {

    static func registerKnownImplementations() {
        self.register { (pageViewContoller) -> RxPageViewControllerProxy in
            RxPageViewControllerProxy(parentObject: pageViewContoller, delegateProxy: self)
        }
    }

    static func currentDelegate(
        for object: UIPageViewController
    ) -> UIPageViewControllerDelegate? {
        return object.delegate
    }

    static func setCurrentDelegate(
        _ delegate: UIPageViewControllerDelegate?, 
        to object: UIPageViewController
    ) {
        object.delegate = delegate
    }
}

 

핵심을 설명하자면 외부 뷰와 바인딩 되는 Subject나 Binder가 UIPageViewController의 Delegate인 didFinishAnimating, willTransitionTo와 연결되어 제스처에 의한 화면 전환을 감지하거나, 코드상에서 화면을 전환시키는 일을 합니다.

    public let _onMove = PublishSubject<Page>()
    /// Subject that emits the current page after the page change is completed
    public var onMove: Observable<Page> {
        return _onMove.asObservable()
    }
    
    /// The index of the currently displayed page
    public var selectedPage: Binder<Page> {
        return Binder(self) { (pageViewController: RxPageViewController, page: Page) in
            guard let pageIndex = pageViewController.pages.firstIndex(of: page) else { return }
            pageViewController.moveToPage(at: pageIndex)
        }
    }

 

 

결과화면을 보시면 제스쳐를 하고있음에도 상단 탑바와 동기화되지 않는 것을 보실 수 있습니다. (탭바와는 Rx로 연결된 상태)

사실, 제스처로만 화면전환을 하거나, 탑바로만 화면전환을 하면(외부에서 selectedPage지정) 오류가 발생하지 않았는데,

이 두가지를 섞어서 사용하니 문제가 생겼습니다.

 

원인은 didFinishAnimating, willTransitionTo 이 두 델리게이트 메서드가 제때 호출되지 않는 것이었습니다.

이미 화면은 이동했는데, 한번 더 제스쳐를 해야 호출된다던지하는 동작을 보였습니다.

 

아무래도 페이지 이동시 다음, 이전 페이지를 바로바로 계산하는 방식이라 그런지, setViewControllers를 여러번 호출하는게 문제인건지 정확히는 모르겠으나, 이전에도 UIPageViewController의 정확한 index 계산이 어렵다고 들어 다른 방식을 찾게 되었습니다.

 

UIScrollView


UIScrollView를 채택하면서는 그때그때 화면에 보일 뷰를 붙이는 방식이 아니라 모든 뷰를 우선 하나의 ScrollView에 세팅해두고 scroll offset을 조정하는 방법을 선택했습니다.

따라서 아주 많은 뷰를 페이징하기엔 적합하지 않고, 1~10개 정도의 페이지에 적절할 것 같습니다.

 

바인딩 요소들은 위에서 구현한 RxPageViewContoller와 거의 동일하게 구성했습니다.

 

전체코드 (바로 복사해서 쓰셔도 무방합니다. 사용법은 주석을 참고해주세요.)

import UIKit

import RxSwift
import RxCocoa
import SnapKit

public protocol PageType: Hashable {
    var viewController: UIViewController { get }
}

/**
 A view that can scroll by pages.

 First, define the types of pages to be displayed as an enumeration.
 This enumeration is also used to determine which page to move to in a clear way, not by index.
 ```swift
 enum TestViewPage: PageType {
     case first
     case second
     case third
     
     var viewController: UIViewController {
         switch self {
         case .first:
             let vc = UIViewController()
             vc.view.backgroundColor = .gray02
             return vc
         case .second:
             let vc = UIViewController()
             vc.view.backgroundColor = .gray03
             return vc
         case .third:
             let vc = UIViewController()
             vc.view.backgroundColor = .gray05
             return vc
         }
     }
 }
```
 
 Create an RxPageViewController using the defined enumeration
 and inject the pages to be displayed into the initializer.
 - Important: The elements of the page array must not be duplicated. 
 The recommended approach is to use allCases.
 ```swift
 // 1. Create a PageView with pages.
 let paginableView = PaginableView<TestViewPage>(pages: [.first, .second, third])
 
 // 2. Add the PageView to the parent view.
 addSubview(paginableView)
 paginableView.snp.makeConstraints { make in
     make.edges.equalToSuperview()
 }
 
 // 3. Subscribe the onMove action. You can observe when the page changes by dragging.
 paginableView.onMove
     .subscribe(onNext: { page in
         print(page) // result: first or second or third
     })
     .disposed(by: disposeBag)
 
 // 4. Bind the some subject to moveTo. This allows you to programmatically move to a specific page.
 someSubject
     .bind(to: paginableView.moveTo)
     .disposed(by: disposeBag)
 ```
 
 If you want to change the page configuration at runtime, use the ``updatePages(_:)`` method.
- Warning: There is no problem modifying the previous view, the next view,
 but if you delete the page you are currently showing, unexpected behavior can occur.
 ```swift
 paginableView.updatePages([.first, .second])
 ```
 */
open class PaginableView<Page: PageType>: UIView, UIScrollViewDelegate {
    // MARK: Event
    private let _onMove = PublishSubject<Page>()
    public var onMove: Observable<Page> {
        return _onMove.asObservable()
    }
    
    public var selectedPage: Binder<Page> {
        return Binder(self) { (pageView: PaginableView, page: Page) in
            guard let pageIndex = pageView.pages.firstIndex(of: page) else { return }
            pageView.moveToPage(at: pageIndex, animated: true)
        }
    }
    
    private let _scrollOffset = PublishSubject<CGFloat>()
    public var scrollOffset: Observable<Page> {
        return _onMove.asObservable()
    }
    
    // MARK: Private property
    private var pages: [Page] = []
    private let firstPage: Page
    private let gestureDisabled: Bool
    private var viewControllersDict: [Page: UIViewController] = [:]
    private var pageIndexDict: [Page: Int] = [:]
    private var currentPage: Page?
    
    // MARK: UI component
    private let scrollView = UIScrollView()
    private let contentView = UIView()
    
    // MARK: DisposeBag
    private let disposeBag = DisposeBag()
    
    // MARK: Initializer
    public init(
        pages: [Page],
        firstPage: Page,
        gestureDisabled: Bool = true
    ) {
        self.firstPage = firstPage
        self.gestureDisabled = gestureDisabled
        
        super.init(frame: .zero)
        
        if pages.isEmpty {
            fatalError("You must provide at least one page.")
        }
        
        if !pages.contains(firstPage) {
            fatalError("The first page must be included in the page list.")
        }
        
        if pages.count != Set(pages).count {
            fatalError("There are duplicate pages.")
        }
        setupPages(pages)
        setupScrollView()
        setupContentView()
        setupFirstPage()
    }
    
    required public init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // MARK: Life cycle
    public override func layoutSubviews() {
        super.layoutSubviews()
        setupPageLayout()
        setupFirstPage()
    }
    
    // MARK: Public method
    public func updatePages(_ pages: [Page]) {
        if pages.count != Set(pages).count {
            fatalError("There are duplicate pages.")
        }
        
        setupPages(pages)
        updatePageLayout()
    }
    
    // MARK: Setup
    private func setupPages(_ pages: [Page]) {
        self.pages = pages
        viewControllersDict.removeAll()
        pages.enumerated().forEach { index, page in
            viewControllersDict[page] = page.viewController
            pageIndexDict[page] = index
        }
    }
    
    private func setupScrollView() {
        scrollView.delegate = self
        scrollView.isPagingEnabled = true
        scrollView.showsHorizontalScrollIndicator = false
        scrollView.bounces = false
        scrollView.alwaysBounceHorizontal = false
        if gestureDisabled {
            scrollView.panGestureRecognizer.isEnabled = false
        }
        
        addSubview(scrollView)
        
        scrollView.snp.makeConstraints { make in
            make.edges.equalToSuperview()
        }
    }
    
    private func setupContentView() {
        scrollView.addSubview(contentView)
        
        contentView.snp.makeConstraints { make in
            make.edges.equalTo(scrollView)
            make.height.equalTo(scrollView.snp.height)
            make.width.equalTo(scrollView.snp.width)
                .multipliedBy(CGFloat(pages.count))
        }
    }
    
    private func setupPageLayout() {
        for (index, page) in pages.enumerated() {
            if let viewController = viewControllersDict[page] {
                contentView.addSubview(viewController.view)
                
                viewController.view.snp.makeConstraints { make in
                    make.top.bottom.equalToSuperview()
                    make.width.equalTo(scrollView.snp.width)
                    make.leading.equalTo(scrollView.snp.leading)
                        .offset(scrollView.bounds.width * CGFloat(index))
                }
            }
        }
    }
    
    private func setupFirstPage() {
        guard let firstPageIndex = pageIndexDict[firstPage]
        else { return }
        
        currentPage = firstPage
        scrollView.contentOffset = CGPoint(
            x: scrollView.bounds.width * CGFloat(firstPageIndex),
            y: 0
        )
    }
    
    // MARK: Update
    private func updatePageLayout() {
        let existingViews = Set(contentView.subviews)
        var newViews: Set<UIView> = []
        
        // Iterate over pages array and configure the view for each page
        for (index, page) in pages.enumerated() {
            if let viewController = viewControllersDict[page],
                let view = viewController.view {
                
                newViews.insert(view)
                
                // If the view is not already in the existingViews, add it to contentView
                if !existingViews.contains(view) {
                    contentView.addSubview(view)
                }
                
                view.snp.remakeConstraints { make in
                    make.top.bottom.equalToSuperview()
                    make.width.equalTo(scrollView.snp.width)
                    make.leading.equalTo(scrollView.snp.leading)
                        .offset(scrollView.bounds.width * CGFloat(index))
                }
            }
        }
        
        // Remove views that are no longer needed
        for view in existingViews.subtracting(newViews) {
            view.removeFromSuperview()
        }
        
        // Remake constraints for contentView to fit all pages
        contentView.snp.remakeConstraints { make in
            make.edges.equalTo(scrollView)
            make.height.equalTo(scrollView.snp.height)
            make.width.equalTo(scrollView.snp.width)
                .multipliedBy(CGFloat(pages.count))
        }
        
        // Ensure the scroll view is positioned correctly for the current page
        guard let currentPage = currentPage,
                let currentPageIndex = pageIndexDict[currentPage]
        else { return }
        
        scrollView.contentOffset = CGPoint(
            x: scrollView.bounds.width * CGFloat(currentPageIndex),
            y: 0
        )
    }

    private func moveToPage(at index: Int, animated: Bool) {
        scrollView.setContentOffset(
            CGPoint(
                x: scrollView.bounds.width * CGFloat(index),
                y: 0
            ),
            animated: animated
        )
    }
    
    // MARK: UIScrollViewDelegate
    public func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let offsetX = scrollView.contentOffset.x
        _scrollOffset.onNext(offsetX)
    }
    
    public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        let pageIndex = Int(scrollView.contentOffset.x / scrollView.bounds.width)
        currentPage = pages[pageIndex]
        _onMove.onNext(pages[pageIndex])
    }
    
    public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
        let pageIndex = Int(scrollView.contentOffset.x / scrollView.bounds.width)
        currentPage = pages[pageIndex]
        _onMove.onNext(pages[pageIndex])
    }
}

 

아래와 같이 사용할 수 있습니다! 외부 컴포넌트와 연동할땐 Subject를 연동해주면 됩니다.

public class HomeView: BaseView {
    enum Page: CaseIterable, Identifiable, PageType {
        case noti
        case todo
        
        var krName: String {
            switch self {
            case .noti: return "노티"
            case .todo: return "투두"
            }
        }
        
        var id: String {
            return String(describing: "\(self))")
        }
        
        var viewController: UIViewController {
            switch self {
            case .noti:
                let vc = UIViewController()
                vc.view.backgroundColor = .blue100
                return vc
            case .todo:
                let vc = UIViewController()
                vc.view.backgroundColor = .yellow100
                return vc
            }
        }
    }
    
    // MARK: UI component
    lazy var segmentControl = BaseSegmentControl( // custom segment
        source: Page.allCases,
        itemBuilder: { option in
            UILabel().then {
                $0.text = "\(option.krName)"
                $0.setTypo(.heading3)
                $0.textAlignment = .center
            }
        }
    ).then {
        $0.styled(variant: .underline)
    }
    
    lazy var paginableView = PaginableView<Page>(
        pages: Page.allCases,
        firstPage: Page.todo
    )
    
    // MARK: Setup
    public override func setupHierarchy() {
        // segmentControl
        addSubview(segmentControl)
        
        // pageViewController
        addSubview(paginableView)
    }
    
    public override func setupLayout() { 
        segmentControl.snp.makeConstraints {
            $0.top.equalTo(safeAreaLayoutGuide.snp.top)
            $0.leading.equalToSuperview().offset(pagePadding)
            $0.width.equalToSuperview().multipliedBy(0.4)
            $0.height.equalTo(60)
        }
        
        paginableView.snp.makeConstraints {
            $0.top.equalTo(segmentControl.snp.bottom)
            $0.leading.trailing.bottom.equalToSuperview()
        }
    }
    
    // ✅ 연동
    public override func setupBind() { 
        paginableView.onMove
            .bind(to: self.segmentControl.selectedOption)
            .disposed(by: disposeBag)
        
        segmentControl.onChange
            .bind(to: self.paginableView.selectedPage)
            .disposed(by: disposeBag)
    }
}

 

설문조사 등에 해당 뷰를 사용하는 경우, 이전 답변에 따라 다음뷰가 분기되어야하는 경우도 있을 텐데요, 간단하게 selectedPage를 넘겨도 되지만 접근 불가능한 뷰를 아예 삭제하기 위해 updatePages([.todo]) 메서드를 사용할 수 있습니다.

주의할 점은, 이전 다음뷰를 삭제하는 것은 상관없으나 현재 보이고 있는 뷰를 삭제하게되면 바로 그 다음뷰가 보이게 됩니다.

paginableView.updatePages([.todo]) // previous: [.noti, .todo]

 

애니메이션 속도에 차이는 있지만 결과적으로 탑바와 페이지뷰가 같은 인덱스로 동기화되는 것을 볼 수 있습니다. 🎉🎉

 


혹시 비슷한 기능이 필요하신분께 도움이 되길바랍니다.

 

+

제 예시에는 적용되지 않았으나 최근 앱들을 보면 상단 탑바의 인디케이터가 페이지 스크롤 오프셋에 따라 함께 움직이는 뷰도 보았는데요, 

PageView에 scrollOffset을 외부로 전달할 수 있게끔 Observable(var scrollOffset)을 추가적으로 구현해두었으니 이를 이용하시면 될 것 같습니다!!

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/02   »
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
글 보관함