Sidemenu、Sidebar、Slide menu、Hamburger menu、Left menuなどいろいろ言い回しがあるけど、真剣に実装するとこれが意外とめんどくさい。
- スワイプで指に追随するように表示/非表示する
- フリックでも表示/非表示する
- スクリーンエッジからPanしても表示する
- ナビゲーションバーのボタンをタップしても表示する
- メニュー選択時にも非表示にする
- メニュー以外の半透明の薄暗い部分をタップした時も非表示にする
などなどの要求仕様を満たすとGestureRecognizer周りの実装やView構造どうしたらいいんだっけとか考えるのが割と手間。
しかし、サイドメニューごときに外部ライブラリ依存したくない!
ということで、いつでも自前実装可能なようにサンプルコードにして上げておいた。
https://github.com/atsushijike/Sidemenu
環境
- Xcode9.4
- Swift4.1
挙動
サイドメニューといってもメイン画面の上に重なって表示されるケース、メイン画面も追随して右方向にスライドして表示させるケースなど思い浮かぶ。いずれのケースもメイン画面の少し見えている部分は操作対象とせず薄暗く明度を落とした表現のデザインで、だいたいそこをタップするとサイドメニューが閉じることが多い。
今回はメイン画面の上に重なって表示されるタイプにした。
構造
- ウインドウ
AppDelegate.window
(UIWindow)- ルート画面
rootViewController
( MainViewController )- コンテンツ画面
contentViewController
(UINavigationController) - サイドメニュー
sidemenuViewController
( SidemenuViewController )- ラッパー contentView (UIView)
- メニュー tableView (UITableView)
- ラッパー contentView (UIView)
- コンテンツ画面
- ルート画面
サイドメニュー表示
sidemenuViewController
を addChildViewController
してから、 sidemenuViewController.contentView
を滑らかに左端から表示させている。(animated
, contentAvailability
については後述)
private func showSidemenu(contentAvailability: Bool = true, animated: Bool) {
if isShownSidemenu { return }
addChildViewController(sidemenuViewController)
sidemenuViewController.view.autoresizingMask = .flexibleHeight
sidemenuViewController.view.frame = contentViewController.view.bounds
view.insertSubview(sidemenuViewController.view, aboveSubview: contentViewController.view)
sidemenuViewController.didMove(toParentViewController: self)
if contentAvailability {
sidemenuViewController.showContentView(animated: animated)
}
}
contentRatio
が 1.0
になるようにしている。
func showContentView(animated: Bool) {
if animated {
UIView.animate(withDuration: 0.3) {
self.contentRatio = 1.0
}
} else {
contentRatio = 1.0
}
}
contentRatio
は 0の時に contentView
が見えなくなり、1.0の時に完全に見えた状態になるようレイアウト変更している。
PanGestureで指に追随させる実装を行うため、0, 1以外のケースで半端な位置が表現できる。
あと半透明で薄暗い背景にしたいけど急に黒くなったり明るくなったりすると不自然なので
これも位置と同様にratioから view.backgroundColor
のalpha値をセットしている。
ついでにshadowが少し落ちた方が見栄えが良いので落としている。
private var contentRatio: CGFloat {
get {
return contentView.frame.maxX / contentMaxWidth
}
set {
let ratio = min(max(newValue, 0), 1)
contentView.frame.origin.x = contentMaxWidth * ratio - contentView.frame.width
contentView.layer.shadowColor = UIColor.black.cgColor
contentView.layer.shadowRadius = 3.0
contentView.layer.shadowOpacity = 0.8
view.backgroundColor = UIColor(white: 0, alpha: 0.3 * ratio)
}
}
隠す時は逆で sidemenuViewController.contentView
を左端に動かして非表示化させてから removeFromParentViewController
しておく。
private func hideSidemenu(animated: Bool) {
if !isShownSidemenu { return }
sidemenuViewController.hideContentView(animated: animated, completion: { (_) in
self.sidemenuViewController.willMove(toParentViewController: nil)
self.sidemenuViewController.removeFromParentViewController()
self.sidemenuViewController.view.removeFromSuperview()
})
}
contentRatio
が 0
になるようにしている。
func hideContentView(animated: Bool, completion: ((Bool) -> Swift.Void)?) {
if animated {
UIView.animate(withDuration: 0.2, animations: {
self.contentRatio = 0
}, completion: { (finished) in
completion?(finished)
})
} else {
contentRatio = 0
completion?(true)
}
}
GestureRecognizer のハンドリング
Tap
- メニュー以外の半透明の薄暗い部分をタップした時も非表示にする
これに使う。
viewにaddする
override func viewDidLoad() {
super.viewDidLoad()
...
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(backgroundTapped(sender:)))
tapGestureRecognizer.delegate = self
view.addGestureRecognizer(tapGestureRecognizer)
}
そのままだとtableView(_:didSelectRowAt:)
がハンドリングされなくなるので、tableView
以外のタップのみ許可するようにしている。その他のGestureでも適用される。
extension SidemenuViewController: UIGestureRecognizerDelegate {
internal func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
let location = gestureRecognizer.location(in: tableView)
if tableView.indexPathForRow(at: location) != nil {
return false
}
return true
}
}
Pan
- スワイプで指に追随するように表示/非表示する
- フリックでも表示/非表示する
これらに使う。
面倒なのはサイドメニューではなくメイン画面のフリックをハンドルする必要があるところ。
以下をコールするとdelegateで親となるViewControllerを取得し、そこにaddしている。
今回はdelegateも親となるViewControllerもどちらもメイン画面としている。
func startPanGestureRecognizing() {
if let parentViewController = self.delegate?.parentViewControllerForSidemenuViewController(self) {
...
panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureRecognizerHandled(panGestureRecognizer:)))
panGestureRecognizer.delegate = self
parentViewController.view.addGestureRecognizer(panGestureRecognizer)
}
}
override func viewDidLoad() {
super.viewDidLoad()
...
sidemenuViewController.delegate = self
sidemenuViewController.startPanGestureRecognizing()
}
この実装の肝となるPanGestureの処理部分。
まずそもそもサイドメニューを表示すべきか(できる状態なのか)を delegate
に問い合わせて、できなければ 何もしない。
@objc private func panGestureRecognizerHandled(panGestureRecognizer: UIPanGestureRecognizer) {
guard let shouldPresent = self.delegate?.shouldPresentForSidemenuViewController(self), shouldPresent else {
return
}
既に表示されているのに右方向のPanは無視で return
let translation = panGestureRecognizer.translation(in: view)
if translation.x > 0 && contentRatio == 1.0 {
return
}
Gesture開始時に beganState
として表示状態から始まったのか、非表示状態から始まったのか、またその位置を変数に格納しておく。
右方向のPanならサイドメニュー表示だが、この時点では contentView
を表示する必要がないので contentAvailability == false
, animated == false
でdelegateに表示処理してもらう。(viewControllerのchild追加処理だけ行われる)
let location = panGestureRecognizer.location(in: view)
switch panGestureRecognizer.state {
case .began:
beganState = isShown
beganLocation = location
if translation.x >= 0 {
self.delegate?.sidemenuViewControllerDidRequestShowing(self, contentAvailability: false, animated: false)
}
動かしている最中。
Gesture開始時の状態を参照して適切な移動距離を distance
に格納し、
それを元に表示状態が 0 ~ 1.0 の内どの状態なのかを割り出して contentRatio
にセットされ contentView
がレイアウトされる、つまり指に追随する。
case .changed:
let distance = beganState ? beganLocation.x - location.x : location.x - beganLocation.x
if distance >= 0 {
let ratio = distance / (beganState ? beganLocation.x : (view.bounds.width - beganLocation.x))
let contentRatio = beganState ? 1 - ratio : ratio
self.contentRatio = contentRatio
}
Gestureが終わったら最後まで動かさなかったケースを担保するため、それぞれ完全に表示状態もしくは非表示状態にアニメーションするようにしておく。
case .ended, .cancelled, .failed:
if contentRatio <= 1.0, contentRatio >= 0 {
if location.x > beganLocation.x {
showContentView(animated: true)
} else {
self.delegate?.sidemenuViewControllerDidRequestHiding(self, animated: true)
}
}
beganLocation = .zero
beganState = false
default: break
}
}
ScreenEdge
- スクリーンエッジからPanしても表示する
これに使う。
個人的にはこれが一番ユースケース多い気がする。
左上のボタンは遠いのでこれに対応していないアプリは結構辛い。
これもPanGestureと同様に親となるViewControllerでハンドルする必要がある。
func startPanGestureRecognizing() {
if let parentViewController = self.delegate?.parentViewControllerForSidemenuViewController(self) {
screenEdgePanGestureRecognizer = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(panGestureRecognizerHandled(panGestureRecognizer:)))
screenEdgePanGestureRecognizer.edges = [.left]
screenEdgePanGestureRecognizer.delegate = self
parentViewController.view.addGestureRecognizer(screenEdgePanGestureRecognizer)
...
}
}
Delegation
-
parentViewControllerForSidemenuViewController(_ sidemenuViewController:) -> UIViewController
PanGestureRecognizerをハンドリングするViewControllerを返す -
shouldPresentForSidemenuViewController(_ sidemenuViewController:) -> Bool
サイドメニューを表示すべきか(できる状態)かを返す。何かしらサイドメニュー表示しちゃいけない時はあると思うので設けている -
sidemenuViewControllerDidRequestShowing(_ sidemenuViewController:, contentAvailability: Bool, animated: Bool)
サイドメニュー表示のリクエスト -
sidemenuViewControllerDidRequestHiding(_ sidemenuViewController:, animated:)
サイドメニュー非表示のリクエスト -
sidemenuViewController(_ sidemenuViewController:, didSelectItemAt indexPath:)
サイドメニュー項目選択された時に呼ばれる