しおメモ

雑多な技術系ブログです。ニッチな内容が多いです。

今さらUIPageViewController詳解

UIPageViewControllerが登場したのが古いこともあり、公式ドキュメントや、ネットに出ているの情報だと、いまいち各メソッドの挙動までは掴みづらかったので、改めてまとめてみました。

UIPageViewControllerの使い所

電子書籍リーダーのようなスタンダードな使い方から、アプリのインストール時のガイド、タブUIなど、スワイプでView Controllerを切り替えたい際に使えます。

写真のプレビューなど、中のViewだけ切り替えたい時には、UISwipeGestureRecognizerなどを使うという方法もあるので、 要素の数や内容によってはUIPageViewController適さない場面もあります。

UIPageViewController.setViewControllers

UIPageViewControllersetViewControllersviewDidLoadの時とページジャンプなどスワイプ以外で表示するView Controllerを変更したい際に利用します。
第一引数の[UIViewController]の要素数は、表示するページの枚数になっている必要があります。

UIPageViewControllerDataSource

DataSourceの実装は必須となっていて、そのページの前後のView Controllerを返す2つのメソッドを定義する必要があります。

  • pageViewController(_:viewControllerBefore:)
  • pageViewController(_:viewControllerAfter:)

developer.apple.com

このメソッドが呼ばれるタイミングは、スワイプが完了してView Controllerが切り替わった後と、setViewControllersなどのあとのスワイプ開始時になります。

タイミングが二つある理由は、UIPageViewControllerはおそらくアニメーションのために、表示するページの前後のView Controllerを事前に知る必要があるためです。

スワイプ完了時

こちらはスワイプした方向の次のView Controllerを取得します。
例えば、1ページ目から2ページ目にスワイプした場合、pageViewController(_:viewControllerAfter:)の方が呼ばれ、3ページ目にあたるView Controllerを取得しにいきます。

ただし、こちらはこのタイミングで呼ばれず、下記のスワイプ開始時のタイミングで呼ばれることもあります。(条件特定できず...)

スワイプ開始時

ページジャンプなど、setViewControllersした直後や先ほどタイミングで呼ばれなかった場合は、前後のView Controllerがわからない状態なので、スワイプ開始時に、pageViewController(_:viewControllerBefore:)pageViewController(_:viewControllerAfter:)両方を呼びます。
スワイプが完了時のタイミングでも呼ばれるので、これらのメソッドはBeforeとAfter合わせて、計3回呼ばれることになります。

注意点

これらを踏まえると、こちらでページ切り替わりのイベントを発行したり、indexをインクリメント、デクリメントするのはDataSourceの意図するところではないため、あまり良くない気がします。

UIPageViewControllerDelegate

先ほどのページ切り替わりのイベントやindexの変更を行いたい場合は、こちらで行うことになります。

  • pageViewController(_:willTransitionTo:)
  • pageViewController(_:didFinishAnimating:previousViewControllers:transitionCompleted:)

transitionの最初と最後にこれらのメソッドが呼ばれるので、こちらを利用します。

切り替わりのイベントを発行する例

例えば1ページのみ表示する場合で、そのページのindexを引数としてイベントを発行する場合、このようになります。

func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
    // toIndexの部分はindexに変換する適当な処理
    guard let index = pendingViewControllers.first?.toIndex() else { return }
    nextViewControllerIndex = index
}

func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
    if completed {
        // 適当なイベント
        viewModel.changeSelectedPageIndex(to: nextViewControllerIndex)
    }
}

スワイプし始めて、遷移せず戻る場合などもあるので、completedで遷移したかを確認します。
また、pageViewController.viewControllersなどでも現在のView Controllerがとれるので、 開始時に特にすることがなければ、終了時のメソッドだけでもOKです。

実装例

実際のコードから引っ張っているので、若干説明不足なところもあると思います。
コードからView ControllerをinstantiateViewControllerで毎回生成するケースです。

PageViewController

enumにStoryboard Identifierをまとめています。

final class SamplePageViewController: UIPageViewController {
    enum ViewControllerIdentifier: String, CaseIterable {
        case dummy1 = "Dummy1VC"
        case dummy2 = "Dummy2VC"
        case dummy3 = "Dummy3VC"

        static func index(of value: String) -> Int? {
            return ColorPickerPageViewController.ViewControllerIdentifier(rawValue: value)
                .flatMap(ViewControllerIdentifier.allCases.firstIndex)
        }
        func instantiate() -> UIViewController? {
            let storyboard = UIStoryboard(name: "Main", bundle: nil)
            return storyboard.instantiateViewController(withIdentifier: self.rawValue)
        }
    }
    override func viewDidLoad() {
        super.viewDidLoad()

        dataSource = self
        delegate = self

        setViewControllers(
            [ViewControllerIdentifier.dummy1.instantiate()].compactMap({ $0 }),
            direction: .forward,
            animated: true,
            completion: nil
        )
    }
}

UIPageViewControllerDelegate

先ほどとほぼ同じです。

extension SamplePageViewController: UIPageViewControllerDelegate {
    func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
        guard let index = pendingViewControllers.first?.restorationIdentifier
            .flatMap(ViewControllerIdentifier.index) else { return }
        nextViewControllerIndex = index
    }
    func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
        if completed {
            viewModel.selectedPageIndex = nextViewControllerIndex
        }
    }
}

UIPageViewControllerDataSource

extension SamplePageViewController: UIPageViewControllerDataSource {
    private enum NeighborDirection {
        case previous, next
    }
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        return getNeighborViewController(for: viewController, direction: .previous)
    }
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        return getNeighborViewController(for: viewController, direction: .next)
    }

    private func getNeighborViewController(for viewController: UIViewController, direction: NeighborDirection) -> UIViewController? {
        guard let index = viewController.restorationIdentifier
                .flatMap(ViewControllerIdentifier.index) else { return nil }
        switch direction {
        case .previous:
            guard index > 0 else { return nil }
            return ViewControllerIdentifier.allCases[index - 1].instantiate()
        case .next:
            guard index < ViewControllerIdentifier.allCases.count - 1 else { return nil }
            return ViewControllerIdentifier.allCases[index + 1].instantiate()
        }
    }
}