iOSアプリで紙芝居式オンボーディング画面をUIPageViewControllerで作る

iOSアプリでよく見かける紙芝居式のオンボーディング画面を [UIPageViewController](https://developer.apple.com/documentation/uikit/uipageviewcontroller) で作るメモです。もっともシンプルな例だと思うのに、実装に :angry:**不満ポイント** がたくさんなのでそれもふまえて。

### 作りたいもの
- アプリの説明が書かれた数枚のページをスワイプ操作で進める（戻る）
- 現在のページ位置は画面下部のページインジケータのドットでわかる

つまり、こんなやつです。色使いが気持ち悪いですが、ビューの構成をわかりやすくするためなので我慢してください :bow:。
![ezgif-3-f559cd3f4f55.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/16508/3d0f745d-89ba-a1f4-fe32-1893cc906c61.gif)

### 作り方

#### 準備
`UIPageViewController` を継承したクラスを作り、紙芝居の各ページとなる `UIViewController` を用意します。ここではページが変わっていることがわかるように背景が異なるだけのページを３枚用意し、最初は１枚目を表示するようにします。

```swift
class PageViewController: UIPageViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // 紙芝居１ページ目を設定します
        self.setViewControllers([pages[0]], direction: .forward, animated: true, completion: nil)        
    }

    /// 紙芝居の各ページとなる UIViewController
    private lazy var pages: [UIViewController] = {
        return [createPage(color: .red), createPage(color: .blue), createPage(color: .green)]
    }()
    
    private func createPage(color: UIColor) -> UIViewController {
        let page = UIViewController()
        page.view.backgroundColor = color
        return page
    }
}
```

:angry: **ここが不満！**
`setViewControllers` メソッドは複数の `UIViewController` を設定できるので、３ページ分突っ込めば良さそうに見えるのに、実際には表示する `UIViewController` のみを設定する。

#### ページの切り替え
ページの切り替えを実装するためには `UIPageViewControllerDataSource` プロトコルを実装します。実装必須のメソッドが２つあり、このメソッドの引数で指定されたページの前後にくるページを返すようにします。

```swift
class PageViewController: UIPageViewController, UIPageViewControllerDataSource {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.dataSource = self
        self.setViewControllers([pages[0]], direction: .forward, animated: true, completion: nil)
    }

    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        if let pageIndex = pages.firstIndex(of: viewController), pageIndex - 1 >= 0 {
            return pages[pageIndex - 1]
        }
        return nil
    }
    
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        if let pageIndex = pages.firstIndex(of: viewController), pageIndex + 1 < pages.count {
            return pages[pageIndex + 1]
        }
        return nil
    }

}
```

:angry: **ここが不満！**
メソッドの引数で指定された `UIViewController` から前後のページを判断するのは面倒。ここでは用意した `pages` からインデックスを求めていて回りくどい。

#### 現在のページ位置の表示
ここまでの実装では画面下部にあるページインジケータの表示は行われません。これを表示させるためにはドキュメントにはこう書かれています。

```swift
// A page indicator will be visible if both methods are implemented, transition style is 'UIPageViewControllerTransitionStyleScroll', and navigation orientation is 'UIPageViewControllerNavigationOrientationHorizontal'.
// Both methods are called in response to a 'setViewControllers:...' call, but the presentation index is updated automatically in the case of gesture-driven navigation.
```

まず `UIPageViewController` をインスタンス化するときに次のようにトランジションスタイルとナビゲーションの向きを指定してやります。

```swift
let pageViewController = PageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
```

そして先ほどの `UIPageViewControllerDataSource` プロトコルにあるオプショナルな２つのメソッドを実装します。これらのメソッドは上記にあるとおり `setViewControllers` が呼ばれたときに呼び出されます。なので `presentationIndex(for:)` の方は１ページ目となる０を指定しています。

```swift
class PageViewController: UIPageViewController, UIPageViewControllerDataSource {

    func presentationCount(for pageViewController: UIPageViewController) -> Int {
        return pages.count
    }
    
    func presentationIndex(for pageViewController: UIPageViewController) -> Int {
        return 0
    }

}
```

:angry: **ここが不満！**
スワイプ操作に加えて、ページを進めるボタンを用意するみたいなケースもよくありますが、そのボタンタップ時に改めて `setViewController` を呼ぶ必要があり、このあたりの実装が厄介。例えばページ１からページ２に進めるならこうです。

```swift
setViewControllers([pages[1]], direction: .forward, animated: true, completion: nil)
```

この場合、現在のページを把握して次のページに移動する必要がありますが、こんなコードになります。。。

```swift
if let currentPage = viewControllers.first, let currentPageIndex = pages.first(of: currentPage), currentPageIndex+1 < pages.count {
   setViewControllers([pages[currentPageIndex+1]], direction: .forward, animated: true, completion: nil)
}
```

さらに先ほど書いたとおり `setViewControllers` を実行すると `presentationIndex(for:)` で適切なインデックスを返さなければなりません。これはこんなコードになってしまいます。。。

```swift
func presentationIndex(for pageViewController: UIPageViewController) -> Int {
    if let currentPage = viewControllers.first, let currentPageIndex = pages.first(of: currentPage) {
        return currentPageIndex
    }
    return 0
}
```

#### ページインジケータの色を変える
ページインジケータは [UIPageControl](https://developer.apple.com/documentation/uikit/uipagecontrol) というコンポーネントですが、画面の背景色やアプリのカラースキームによって色を変えたくなります。これは `UIPageControll` の下記のプロパティで変更することができます。

- `backgroundColor` (`.yellow`)
- `pageIndicatorTintColor` (`.lightGray`)
- `currentPageIndicatorTintColor` (`.purple`)

_()内は最初の例のGIFアニメで設定している色なので、どこの色が変わるのか確認できます。_

しかしながら、`UIPageViewController` が表示する `UIPageControl` には **アクセスできません:angry:(不満爆発)**。

設定するには `UIPageViewController` のビューの階層をたどって `UIPageControl` を見つけて色を設定するか、`UIPageControl.appearance()` でデフォルトの色設定を変えてやるしかありません。今回は後者で実装しました。ふぅ。。。

```swift
class PageViewController: UIPageViewController, UIPageViewControllerDataSource {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.dataSource = self
        self.setViewControllers([pages[0]], direction: .forward, animated: true, completion: nil)
        
        UIPageControl.appearance().backgroundColor = .yellow
        UIPageControl.appearance().pageIndicatorTintColor = .lightGray
        UIPageControl.appearance().currentPageIndicatorTintColor = .purple
    }

}
```

### 全コード

今回の全コードをまとめたのがこちらです。Xcode 11.2 の Playground で確認しています。

```swift
import UIKit
import PlaygroundSupport

class PageViewController: UIPageViewController, UIPageViewControllerDataSource {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.dataSource = self
        self.setViewControllers([pages[0]], direction: .forward, animated: true, completion: nil)
        
        UIPageControl.appearance().backgroundColor = .yellow
        UIPageControl.appearance().pageIndicatorTintColor = .lightGray
        UIPageControl.appearance().currentPageIndicatorTintColor = .purple
    }
    
    // MARK: - UIPageViewControllerDataSource

    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        if let pageIndex = pages.firstIndex(of: viewController), pageIndex - 1 >= 0 {
            return pages[pageIndex - 1]
        }
        return nil
    }
    
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        if let pageIndex = pages.firstIndex(of: viewController), pageIndex + 1 < pages.count {
            return pages[pageIndex + 1]
        }
        return nil
    }
    
    func presentationCount(for pageViewController: UIPageViewController) -> Int {
        return pages.count
    }
    
    func presentationIndex(for pageViewController: UIPageViewController) -> Int {
        return 0
    }

    // MARK: - Private

    /// 紙芝居の各ページとなる UIViewController
    private lazy var pages: [UIViewController] = {
        return [createPage(color: .red), createPage(color: .blue), createPage(color: .green)]
    }()
    
    private func createPage(color: UIColor) -> UIViewController {
        let page = UIViewController()
        page.view.backgroundColor = color
        return page
    }
    
}

PlaygroundPage.current.liveView = PageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
```