티스토리 뷰
상단 탭바를 통해 페이지를 좌우로 전환하는 화면을 구현할때 어떻게 해야할까요?
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)을 추가적으로 구현해두었으니 이를 이용하시면 될 것 같습니다!!
'iOS > UIKit' 카테고리의 다른 글
[iOS] Tuist 모듈화 후 CollectionView Cell 초기화 오류 (1) | 2024.03.25 |
---|---|
[iOS] 코디네이터 패턴으로 TabBar 만들기 (UIKit TabBar 코드로 만들기) (2) | 2023.01.19 |
- Total
- Today
- Yesterday
- swift
- reactive programming
- 비동기/동기
- coordinator pattern
- Flux
- Flutter
- TestCode
- 프로그래머스
- healthkit
- 노션
- Bloking/Non-bloking
- 소프트웨어마에스트로
- MVC
- SWM
- ios
- RX
- DocC
- Architecture Pattern
- combine
- Swift Concurrency
- MVI
- SwiftUI
- 코디네이터 패턴
- MVVM
- 아키텍쳐 패턴
- GetX
- notion
- design pattern
- 리액티브 프로그래밍
- programmers
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |