SwiftでContainerViewとStoryboardをフル活用して複雑なUIを実現する際の実装ポイントまとめ

  • 243
    いいね
  • 0
    コメント

1. はじめに

週末に参加した勉強会で、「UI作成の上でコードで組むか?Storyboardを活用するか?」という話題で盛り上がりました。Swift初学者向けの書籍の場合ほとんどの書籍では画面のレイアウトにStoryboardを使った手法がケースとして多いのですが、実務では特に大規模な開発になる場合コードでUIも構築するケースの方が多いかと思います。「Storyboardでは複雑なUIを作成するのは難しいのでは?」と疑問に思われるかもしれませんが、ContainerViewと組み合わせることによって画面遷移がイメージしやすく、他のアプリでもあるような動きを実現することも十分に可能です。

私自身は個人でもアプリをリリースしていますが、基本的にはアプリ開発やサンプル作成等を行う際にはStoryboardをメインに使用していますし、Cocoapods等のライブラリを活用する際にもできるだけStoryboardとの相性を考えて選定するようにしています。

また複雑なUIや昨今の流行のUI挙動に近しいものを実装する際には、ContainerViewを活用してライブラリや参考アプリの挙動に近しいものを作成するスタイルをとっていることが多いです。そうこうしているうちに自分の中で溜まってきた知見やTIPSを改めて棚卸しをしてみたいと感じ基本事項のおさらいから、実際のサンプル作成を通じてのレイアウトを作成する部分におけるポイントとなる部分を自分なりにまとめてみました。

今回は以前にこのあたりの部分について調べた際に書いていたノートの内容も掲載していく形にしようと思います。もしかしたら文字が若干小さくて読みづらいかもしれませんがその点はご容赦ください。

※こちらのサンプルはPullRequestやIssue等はお気軽にどうぞ!

このサンプルのSwift3バージョンの対応&変更点解説は別途記事にまとめる予定です!
(Swift2.3ではおそらく問題なく動くはずです。)

2. 今回の参考アプリとサンプル概要について

今回はメインコンテンツとメニューをContainerViewでうまく独立したViewControllerに分離した上での見せ方の工夫やUITableViewのヘッダー部分にContainerViewを使ったり、スライド式のコンテンツ等、ちょっとしたひと工夫を仕込んでみる感じに仕上げてみました。

☆2-1. 参考アプリ

今回のサンプルで参考にしたアプリは下記のアプリになります。

こちらは海外製の最新ガジェットのニュースと通販販売を行っているアプリになります。今回の左右のボタンを押すと下に隠れていたメニューがスッと表示されるような動きはこちらを参考にしました。やわらかな色合いながらも写真を大胆に見せる感じのデザインやメニューを開いた際のちょっとした小気味良いアニメーション等、UI開発者のこだわりが感じられます。(まあ私はもっぱらウインドウショッピング状態ではございますが。。。)

☆2-2. 今回のサンプルについて

今回はデザイン要素はあまりない感じで骨組みだけのような感じにしてあります。今回のキモの部分は

  1. 「ContainerViewを活用した画面遷移」
  2. 「ContainerViewと繋がってるViewControllerとContainerViewを配置したViewControllerとの親子関係」

になります。

sample_view.jpg

環境やバージョンについて:

  • Xcode7.3
  • Swift2.2
  • MacOS X El Capitan (Ver10.11.6)

※今回は特にライブラリは使用していないので、バージョンアップでもそんなに派手にコケないはず…?

また、左右のメニューコンテンツの開閉に関しては、ContentListViewController.swiftのナビゲーションメニューのボタンから左右メニューのContainerViewが配置されているViewController.swiftに仕込んである開閉処理のメソッドを実行するような仕組みにしています。

※この部分の開閉処理に関しては、ViewController.swiftにGestureRecognizerやタッチイベントの処理を仕込むことによって、開閉の際の工夫ができるようになるので、もし気になる方は是非試してみてはいかがでしょうか。

3. ContainerViewと基本のおさらいとInterfaceBuilderのContainerViewに関しての操作方法など

まずはContainerViewに関する基本事項に関して自分のノートにまとめた部分のキャプチャをここで掲載しておきます。

☆3-1.Storyboard上のViewControllerにContainerViewを配置した際に起こること

from_interface_builder.png

Container(入れ物)の名の通りViewControllerの入れ物となるようなものになります。
またContainerViewに「embed segue」で繋がっているViewControllerとContainerViewを配置したViewControllerは親子関係を持ちます。
IBOutletで接続すると@IBOutlet weak var mainContents: UIView!のような形になります。

☆3-2.ContainerViewから任意のViewControllerに「embed segue」で表示するViewControllerを変更する

how_to_embed_segue.png

InterfaceBuilderでContainerViewを持ってくる時にすでにViewControllerが繋がった状態になっていますが、こちらは上記の図のようにすることで任意のコントローラーにembed segueを付け直すことができます。

他にも、ContainerViewをフル活用したサンプルに関しては記事を書いていますのでよろしければ参考にしてみて下さい。

4. 今回のサンプルの画面遷移図と親子関係図

今回のサンプルアプリの画面遷移とStoryboardに配置されている各ViewControllerクラスの親子関係をまとめてみました。
大枠の画面遷移やContaierViewでembed segueで紐付けされているViewControllerの関係等が整理できれば、後はViewController同士の親子関係を整理しながら「子から親 or 親から子」のステータスを変更する処理、データの更新に関するロジックや部分細かいモーションや画面等の構築を行っていくような流れになります。

☆4-1.画面遷移図

この画面の画面遷移図は下記のようになります。

storyboard_image.png

一番おおもととなるViewController.swiftに対応するStoryBoardには、下記の4つの要素が配置されています。

  • 左メニュー表示用のContainerView
  • 右メニュー表示用のContainerView
  • コンテンツ表示用のContainerView
  • 左右メニュー表示状態の際にコンテンツ表示用のContainerViewに重ねる

(1)メニュー自体の動き部分とメニューの詳細な実装でのメリット

左右メニューのContainerViewで表示される内容は、embed segueで紐付けされているLeftMenuViewController.swift(RightMenuViewController.swift)になります。

  • メニュー自体の幅や高さ・アニメーション → ViewController.swiftの役割
  • メニューのUIパーツの配置やボタン押下処理 → LeftMenuViewController.swift(RightMenuViewController.swift)の役割

という感じで、ViewControllerを分けてしまうことができるので、それぞれの役割を明確にした上で実装ができるのでコード量の節約もできますしとても見通しをよくすることができます。

(2)embed segueでNavigationControllerと「root view controller」で接続したViewControllerクラスと接続する

コンテンツ表示用のContainerViewで表示したい内容は、ContainerViewから任意のViewControllerに対してembed segueでつなぐことによってContainerViewに表示するViewControllerを決めることができます。

今回はメインコンテンツを表示してなおかつ左右にスライドするContainerViewに対してNavigationControllerとつなげた状態のViewControllerをembed segueでつないでいます。

こうすることによって、メニューやコンテンツ表示用のViewControllerとNavigationControllerと繋がっているメインコンテンツ用のViewController独立した状態で考えることができますし、以降の下層コンテンツ用のViewControllerについてもさらにそこからpushでsegueを張れば良いのでぱっと見もかなり見通しが良くなります。

このようにStoryboardととても相性がよく画面遷移を直感的に作成できたり、それぞれの画面ごとにViewControllerを分割して整理できるのはとても大きなメリットです。
ただ無計画な多様はStoryboardの煩雑化やViewControllerファイル数の必要以上の増加にもなるので、整理した上で使うとよいと思います。

☆4-2.親子関係図

この画面で使用されている親子関係を図にしてまとめると下記のようになります。

child_to_parent.png

ContainerViewの特性としては、前述の通りにはなりますがStoryboard上にあるViewControllerに配置した時点で親子関係を認識してくれるようになります。例えば今回のようにメインコンテンツのContainerViewに表示されているViewControllerに配置されているナビゲーションバーの左右にあるボタンを押すとメインコンテンツがスライドして表示する動きは、子のViewControllerからContainerViewを配置している親のクラスのメソッドを実行して、メニューを開いた状態にするという処理になります。

1. 子のViewControllerから親のViewControllerのメソッドを実行する

子のViewControllerから親のViewControllerのメソッドを実行する手順としては、子のViewController内で下記のような手順で実装を行います。

XXX.swift
//手順1: 親のViewController型のインスタンスを作成
let targetViewController = self.parentViewController as! ParentViewController
//手順2: 親のViewControllerに定義されているインスタンスメソッドを実行
targetViewController.doSomething()

※孫のViewControllerから親のViewControllerのメソッドを実行する際は手順1の部分をlet targetViewController = self.parentViewController?.parentViewController as! ParentViewControllerとしてあげればOKです。

2. 親のViewControllerから子のViewControllerのメソッドを実行する

親のViewControllerから子のViewControllerのメソッドを実行する手順としては、親のViewController内で下記のような手順で実装を行います。
子のViewControllerから親のViewControllerのメソッドを呼ぶ際には子からみて親は1つなのですが、親ViewControllerから見た子ViewControllerは必ずしも1つであるとは限りませんのでその点は気をつけないといけません。

XXX.swift
//手順1: 子のViewController型のインスタンスを作成
let targetViewController = self.childViewControllers[index] as! ChildViewController

//(補足)子のViewController達は[ViewController]型で格納されているので、indexはその順番の値

//手順2: 親のViewControllerに定義されているインスタンスメソッドを実行
targetViewController.doSomething()

Storyboardで配置した際はぱっと見ではどのような順番で格納されているかわからないので、下記のような処理を実行してコンソール等で順番を確認するようにすると良いと思います。

XXX.swift
self.childViewControllers.forEach { vc in
    print(vc)
}

上の処理ではコンソールにこんな感じで表示されます。

<プロジェクト名.indexが0のコントローラー名: xxxx…>
<プロジェクト名.indexが1のコントローラー名: xxxx…>
<プロジェクト名.indexが2のコントローラー名: xxxx…>
…

ContainerViewの親子関係を把握した上で「子から親」ないしは「子から親」のメソッドの実行をする方法を理解しておけば、うまく組み合わせることによってより複雑な処理や表現をつくることができるようになります。

5. 左右開閉部分などのContainerViewを使った部分の実装ポイント

ここではこのサンプルを実装するにあたってポイントなる部分をかいつまんで解説していければと思います。ぱっと見複雑な処理を行っているように見えるかもしれませんが、上記で説明したポイントをしっかり押さえておけばさほど困難ではないかと思います。

☆5-1.左右開閉部分の処理

まずはおおもとなるViewControllerにステータスを管理する変数var mainContentsStatus: MainContentsStatusを用意し、この値を切り替えによってメインコンテンツを表示するContainerViewとこのコンテンツに重なる透明ボタンの位置をアニメーションでずらすメソッドを作成します。

ViewController.swift
/**
 * ステータスに応じてメインコンテンツの開閉を決定する
 * 左右開閉状態とコンテンツ表示状態のハンドリングを行う
 * ※子のコンテナに配置したボタン等からも実行できるように切り出してある
 */
func handleMainContentsContainerState(status: MainContentsStatus) {

    //(Case1)左側メニューを開くとき
    if status == MainContentsStatus.LeftMenuOpened {

        //処理内容:透明ボタンの活性化&左側のメニューを表示させるためにメインコンテンツ用コンテナと透明ボタンを右に100ずらす

    //(Case2)右側メニューを開くとき
    } else if status == MainContentsStatus.RightMenuOpened {

        //処理内容:透明ボタンの活性化&右側のメニューを表示させるためにメインコンテンツ用コンテナと透明ボタンを左に280ずらす

    //(Case3)コンテンツ表示状態にする
    } else if status == MainContentsStatus.ContentsDisplay {

        //処理内容:透明ボタンの非活性化&メインコンテンツ用コンテナと透明ボタンを元の位置に戻す

    }
}

このメソッドは上記の画面遷移図のContentListViewController.swiftから実行させてメインコンテンツのContainerViewの位置を変えるように実装をします。

ContentListViewController.swift
/**
 * viewDidLoad()内にナビゲーションバーのボタンをタップした際の設定を下記のように設定しておく
 * 左メニュー → leftMenuButton = UIBarButtonItem(title: "🔖", style: .Plain, target: self, action: #selector(ContentListViewController.leftMenuButtonTapped(_:)))
 * 右メニュー → rightMenuButton = UIBarButtonItem(title: "≡", style: .Plain, target: self, action: #selector(ContentListViewController.rightMenuButtonTapped(_:)))
 *
 */

//左メニューボタンを押した際のアクション
func leftMenuButtonTapped(sender: UIBarButtonItem) {

    /**
     * 親コントローラーのメソッドを呼び出して左コンテンツを開く
     * このコントローラーはUINavigationControllerDelegateを使っているので、
     * 「ViewController(親) → NavigationController(子) → ContentListViewController(孫)」
     * という図式になります。
     *
     */
    let viewController = self.parentViewController?.parentViewController as! ViewController
    viewController.handleMainContentsContainerState(MainContentsStatus.LeftMenuOpened)
}

//右メニューボタンを押した際のアクション
func rightMenuButtonTapped(sender: UIBarButtonItem) {

    /**
     * 親コントローラーのメソッドを呼び出して右コンテンツを開く
     * このコントローラーはUINavigationControllerDelegateを使っているので、
     * 「ViewController(親) → NavigationController(子) → ContentListViewController(孫)」
     * という図式になります。
     *
     */
    let viewController = self.parentViewController?.parentViewController as! ViewController
    viewController.handleMainContentsContainerState(MainContentsStatus.RightMenuOpened)
}

今回はメインコンテンツを表示しているContainerViewでは上記のような親子関係になっています。

☆5-2.UITableViewのヘッダーにContainerViewの内容を表示させる

今回はUITableViewを使用していますが、UITableViewのヘッダーに一工夫を加えて少しリッチな動きをするヘッダーにカスタマイズを行っています。UITableViewのヘッダーのカスタマイズ処理をUITableView側の処理とは独立させた形にしてみました。
ヘッダーで行いたい処理やアニメーションなどをContainerViewに繋がっているViewController側に実装します。そしてその後にUITableViewDelegateのfunc tableView(tableView: UITableView, viewForHeaderInSection section: Int) -> UIView?メソッドに先ほどのContainerViewを追加するような処理をすれば実現できます。

ContentListViewController.swift
/**
 * UITableViewのheaderに入れるContainerView
 * (ポイント)このContainerViewに関してはAutoLayoutで制約を張らずにこのViewControllerに置いているだけ
 * → TableViewHeaderをするタイミングでaddSubViewをして、CGRectMakeでサイズを決めうちする。
 * 
 * @IBOutlet weak var listTableHeader: UIView!
 *
 */

extension ContentListViewController: UITableViewDelegate {

    //・・・(省略)・・・

    //テーブルヘッダに関する処理 ※任意
    func tableView(tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {

        //ヘッダーが必要な物はここにaddSubView → Header用のContainerを突っ込む
        let headerViewBase = UIView()
        headerViewBase.frame = CGRectMake(
            CGFloat(0),
            CGFloat(0),
            CGFloat(DeviceSize.screenWidth()),
            CGFloat(180)
        )
        headerViewBase.backgroundColor = UIColor.redColor()
        headerViewBase.addSubview(listTableHeader)
        headerViewBase.multipleTouchEnabled = true
        listTableHeader.multipleTouchEnabled = true
        return headerViewBase
    }

    //セクションヘッダー高さ ※任意
    func tableView(tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
        return CGFloat(180)
    }

}

上記の方法はUITableViewのヘッダー部分をContainerViewにして全く役割が異なるスクロールを同じViewController内に共存させるような形の一例になります。この他にもContainerViewを応用させて色々とUIをカスタマイズしていると面白いかと思います。

補足1. AutoLayoutとアニメーションに関する所感

今回のサンプルでは、アニメーションに関してはAutoLayoutの制約を付け替える方法で行う部分と部品サイズを決めうちしてアニメーションで再配置する方法が混在しています。本当はAutoLayoutで制約を付け替えて実現する方が望ましいアプローチではありますが、制約が必要以上に複雑になってしまうような場合等では場合に応じて使い分けを行っても良いかと思います。

1. AutoLayoutで制約の付け替えでアニメーションを行っている部分

ContentDetailViewController.swift
//ボトムのポップアップを開く
func openBottomPopup() {
    topPopupConstraint.constant = 17
    bottomPopupConstraint.constant = 0
    UIView.animateWithDuration(0.26, delay: 0, options: UIViewAnimationOptions.CurveEaseOut, animations:

        //変更したAutoLayoutのConstant値を適用する
        {
            self.view.layoutIfNeeded()
        }, completion: { finished in
        }
    )
}

//ボトムのポップアップを閉じる
func closeBottomPopup() {
    topPopupConstraint.constant = 137
    bottomPopupConstraint.constant = -120
    UIView.animateWithDuration(0.26, delay: 0, options: UIViewAnimationOptions.CurveEaseOut, animations:

        //変更したAutoLayoutのConstant値を適用する
        {
            self.view.layoutIfNeeded()
        }, completion: { finished in
        }
    )
}

2. 部品サイズを決めうちしてアニメーションで再配置する部分

ViewController.swift
//レイアウト処理が完了した際の処理
override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()

    /**
     * AutoLayoutで設定したパーツのX座標・Y座標・幅・高さを再定義する
     * 今回は制約をいじらない方針
     * (手順1)AutoLayoutで初期状態に対する制約をつける
     * (手順2)初期状態の制約を元にCGRectMakeで位置を再配置する
     *
     */

    //初期状態の制約(上下左右:0)
    mainContents.frame = CGRectMake(
        CGFloat(mainContents.frame.origin.x),
        CGFloat(mainContents.frame.origin.y),
        CGFloat(mainContents.frame.width),
        CGFloat(mainContents.frame.height)
    )
    ・・・(省略)・・・
}

/**
 * ステータスに応じてメインコンテンツの開閉を決定する
 * 左右開閉状態とコンテンツ表示状態のハンドリングを行う
 * ※子のコンテナに配置したボタン等からも実行できるように切り出してある
 */
func handleMainContentsContainerState(status: MainContentsStatus) {

    //(Case1)左側メニューを開くとき
    if status == MainContentsStatus.LeftMenuOpened {

        mainContentsStatus = MainContentsStatus.LeftMenuOpened
        leftSideMenu.alpha = 1

        UIView.animateWithDuration(0.26, delay: 0, options: UIViewAnimationOptions.CurveEaseOut, animations:

            //メインコンテンツと透明ボタンの位置を左へずらす(移動完了時に透明ボタンを有効にする)
            {
                self.mainContents.frame = CGRectMake(
                    CGFloat(MainContentsSwipeLimitSetting.leftSwipeLimit),
                    CGFloat(self.mainContents.frame.origin.y),
                    CGFloat(self.mainContents.frame.width),
                    CGFloat(self.mainContents.frame.height)
                )
                self.wrappingButton.frame = CGRectMake(
                    CGFloat(MainContentsSwipeLimitSetting.leftSwipeLimit),
                    CGFloat(self.wrappingButton.frame.origin.y),
                    CGFloat(self.wrappingButton.frame.width),
                    CGFloat(self.wrappingButton.frame.height)
                )
            }, completion: { finished in
                self.wrappingButton.alpha = 1
                self.wrappingButton.enabled = true
            }
        )
    ・・・(省略)・・・
    }

}

特にAutoLayoutとアニメーションの部分は、なかなかハマりやすいポイントになるので、AutoLayoutの制約の原理やレイアウト表示までのライフサイクルを押えた上で、下記のリンク等を参考にしてみると理解が深まるのではないかと思います。

(レイアウトに関すること全般に関することの参考)

(AutoLayoutとアニメーションに関すること参考)

補足2. UINavigationBarのカスタマイズに関する参考資料

この部分はこのサンプルを作成した際にも地味にハマってしまった例でした。Storyboardを利用する際でももちろんコードによる実装で色やフォント、その他プロパティを変更してカスタマイズすることもできますが、Storyboard内のNavigationControllerを選択した際の左ペインの中にあるNavigationController → NavigationBarを選択すると、右ペインにタイトルの色やフォント等を設定できる項目が現れるので、この部分を活用して装飾を行うようにすると手軽にできます。(もちろん細かいところではコードが必要になる局面も出てきますが)

navigationbar.png

また、コードでナビゲーションバーの見た目の色変更やフォント変更等のデザインのカスタマイズを行う場合は下記にピックアップしたリンクなどを参考にしてみて下さい。

あとがき

私はアプリ開発やサンプル作成を行う際は結構StoryboardやContainerViewを活用していくスタイルが中心なので今回は今まで取り組んできた中で得た知見や実装方法のアイデアについて改めて棚卸しをしてみた感じになります。

開発の規模が大人数であったりすると、コンフリクトが多発するリスクや画面遷移が複雑になりがちなこともあり、そもそもStoryboardでのUI作成はなかなか難しい面もあるかもしれません。
下記のように実際のアプリでContainerViewやStoryboardをフル活用している例もあり、本当にUIの構築や実装本当に奥が深いなと感じた次第です。

今回はStoryboardありきの実装の解説になりましたが、Storyboardで構築できるものは当然コードでできるものなので、自戒の意味も込めてさらに下記の資料等を参考にしてコードでのContainerViewの実装方法に関してもさらに知見を深堀りして行きたいと思う次第です。

(ContainerView+コードで実装する際に読んでみたい参考資料一覧)

もっともっと色々なiOSアプリのUIの作成バリエーションをアウトプットできるように今後とも精進致しますm(_ _)m

追記

2016.09.17