iPhone
Xcode
iOS
AutoLayout
Swift

Auto Layoutのみを使ったページングUIの実装パターン

More than 1 year has passed since last update.

概要

Auto Layoutのみを使い、できるだけ少ないコード量でページングUIを実装します(ここで言うページングUIとは、起動時のチュートリアル画面や写真のプレビュー画面のような横スライドのやつです)。また、色々な画面サイズにも適応している状態を目指します。
今回は、表示させるデータの特性によってUIScrollViewやUIPageViewControllerを使い分けてみました。幾つかの方法を試した結果から、現時点でこのやり方が一番楽そうと思ったものを独断と偏見で挙げていきます。

実装パターン

実装パターンは、以下の3つに分類しました。

  1. ページングされるViewの数が少なく、一定の場合 (IBでUIScrollViewと各ページのViewを配置するのみ)
  2. ページングされるViewの数が少なく、上限が決まっていて可変の場合 (1.に加えてコード上で制約を操作する)
  3. ページングされるViewの数が多く、任意の数の場合 (UIPageViewControllerを使う)

ページングされるView数の大小、Viewの数が可変か不変かで分けています。結局はどのケースも3.のやり方で作れてしまうのですが、UIPageViewControllerは少し癖があって扱いにくく、コード量も増えるため、できるだけ使わない方法をとっています。 (ただし、回転にもちゃんと対応する場合にはなんだかんだでUIPageiVewControllerが一番楽かもしれません。この記事では回転の際の実装方法については触れていません。) <- 回転の話を追記しました。

1. ページングされるViewの数が少なく、一定の場合 (IBでUIScrollViewと各ページのViewを配置するのみ)

例えば、チュートリアル画面などのページ数が決まったUIであればこの方法を取っています。UIScrollViewとページングさせたいViewを全てIB上に配置し、それらにAuto Layoutを張っていきます。

IB上でUIScrollViewへ制約を貼るには少し工夫が必要で、Web上ではいくつかの方法が提案されています。

以下の記事ではAutolayoutの観点からUIScrollViewの仕組みを解説されており、背景がわかりやすいです。

Appleが提供するAutoLayoutのドキュメントでも、一章とってUIScrollViewの話をしています。

この記事の中では、Appleのドキュメント内で解説されている方法を取ります。

1-1. UIScrollViewをViewに配置して上下左右に制約を貼る

IB上でUIScrollViewを配置して、上下左右の制約をつけます。UIScrollViewのPaging Enabledにもチェックをつけておきましょう。
スクリーンショット 2016-01-17 11.59.03.png

1-2. UIScrollView上に、スクロール領域の大きさを決定するためのContant Viewを配置

AutolayoutでSubViewsのレイアウトを決める場合、ScrollViewのスクロール領域であるContentSizeはSubViewsに張られているサイズとマージンで決定されます。「Auto Layout Guide: Working with Scroll Views」ではSubViews全体のサイズを決めるために、それ専用のViewをContent ViewとしてUIScrollView配下に置いています。ここではその方法で進めます。

まずContent ViewにあたるUIViewを配置して、上下左右に制約を貼ります。この時点ではContent Viewのサイズがわからないため、ScrollViewのContentSizeが決められなく、Unsatisfiable Constraintsの状態です。
スクリーンショット 2016-01-17 12.00.50.png

そして、Content Viewの高さと幅をScrollViewの高さ・幅に合わせてみましょう。こうすると、UIScrollViewの制約から中身のサイズが画面全体分だと決定でき、エラーが消えます。
スクリーンショット 2016-01-17 12.01.52.png

ただしこのままでは横にページングされないので、幅の制約を削除しておきましょう。幅に対して再度エラーが発生するかと思います。Content Viewへの制約は一旦ここで止めます。
スクリーンショット 2016-01-17 12.02.12.png

1-3. ページングさせたいViewを配置していく

最後に、ページングさせたいViewを配置していきます。わかりやすくするために、色分けした4つのViewを置いてみました。それぞれの上下左右に0pt制約を張っていきましょう。まだUIScrollViewのContentSizeが決定されていない状態です。
スクリーンショット 2016-01-17 12.06.40.png

ContentSizeは最終的にUIScrollViewの幅 + ページングさせたいView数にしたいと思っています。ページングさせたいViewのそれぞれとUIScrollViewの幅を同じにする制約を張っていきましょう。すると、AutoLayoutのエラーが消えます。
スクリーンショット 2016-01-17 12.11.38.png

シミュレータを実行してみると、配置したViewがページンクされることがわかります。UIScrollViewのContentSizeは、Content Viewの高さ制約+上下左右制約とページングさせたいViewの幅制約+上下左右制約でいい感じに決定されました。
スクリーンショット 2016-01-17 12.25.48.png

2. ページングされるViewの数が少なく、上限が決まっていて可変の場合 (1.に加えてコード上で制約を操作する)

APIの通信結果をページングUIで表示したいが仕様で上限が4〜5個と決まっている、といった場合などはこの方法が楽でした(カバーフローとか)。1と同じくページングされるViewをUIScrollViewへ配置していき、それに対してコード上で制約をいじることによって実際のView数を減らしていきます。具体的には表示したくないViewの幅をコード上で0にしてしまい、ContentSizeを変更します。

2-1. ページングさせたいViewとScrollViewの間に張った幅制約のPriorityを下げる

1の方法で作ったページング要素のView達に対して、UIScrollViewとの間に既に張ってある幅制約のPriorityを下げましょう。この後で使わないViewの幅を0にする制約を入れるため、それとのコンフリクトを避けようと思います。
スクリーンショット 2016-01-17 12.35.32.png

スクリーンショット 2016-01-17 16.57.29.png

2-2. ページングさせたくないViewに対して幅0の制約を張っていく

Priorityの設定が完了したら、それらのViewをIBOutlet Collectionでコードとつなぎます。
スクリーンショット 2016-01-17 12.30.09.png

ここから先はコード上で実際に表示させたいデータ量に合わせたページ数の調整をします。表示させたいデータ量よりViewの数が多い場合には、幅0の制約を追加しましょう(Priorityは1000)。

class ViewController: UIViewController {


    @IBOutlet var contentView: [UIView]!

    let dataSource = ["page1", "page2", "page3"]

    override func viewDidLoad() {
        super.viewDidLoad()

        contentView.enumerate().forEach { (index, _) in
            if dataSource.count <= index {

                // iOS9以降であればNSLayoutAnchorを使いましょう。
                // 残念ながらiOS9未満にも対応しなければならない場合はNSLayoutConstraintを仕方なく使いましょう。
                if #available(iOS 9.0, *) {
                    contentView[index]
                        .widthAnchor
                        .constraintEqualToConstant(0)
                        .active = true
                } else {
                    contentView[index].addConstraint(
                        NSLayoutConstraint(
                            item: contentView[index],
                            attribute: .Width,
                            relatedBy: .Equal,
                            toItem: nil,
                            attribute: .NotAnAttribute,
                            multiplier: 1,
                            constant: 0
                        )
                    )
                }
            }
        }

    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()

    }


}

上記コードで実行してみると、ページングされるViewが3つに減っていて、4つめのViewが見えなくなっているのがわかると思います。

スクリーンショット 2016-01-17 19.30.25.png

3. ページングされるViewの数が多く、任意の数の場合 (UIPageViewControllerを使う)

アルバムの写真プレビューなど、要素の数が無限にありうる場合などはUIPageViewControllerを使ってしまうのが良さそうです。ただしUIPageViewControllerはあまり拡張性がなく、組み込みのUIPageControlの位置を直接いじれなかったり、IB上では上にViewを配置できなかったりと不便な面があります。色々なデザインに対応できるよう、UIViewController上へUIPageViewControllerをEmbedして使うのがいいと思います。(例えばpage controlを見せたい場合には、別途UIPageControlを配置するほうがレイアウトに柔軟性をもたせられていいです。)

3-1. コンテナとなるViewControllerの上にUIPageViewControllerを配置する

まずIB上に、コンテナとなるViewControllerと、それの子となるUIPageViewControllerを配置します。ViewControllerの上にContainerViewを置いて、上下左右に制約を与えましょう。ContainerViewにデフォルトでEmbedされているViewControllerは削除して、新たにUIPageViewControllerをEmbed Segueで結びます。一つのStoryboardに2つViewControllerがいるとうざいのでメニューの「Editor -> Refactor to Storyboard...」からStoryBoard Referenceにしてしまいます(iOS8以上がターゲットの場合のみ)。

スクリーンショット 2016-01-18 3.08.34.png

ViewControllerの構成は、

ContainerViewController.storyboardのContainerViewController -[Embed]-> PageViewController.storyboardのPageViewController

となります。

PageViewControllerのインスペクタでNavigationをHorizontal、Transition StyleをScrollにしておきましょう。
スクリーンショット 2016-01-17 21.03.28.png

次に、UIPageViewControllerでページングされるコンテンツのViewControllerを作りましょう。

スクリーンショット 2016-01-17 21.47.45.png

とりあえずここでは、プロパティであるpageIndexがセットされたら、その値にラベル書き換えるというだけの簡単なものにしておきます。

class PageContentViewController: UIViewController {

    var pageIndex: Int? {
        didSet {
            if let pageIndex = pageIndex {
                pageLabel?.text = "\(pageIndex)"
            }
        }
    }

    @IBOutlet weak var pageLabel: UILabel! 

    override func viewDidLoad() {
        super.viewDidLoad()

    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()

    }
}

3-2. UIPageViewControllerに対してページングUIを実装

あとはUIPageViewControllerの定型句的なコードをもりもり書いていきます。ここではContainerとしてViewControllerを間に挟んでいるため、prepareForSegue内でPageViewControllerへデータを渡しましょう。

class ContainerViewController: UIViewController {

    var pageViewController: PageViewController?

    override func viewDidLoad() {
        super.viewDidLoad()
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }


    // MARK: - Navigation
    override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
        if let vc = segue.destinationViewController as? PageViewController {
            pageViewController = vc
            pageViewController?.colors = [UIColor.redColor(), UIColor.blueColor(), UIColor.greenColor()]
        }
    }

}

UIPageViewControllerで各イベント呼び出しに対する表示処理を書いていきます。やることとしては、ページングの度にStoryboardからUIViewControllerを生成し、データを渡して表示させます。

class PageViewController: UIPageViewController, UIPageViewControllerDelegate, UIPageViewControllerDataSource {

    // DataSourceにあたるもの
    var colors = [UIColor]()

    override func viewDidLoad() {
        super.viewDidLoad()

        // UIPageViewControllerのDataSourceとDelegateを設定 (ここではDataSourceのみしか実装していませんが...)
        dataSource = self
        delegate = self

        // 先頭ページの設定
        if !colors.isEmpty {
            let vc = UIStoryboard(name: "PageContentViewController", bundle: nil).instantiateInitialViewController() as! PageContentViewController
            vc.pageIndex = 0
            vc.view.backgroundColor = colors[0]
            setViewControllers([vc], direction: UIPageViewControllerNavigationDirection.Forward, animated: true) { (_) in }
        }
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }

    # MARK:- UIPageViewControllerDataSource

    // 前に戻るページングでフックされるメソッド。いくつか前のページの画面を作って返す必要があります。
    func pageViewController(pageViewController: UIPageViewController, viewControllerBeforeViewController viewController: UIViewController) -> UIViewController? {
        guard let prevVc = viewController as? PageContentViewController else {
            fatalError("not PageContentViewController")
        }

        guard let prevPageIndex = prevVc.pageIndex.flatMap({ $0 - 1 }) where 0 <= prevPageIndex else {
            return nil
        }

        let vc = UIStoryboard(name: "PageContentViewController", bundle: nil).instantiateInitialViewController() as! PageContentViewController
        vc.pageIndex = prevPageIndex
        vc.view.backgroundColor = colors[prevPageIndex]
        return vc
    }

    // 先に進むページングでフックされるメソッド。いくつか前のページの画面を作って返す必要があります
    func pageViewController(pageViewController: UIPageViewController, viewControllerAfterViewController viewController: UIViewController) -> UIViewController? {
        guard let nextVc = viewController as? PageContentViewController else {
            fatalError("not PageContentViewController")
        }

        guard let nextPageIndex = nextVc.pageIndex.flatMap({ $0 + 1 }) where nextPageIndex < colors.count else {
            return nil
        }

                    let vc = UIStoryboard(name: "PageContentViewController", bundle: nil).instantiateInitialViewController() as! PageContentViewController
        vc.pageIndex = nextPageIndex
        vc.view.backgroundColor = colors[nextPageIndex]
        return vc
    }
}

すると以下の様なページングが実装されます。

スクリーンショット 2016-01-18 3.07.27.png

UIPageViewControllerを使った場合、このままでも回転させた時にきちんと正しい位置に来るようになっているかと思います。UIPageViewControllerを使うとなると学習コストが発生して面倒ですが、数が多い時にはページングされるViewの管理をせずに済むため非常に楽かと思います。

最後に

1.のケースについて解説している記事はWeb上に多くあるのですが、2.や3.のケースについてまとめている記事は見かけなかったため分類してみました。実装のお役に立てればと思います。
今回はページングされるViewの中身(実際のコンテンツ)についてはあまり考慮せず、とにかくコードで実装しなくて済む方法を検討しています。コンテンツが複雑だとか、スクロールに合わせてアニメーションをしたいとかであれば、別の方法をとったほうが楽かもしれません(そういったものを実装していないためわかりませんが...)。
また、回転にも対応しなければならない場合、UIScrollViewを使ったやり方ではコード上で座標計算をしてスクロール位置を制御する必要があるかと思います(が、何か座標処理をしなくて済む方法があったら教えていただきたいです)。

ちなみに1., 2.のやり方で回転対応

UIScrollViewに制約を貼ってページングを実現する場合には、現在地から回転後の幅を元にoffsetを計算すれば(回転というかサイズ変更に)対応できます。(iOS7に対応する場合は別のメソッドをオーバーライドする必要がある。)

    override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator) {
        let positionX = (self.scrollView.contentOffset.x / self.scrollView.frame.width) * size.width
        super.viewWillTransitionToSize(size, withTransitionCoordinator: coordinator)
        coordinator.animateAlongsideTransition({ [weak self] (context) in
            let position = CGPointMake(positionX, 0)
            self?.scrollView.setContentOffset(position, animated: false)
        }) { (_) in }
    }

参考資料

UIScrollViewのAuto Layoutでの扱い方は以下の資料が参考になります。

UIPageViewControllerについては以下の資料を見てください。(※ Objective-C)

少し古いですが、AutoLayoutに特化した本としては以下のものがあります。