iOSでインタラクティブな画面遷移をドロワーメニュー（NavigationDrawer）を例にして実装してみる

# 3行で

- インタラクティブに画面を遷移させるには `UIViewControllerTransitioningDelegate` と`UIViewControllerAnimatedTransitioning` と `UIPercentDrivenInteractiveTransition` の3つが必要です
- 今回はドロワーメニューを例にしてインタラクティブな画面遷移のサンプルを作成しました
- `UIViewPropertyAnimator` でさらに効率的に実装できそうだけど今回は挑戦できませんでした

# サンプルについて

インタラクティブな画面遷移を試すためにサンプルを作成してGitHubに公開しました。
サンプルは iOS9 まで対応しています。

- https://github.com/masashi-sutou/NavigationDrawerSampler

NavigationDrawerSampler.gif | 
:-: |
<img src="https://qiita-image-store.s3.amazonaws.com/0/37385/e5e9ae70-4267-559b-c2ed-d331680fbc90.gif" width="320"/> | 

サンプルではドロワーメニューを呼び出す画面で NavigationDrawerTransitionCoordinator.swift を初期化してプロパティに保持させています。 NavigationDrawerTransitionCoordinator.swift がドロワーメニューを画面遷移させているクラスです。

```swift:ViewController.swift
import UIKit
import NavigationDrawerTransition

final class ViewController: UIViewController {
    
    private var navigationDrawerTransitionCoordinator: NavigationDrawerTransitionCoordinator?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        navigationItem.title = "NavigationDrawerSampler"
        
        navigationDrawerTransitionCoordinator = NavigationDrawerTransitionCoordinator(rootViewController: self)
        navigationDrawerTransitionCoordinator?.setupDrawerNavigationItemLeftBarButton()
    }
}
```


# ドロワーメニューの仕様

ドロワーメニューはiOSのUIというよりは、AndroidのUIです。しかし、Twitterやメルカリなど有名なiOSアプリでも採用されています。

UIKitにはないのでOSSのライブラリを使うか、自分で実装する必要があります。ドロワーメニューのデザインは様々ありますが、おおよそ以下のような仕様になります。

- 画面遷移が途中で停止して全画面の80%の幅でドロワーメニューが表示される
- ドロワーメニューの開閉がドラッグでインタラクティブにできる
- 呼び出し画面（暗い画面）をドラッグまたはタップでドロワーメニューを閉じることができる
- ドロワーメニューが開くのに合わせて呼び出し画面の全体が暗くなり、閉じるのに合わせて元に戻る


# 実装に必要なプロトコルとクラス

仕様を満たすために画面遷移をカスタマイズして遷移の開始と終了をインタラクティブにさせます。
Appleの公式ドキュメントだと下記のリンクで具体的な実装方法が説明されています。

- [View Controller Programming Guide for iOS - Customizing the Transition Animations](https://developer.apple.com/library/archive/featuredarticles/ViewControllerPGforiPhoneOS/CustomizingtheTransitionAnimations.html#//apple_ref/doc/uid/TP40007457-CH16-SW1)

ドキュメントを要約すると、必要なものは以下の protocol と class です。

- [UIViewControllerTransitioningDelegate](https://developer.apple.com/documentation/uikit/uiviewcontrollertransitioningdelegate)
- [UIViewControllerAnimatedTransitioning](https://developer.apple.com/documentation/uikit/uiviewcontrolleranimatedtransitioning)
- [UIPercentDrivenInteractiveTransition](https://developer.apple.com/documentation/uikit/uipercentdriveninteractivetransition)

`UIViewControllerTransitioningDelegate` と `UIViewControllerAnimatedTransitioning` は遷移をカスタマイズするときに使うプロトコルで、 `UIPercentDrivenInteractiveTransition` は遷移をインタラクティブにしてくれるクラスです。

インタラクティブな開閉ジェスチャーはドラッグで実装するので、 `UIPercentDrivenInteractiveTransition` は `UIPanGestureRecognizer` と合わせて使います。


# ドロワーメニューの開閉遷移の処理

ドロワーメニューの開閉遷移の開始、終了、キャンセルで呼ばれるメソッドは、インタラクティブである場合とそうでない場合で少し違います。

詳しくは、サンプルをビルドしてブレークポイントを設定するのが一番わかりやすいので、私が実装中にハマった部分のみ説明します。

### :black_medium_square: ドロワーメニュー表示中に呼び出し元の画面を表示する

```swift:NavigationDrawerAnimationPresenter.swift
  /*
   * 遷移中のViewに遷移元のViewを貼り付けてスライドしてきた感じを演出
   * さらに遷移元のViewにタップとドラッグのジェスチャーを登録したViewを最前面に貼り付けて
   * タップとドラッグの両方で画面を閉じることを可能にする
   */
  containerView.addSubview(rightView)
  self.gestureDarkView?.frame = rightView.frame
  containerView.insertSubview(self.gestureDarkView!, aboveSubview: rightView)
```
> https://github.com/masashi-sutou/NavigationDrawerSampler/blob/master/NavigationDrawerTransition/Transition/NavigationDrawerAnimationPresenter.swift#L74-L76


### :black_medium_square: AnimationOptionsをcurveLinearにしないと指と動作が合わない

- curveLinearにしないとドラッグしている指を移動させていく距離が伸びるにつれて、想像以上に画面遷移が進んでしまいます。
- イージングに緩急をつけずに等速運動させると回避できそうと思い、curveLinearで対応しました。


### :black_medium_square: 画面遷移を途中でキャンセルしたときの処理

- インタラクティブな遷移なので遷移を途中でキャンセルできます。
- キャンセル判定の閾値は、NavigationDrawerInteractiveTransition.swiftの `percentCompleteThreshold` です。
- `finish()` で `percentCompleteThreshold` を引いて、キャンセルしたときに元の画面状態に戻る遷移を自然な状態にします。

```swift:NavigationDrawerInteractiveTransition.swift
<省略>
  private let percentCompleteThreshold: CGFloat = 0.2

  override func cancel() {
      completionSpeed = percentCompleteThreshold
      super.cancel()
  }

  override func finish() {
      completionSpeed = 1 - percentCompleteThreshold
      super.finish()
  }
<省略>
```
> https://github.com/masashi-sutou/NavigationDrawerSampler/blob/master/NavigationDrawerTransition/Transition/NavigationDrawerInteractiveTransition.swift#L25-L28


- さらにキャンセルした場合は、遷移を制御している側にもキャンセルしたことを伝える必要があります。

```swift:NavigationDrawerAnimationPresenter.swift
  guard !transitionContext.transitionWasCancelled else {
      transitionContext.completeTransition(false)
      return
  }

  transitionContext.completeTransition(true)
```
> https://github.com/masashi-sutou/NavigationDrawerSampler/blob/master/NavigationDrawerTransition/Transition/NavigationDrawerAnimationPresenter.swift#L62-L67

```swift:NavigationDrawerAnimationDismisser.swift
  transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
```
> https://github.com/masashi-sutou/NavigationDrawerSampler/blob/master/NavigationDrawerTransition/Transition/NavigationDrawerAnimationDismisser.swift#L48



### :black_medium_square: iOS9~11でインタラクティブな画面遷移のとき遷移アニメーションがカクカクする

- iOS9〜11でインタラクティブにドロワーメニューを開閉すると、遷移アニメーションがカクカクする問題が起きました。
  - [jonkykong/SideMenu](https://github.com/jonkykong/SideMenu) でも同じ問題に直面したようで、下記のようなコメントが書かれていました。
  - https://github.com/jonkykong/SideMenu/blob/master/Pod/Classes/SideMenuTransition.swift#L510

### :black_medium_square: ViewControllerのライフサイクル

- 遷移をキャンセルして呼び出し画面に戻ると `viewDidAppear:` などのライフサイクルはしっかりと呼ばれます。
- 遷移してることに変わりはないのでライフサイクルには注意してください。


# 最後に

公式ドキュメントを読めばインタラクティブな画面遷移を実装することはできます。でも、頻繁に見かけるUIで試さないと理解しづらいです。必要なプロトコルやクラスが多く、名前も長いし、処理の順番も把握しにくかったです。画面遷移のカスタマイズは難しい 😕

最近では、ショートカットやマップなどの標準アプリでも画面遷移がとてもカスタマイズされており、画面遷移をカスタマイズする機会はさらに増えそうだなと、この記事を準備していたときは思っていましたが、 [UIViewPropertyAnimatorを駆使して作ったもの](https://qiita.com/hachinobu/items/bc2e1fa0c3bbd0a51d17#uiviewpropertyanimator%E3%82%92%E9%A7%86%E4%BD%BF%E3%81%97%E3%81%A6%E4%BD%9C%E3%81%A3%E3%81%9F%E3%82%82%E3%81%AE) を読むと標準アプリは画面遷移ではなく、 もしや `UIViewPropertyAnimator` で全て実現しているのではとも思い始めています 🤔

画面遷移とアニメーションの奥は深い 🧐