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. 参考アプリ
今回のサンプルで参考にしたアプリは下記のアプリになります。
- アプリ:RAKUNEW
- サイト:https://www.rakunew.com/
こちらは海外製の最新ガジェットのニュースと通販販売を行っているアプリになります。今回の左右のボタンを押すと下に隠れていたメニューがスッと表示されるような動きはこちらを参考にしました。やわらかな色合いながらも写真を大胆に見せる感じのデザインやメニューを開いた際のちょっとした小気味良いアニメーション等、UI開発者のこだわりが感じられます。(まあ私はもっぱらウインドウショッピング状態ではございますが。。。)
☆2-2. 今回のサンプルについて
今回はデザイン要素はあまりない感じで骨組みだけのような感じにしてあります。今回のキモの部分は
- 「ContainerViewを活用した画面遷移」
- 「ContainerViewと繋がってるViewControllerとContainerViewを配置したViewControllerとの親子関係」
になります。
環境やバージョンについて:
- 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を配置した際に起こること
Container(入れ物)の名の通りViewControllerの入れ物となるようなものになります。
またContainerViewに「embed segue」で繋がっているViewControllerとContainerViewを配置したViewControllerは親子関係を持ちます。
IBOutletで接続すると@IBOutlet weak var mainContents: UIView!
のような形になります。
☆3-2.ContainerViewから任意のViewControllerに「embed segue」で表示するViewControllerを変更する
InterfaceBuilderでContainerViewを持ってくる時にすでにViewControllerが繋がった状態になっていますが、こちらは上記の図のようにすることで任意のコントローラーにembed segueを付け直すことができます。
他にも、ContainerViewをフル活用したサンプルに関しては記事を書いていますのでよろしければ参考にしてみて下さい。
4. 今回のサンプルの画面遷移図と親子関係図
今回のサンプルアプリの画面遷移とStoryboardに配置されている各ViewControllerクラスの親子関係をまとめてみました。
大枠の画面遷移やContaierViewでembed segueで紐付けされているViewControllerの関係等が整理できれば、後はViewController同士の親子関係を整理しながら「子から親 or 親から子」のステータスを変更する処理、データの更新に関するロジックや部分細かいモーションや画面等の構築を行っていくような流れになります。
☆4-1.画面遷移図
この画面の画面遷移図は下記のようになります。
一番おおもととなる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.親子関係図
この画面で使用されている親子関係を図にしてまとめると下記のようになります。
ContainerViewの特性としては、前述の通りにはなりますがStoryboard上にあるViewControllerに配置した時点で親子関係を認識してくれるようになります。例えば今回のようにメインコンテンツのContainerViewに表示されているViewControllerに配置されているナビゲーションバーの左右にあるボタンを押すとメインコンテンツがスライドして表示する動きは、子のViewControllerからContainerViewを配置している親のクラスのメソッドを実行して、メニューを開いた状態にする
という処理になります。
1. 子のViewControllerから親のViewControllerのメソッドを実行する
子のViewControllerから親のViewControllerのメソッドを実行する手順としては、子のViewController内で下記のような手順で実装を行います。
//手順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つであるとは限りませんのでその点は気をつけないといけません。
//手順1: 子のViewController型のインスタンスを作成
let targetViewController = self.childViewControllers[index] as! ChildViewController
//(補足)子のViewController達は[ViewController]型で格納されているので、indexはその順番の値
//手順2: 親のViewControllerに定義されているインスタンスメソッドを実行
targetViewController.doSomething()
Storyboardで配置した際はぱっと見ではどのような順番で格納されているかわからないので、下記のような処理を実行してコンソール等で順番を確認するようにすると良いと思います。
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とこのコンテンツに重なる透明ボタンの位置をアニメーションでずらすメソッドを作成します。
/**
* ステータスに応じてメインコンテンツの開閉を決定する
* 左右開閉状態とコンテンツ表示状態のハンドリングを行う
* ※子のコンテナに配置したボタン等からも実行できるように切り出してある
*/
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の位置を変えるように実装をします。
/**
* 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を追加するような処理をすれば実現できます。
/**
* 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で制約の付け替えでアニメーションを行っている部分
//ボトムのポップアップを開く
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. 部品サイズを決めうちしてアニメーションで再配置する部分
//レイアウト処理が完了した際の処理
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を選択すると、右ペインにタイトルの色やフォント等を設定できる項目が現れるので、この部分を活用して装飾を行うようにすると手軽にできます。(もちろん細かいところではコードが必要になる局面も出てきますが)
また、コードでナビゲーションバーの見た目の色変更やフォント変更等のデザインのカスタマイズを行う場合は下記にピックアップしたリンクなどを参考にしてみて下さい。
- ナビゲーションバーを透明にする transparent UINavigationBar
- StoryboardのContainer ViewでUINavigationControllerを埋め込む
- Change color of Back button in navigation bar
あとがき
私はアプリ開発やサンプル作成を行う際は結構StoryboardやContainerViewを活用していくスタイルが中心なので今回は今まで取り組んできた中で得た知見や実装方法のアイデアについて改めて棚卸しをしてみた感じになります。
開発の規模が大人数であったりすると、コンフリクトが多発するリスクや画面遷移が複雑になりがちなこともあり、そもそもStoryboardでのUI作成はなかなか難しい面もあるかもしれません。
下記のように実際のアプリでContainerViewやStoryboardをフル活用している例もあり、本当にUIの構築や実装本当に奥が深いなと感じた次第です。
今回はStoryboardありきの実装の解説になりましたが、Storyboardで構築できるものは当然コードでできるものなので、自戒の意味も込めてさらに下記の資料等を参考にしてコードでのContainerViewの実装方法に関してもさらに知見を深堀りして行きたいと思う次第です。
(ContainerView+コードで実装する際に読んでみたい参考資料一覧)
- カスタムContainer View Controllerを作る
- AutoLayoutでレイアウトしたコンテナViewController内のViewControllerをアニメーションして入れ替える
- Container View Controllers Quickstart
- Embed UIViewController Programatically?
- Loading a ViewController inside a Container View
- How to add a Container View programmatically
もっともっと色々なiOSアプリのUIの作成バリエーションをアウトプットできるように今後とも精進致しますm(_ _)m
追記
2016.09.17
- こちらのサンプルを__「Swift3 + Xcode8」で書き直した際の記事__を新たに作成しました。→ (Swift3.0対応)ContainerViewとStoryboardをフル活用して複雑なUIを作るサンプルをSwift3.0へ書き直し対応した際のまとめ