記事ネタは古いですが、最近では RESideMenu などに派生しているだけなので、この記事でベースを理解できれば、RESideMenu のコードを読めるようになると思います。
ゴール
最終的な動きとしては、下記になります。
Android で言うと Navigation Drawer に近いと思います。
コード
今回のコードはGitHubに上がっています。
https://github.com/mothule/ResearchSideMenu
Xcode8 + Swift3の環境で動作できると思います。
機能
今回作成する機能は次の最低限の機能になります。
- 画面はメニュー画面とコンテンツ画面の2画面構成
- メニュー画面は普段閉じており、呼ぶと横から出てくる
- コンテンツ画面からメニュー画面を呼べる
- メニュー画面からコンテンツ画面を呼べる
- メニュー画面から同じコンテンツを呼ぶ場合は開かない。
注意事項
- 細かい機能は省いてあります。
実装の説明
順を追って説明します。
いきなり処理の話の前にデータ構造の理解をした上で処理を見たほうが理解がしやすいです。
ここでのデータ構造とは、画面間の親子関係のことになります。
なので最初は画面の説明からしていきます。
Storyboardの作成
ViewControllerが4枚必要です。
左から
- ルート画面(RootViewController) ここが最初に呼ばれます
- メニュー画面(MenuViewController) StoryID:menu
- コンテンツ画面(コンテンツ1)(ContentViewController) StoryID:content
- プロフィール画面(コンテンツ2)(ProfileViewController) StoryID:profile
になります。
それぞれは繋がっていません。
画面の親子関係
4つの画面は下記の親子関係を持ちます。
- RootViewController
- MenuViewController
- ContentViewController = ProfileViewController
class RootViewController: UIViewController {
var menuViewController:UIViewController!
var contentViewController:UIViewController!
// 略
}
ProfileViewControllerとContentViewControllerは特別なことは何もしていません。
ただのアプリ用画面です。つまり RootViewController に重要機能がつまってます
RootViewController は何をやっているのか?
ロード時
下のコード内コメントの番号順に説明すると
- メニュー画面と初期表示コンテンツ画面をロード
- 下記処理を行ってます
- RootViewControllerにMenuViewControllerを子ViewControllerとして登録
- RootViewControllerのViewにMenuViewControllerのViewを追加
- MenuViewControllerの親移動完了通知
- 上のコンテンツ版
- メニュー画面を非表示にして、前に出す
override func awakeFromNib() {
super.awakeFromNib()
print("RootViewController.awakeFromNib");
// 1.
// 各ViewControllerをロード
self.menuViewController = self.storyboard?.instantiateViewController(withIdentifier: "menu")
self.contentViewController = self.storyboard?.instantiateViewController(withIdentifier: "content")
// 2.
// メニューViewControllerを登録
// 子ViewControllerとして追加.
// ViewControllerのViewをコンテナに追加
self.addChildViewController(self.menuViewController)
self.view.addSubview(self.menuViewController.view)
self.menuViewController.didMove(toParentViewController: self)
// 3.
// コンテンツViewControllerを登録
// 子ViewControllerとして追加.
// ViewControllerのViewをコンテナに追加
self.addChildViewController(self.contentViewController)
self.view.addSubview(self.contentViewController.view)
self.contentViewController.didMove(toParentViewController: self)
// 4.
// メニューは非表示にする
// メニューを前に出す
self.menuViewController.view.isHidden = true
self.view.bringSubview(toFront: self.menuViewController.view)
}
これでデータの準備が整います。
次にメニューを表示制御コードを作成します。
表示メソッド
// メニューViewControllerの表示
func presentMenuViewController(){
menuViewController.beginAppearanceTransition(true, animated: true)
self.menuViewController.view.isHidden = false
self.menuViewController.view.frame = menuViewController.view.frame.offsetBy(dx: -menuViewController.view.frame.size.width, dy: 0)
UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.3, options: UIViewAnimationOptions.curveEaseOut, animations: {
let bounds = self.menuViewController.view.bounds
self.menuViewController.view.frame = CGRect(x:-bounds.size.width / 2, y:0, width:bounds.size.width, height:bounds.size.height)
}, completion: {_ in
self.menuViewController.endAppearanceTransition()
})
}
// メニューViewControllerの非表示
func dismissMenuViewController(){
self.menuViewController.beginAppearanceTransition(false, animated: true)
UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut, animations: {
self.menuViewController.view.frame = self.menuViewController.view.frame.offsetBy(dx: -self.menuViewController.view.bounds.size.width / 2, dy: 0)
}, completion: {_ in
self.menuViewController.view.isHidden = true
self.menuViewController.endAppearanceTransition()
})
}
見るとわかると思いますが、やってることは2つしかないです。
- ViewControllerのライフサイクルイベント通知
- Viewの(フェード)アニメーション
表示メソッドの呼び出し
コンテンツ側からRootViewControllerへの参照方法の用意
さきほど作成したメニュー表示メソッドをコンテンツから呼び出します。
しかし、コンテンツ側は、RootViewControllerへの参照を持っていません。
なので何らかの方法でコンテンツ側からRootViewControllerへの参照ができるようにする必要があります。
方法としては、ViewControllerの親子関係を利用します。
上での説明では、RootViewControllerとContentViewController/ProfileViewControllerは親子関係になります。
つまりコンテンツの親には必ずRootViewControllerがいるはずです。この情報をたどるためにUIViewControllerを拡張してViewControllerなら誰でも呼べるようにします
extension UIViewController {
func rootViewController() -> RootViewController? {
var vc = self.parent
while(vc != nil){
guard let viewController = vc else { return nil }
if viewController is RootViewController {
return viewController as? RootViewController
}
vc = viewController.parent
}
return nil
}
}
これでコンテンツ側からRootViewControllerへの参照ができるようになりました。
当然ですが、RootViewControllerの子ではないViewControllerがこのメソッドをコールしてもnilが帰ってきます。
コンテンツからMenuを呼ぶ
class ContentViewController : UIViewController{
@IBAction func onTouchBootMenuButton(sender: UIButton) {
guard let rootViewController = rootViewController() else { return }
rootViewController.presentMenuViewController()
}
@IBAction func onTouchCloseMenuButton(sender: UIButton) {
guard let rootViewController = rootViewController() else {return }
rootViewController.dismissMenuViewController()
}
}
短いので全文載せます。内容もただ取得して読んでいるだけなので省きます。
メニューからコンテンツ呼び出し
コンテンツからメニューの呼び出しができるようになりました。
横からメニューが出てきていると思います。
せっかくメニューが呼び出せるようになったので、今度は、メニューからコンテンツを呼び出す処理が必要だと思います。
RootViewControllerが持つcontentViewControllerを新しいViewControllerに差し替えるだけになります。
処理内容は下のコードのコメントの通りになります。
/**
コンテンツViewControllerにUIViewControllerをセット.
*/
func set(contentViewController: UIViewController){
// 既存コンテンツと新コンテンツが同じであれば無視する.
if let currentContentViewController = self.contentViewController {
guard type(of:currentContentViewController) != type(of:contentViewController) else { return }
}
// 既存コンテンツの開放
self.contentViewController.willMove(toParentViewController: nil)
self.contentViewController.view.removeFromSuperview()
self.contentViewController.removeFromParentViewController()
// 新コンテンツのセット
self.contentViewController = contentViewController
self.view.addSubview(contentViewController.view)
self.view.bringSubview(toFront: self.menuViewController.view)
self.addChildViewController(contentViewController)
// 新コンテンツフェードイン
contentViewController.view.alpha = 0
UIView.animate(withDuration: 0.3, animations: {
contentViewController.view.alpha = 1
}, completion: { _ in
contentViewController.didMove(toParentViewController: self)
})
}
あとはこのメソッドをMenuから呼ぶだけです。
class MenuViewController : UIViewController {
@IBAction func onTouchCloseButton(_ sender: UIButton) {
guard let rootViewController = rootViewController() else {return }
rootViewController.dismissMenuViewController()
}
@IBAction func onTouchContentButton(_ sender: UIButton) {
guard let rootViewController = rootViewController() else {return }
rootViewController.dismissMenuViewController()
let profileViewController = self.storyboard!.instantiateViewController(withIdentifier: "content")
rootViewController.set(contentViewController: profileViewController)
}
@IBAction func onTouchProfileButton(_ sender: UIButton) {
guard let rootViewController = rootViewController() else {return }
rootViewController.dismissMenuViewController()
let profileViewController = self.storyboard!.instantiateViewController(withIdentifier: "profile")
rootViewController.set(contentViewController: profileViewController)
}
}
コードは3つボタンがあるので、3つメソッドがありますが、やっている流れは全部おなじです。
以上になります。
まとめ
意外と簡単な仕組みで実装されていることが、理解できたかと思います。
重要なキーワードは ViewControllerが子ViewControllerを持ってるだけです。
上記コードを理解できれば、冒頭で話したRESideMenuのようなサイドメニューを作りたい場合、メニューの表示方法を変更するだけで実現できそうなことがイメージできると思います。
実際に試していないので推測の域ですが、メニューの表示方法をメニューが横からスライドするのではなく、次のアニメーションを同時にすればできると思います。
- メニューを最初ズームイン(transformでscale弄る)した状態からズームアウトするアニメーション
- コンテンツをズームアウト + 画面右に移動するアニメーション
参考
- RESideMenu : https://github.com/romaonthego/RESideMenu