[iOS][Swift] サイドメニューの実装

![sidemenu2_e.gif](https://qiita-image-store.s3.amazonaws.com/0/156085/f56223c0-a4ed-3fd6-d777-d2170f44c03e.gif)

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)

## サイドメニュー表示
`sidemenuViewController` を `addChildViewController` してから、 `sidemenuViewController.contentView` を滑らかに左端から表示させている。（`animated ` , `contentAvailability` については後述）

```Swift:MainViewController.swift
    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` になるようにしている。

```Swift:SidemenuViewController.swift
    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が少し落ちた方が見栄えが良いので落としている。

```Swift:SidemenuViewController.swift
    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` しておく。

```Swift:MainViewController.swift
    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` になるようにしている。

```Swift:SidemenuViewController.swift
    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する

```Swift:SidemenuViewController.swift
    override func viewDidLoad() {
        super.viewDidLoad()

        ...

        let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(backgroundTapped(sender:)))
        tapGestureRecognizer.delegate = self
        view.addGestureRecognizer(tapGestureRecognizer)
    }
```
そのままだと`tableView(_:didSelectRowAt:)` がハンドリングされなくなるので、`tableView` 以外のタップのみ許可するようにしている。その他のGestureでも適用される。

```Swift:SidemenuViewController.swift
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もどちらもメイン画面としている。

```Swift:SidemenuViewController.swift
    func startPanGestureRecognizing() {
        if let parentViewController = self.delegate?.parentViewControllerForSidemenuViewController(self) {
            
            ...

            panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureRecognizerHandled(panGestureRecognizer:)))
            panGestureRecognizer.delegate = self
            parentViewController.view.addGestureRecognizer(panGestureRecognizer)
        }
    }
```

```Swift:MainViewController.swift
    override func viewDidLoad() {
        super.viewDidLoad()

        ...

        sidemenuViewController.delegate = self
        sidemenuViewController.startPanGestureRecognizing()
    }
```
この実装の肝となるPanGestureの処理部分。
まずそもそもサイドメニューを表示すべきか(できる状態なのか)を `delegate` に問い合わせて、できなければ 何もしない。

```Swift:SidemenuViewController.swift
    @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でハンドルする必要がある。

```Swift:SidemenuViewController.swift
    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:)** 
 *サイドメニュー項目選択された時に呼ばれる* 

---