LoginSignup
9
3

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-12-11

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

作りたいもの

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

つまり、こんなやつです。色使いが気持ち悪いですが、ビューの構成をわかりやすくするためなので我慢してください :bow:
ezgif-3-f559cd3f4f55.gif

作り方

準備

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

class PageViewController: UIPageViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // 紙芝居1ページ目を設定します
        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 を設定できるので、3ページ分突っ込めば良さそうに見えるのに、実際には表示する UIViewController のみを設定する。

ページの切り替え

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

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 からインデックスを求めていて回りくどい。

現在のページ位置の表示

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

// 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 をインスタンス化するときに次のようにトランジションスタイルとナビゲーションの向きを指定してやります。

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

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

class PageViewController: UIPageViewController, UIPageViewControllerDataSource {

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

    func presentationIndex(for pageViewController: UIPageViewController) -> Int {
        return 0
    }

}

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

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

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

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:) で適切なインデックスを返さなければなりません。これはこんなコードになってしまいます。。。

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

ページインジケータの色を変える

ページインジケータは UIPageControl というコンポーネントですが、画面の背景色やアプリのカラースキームによって色を変えたくなります。これは UIPageControll の下記のプロパティで変更することができます。

  • backgroundColor (.yellow)
  • pageIndicatorTintColor (.lightGray)
  • currentPageIndicatorTintColor (.purple)

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

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

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

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 で確認しています。

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)
9
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
3