74
65

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-06-16

sidemenu2_e.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)

サイドメニュー表示

sidemenuViewControlleraddChildViewController してから、 sidemenuViewController.contentView を滑らかに左端から表示させている。(animated , contentAvailability については後述)

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)
        }
    }

contentRatio1.0 になるようにしている。

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

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 しておく。

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()
        })
    }

contentRatio0 になるようにしている。

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する

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でも適用される。

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もどちらもメイン画面としている。

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)
        }
    }
MainViewController.swift
    override func viewDidLoad() {
        super.viewDidLoad()

        ...

        sidemenuViewController.delegate = self
        sidemenuViewController.startPanGestureRecognizing()
    }

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

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でハンドルする必要がある。

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


74
65
12

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
74
65