iOS13のモーダル画面の対応について

## はじめに
iOS13からシートモーダル表示（セミモーダル表示）がデフォルトスタイルになりました。
iOS13で新たなモーダルスタイルが登場したのをきっかけにモーダル画面について調べたことを
記載したいと思います。

## そもそもモーダルとは

Human Interface Guidelinesの[Modality](https://developer.apple.com/design/human-interface-guidelines/ios/app-architecture/modality/)にモーダル画面表示について書かれています。

```
Modality is a design technique that presents content in a temporary mode 
that’s separate from the user's previous current context 
and requires an explicit action to exit. 

・Help people focus on a self-contained task or set of closely related options
・Ensure that people receive and, if necessary, act on critical information

```
上記をみると、モーダル画面はユーザーに**表示していた画面とは別の一時的なモードでコンテンツを提示するデザイン手法**と書かれており、終了するには明示的なアクションが必要なことも書かれています。

また、その目的として以下のことが書かれており、モーダルの用途を改めて確認しました。

* 自己で完結するタスクまたは、**関連するオプションにユーザーが集中できる**ようにする
* ユーザーが**重要な情報を受け取り**、それを見て**必要に応じて行動するように**する

### SheetとFullscreen
Human Interface GuidelinesによるとiOS 13以降では`Sheet（シート）`と`Fullscreen（フルスクリーン）`のスタイルがサポートされていると記載されています。フルスクリーンの全画面カバーされる特徴とは異なり、シートはコンテンツの上に覆うカードとして表示され、カード以外の部分の領域は暗くなります。

__＜Sheet＞__　　※Human Interface GuidelinesのModalityより転載

<img width="345" alt="スクリーンショット 2019-11-30 9.48.05.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/361214/e4420a08-2413-ab1d-0d8b-3364bffade73.png">


iOS13の対応を進めていく中で、このシートとフルスクリーンのスタイルをどう使い分けるか悩みました。
Human Interface Guidelinesでは、以下のようにかかれていました。

* Sheet
`Use a sheet for nonimmersive modal content that doesn’t enable a complex task.`（複雑なタスクを有効にしない非没入型のモーダルコンテンツにはシートを使用します。）

* Fullscreen
`Use a full-screen modal view for immersive content—such as videos, photos, or camera views—or a complex task that benefits from a full-screen presentation, such as marking up a document or editing a photo.`
（ビデオ、写真、カメラビューなどの没入型コンテンツに使用するか、ドキュメントのマークアップや写真の編集などのフルスクリーンプレゼンテーションにすることにより恩恵を受ける複雑なタスクに使用します。）

これを踏まえると、表示するコンテンツの特性を見極め、ユーザーによって使いやすいようシートとフルスクリーンのスタイルを選択する必要があると感じました。

## iOS13のシートモーダル画面対応

開発に携わっているアプリでもいくつかのモーダル画面をシートスタイルにしたのですが、シートスタイルでは注意する点がありました。

### 1）ViewControllerのAppearanceコールバックが変わる
モーダル画面をシートスタイルにすると遷移元のViewControllerのAppearanceコールバックが変わります。WWDC2019の[Modernizing Your UI for iOS 13](https://developer.apple.com/videos/play/wwdc2019/224/)のスライドを見ると遷移元のAppearanceコールバックについて記載されています。

<img width="668" alt="スクリーンショット 2019-11-27 10.31.28.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/361214/8165a40c-b8de-b886-9b1e-6e63e4979420.png">

上記を見るとモーダルが表示される際に、遷移元のviewWillDisappear、viewDidDisappearが呼ばれなず、またモーダルが閉じたときにも遷移元のviewWillAppear、viewDidAppearが呼ばれないことがわかります。
遷移元は、モーダル画面はフルスクリーンだと以前のAppearanceコールバックとなるのですが、シートスタイルになるとコールバックされなくなるため、Appearanceコールバックで期待していた処理が行われなくなることに注意する必要があります。

では、遷移元はどのようにシートスタイルのモーダル画面が閉じられたことを検知するのかというと、`UIAdaptivePresentationControllerDelegate`にiOS13から追加されたコールバックメソッドがあり、これを利用します。

### 2）UIAdaptivePresentationControllerDelegateの利用

UIAdaptivePresentationControllerDelegateを利用して遷移元のモーダル画面を閉じたときの検知については、以下のように実装していきます。

まずは、遷移元でモーダル画面をpresentする際にpresent対象のViewControllerの`presentationController?.delegate`にselfをセットします。

```swift:遷移元のViewController
let modal = ModalViewController()
let nav = UINavigationController(rootViewController: modal)
nav.presentationController?.delegate = self
present(nav, animated: true, completion: nil)
```

あとは、遷移元のViewControllerでUIAdaptivePresentationControllerDelegateを採用しコールバックでの処理を記載します。

```swift:遷移元のViewController
extension MainViewController: UIAdaptivePresentationControllerDelegate {

    @available(iOS 13.0, *)
    func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
        // モーダル画面が閉じられる前の処理
    }

    @available(iOS 13.0, *)
    func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
        // モーダル画面が閉じられた後の処理
    }
}
```

### 3) さらにモーダル画面を制御したい場合

他にもiOS13から追加された以下を使うことで、さらにモーダル画面を制御することができます。

- UIAdaptivePresentationControllerDelegate 
  - presentationControllerShouldDismiss（モーダルを閉じるかどうかをBoolで返却します）
  - presentationControllerDidAttemptToDismiss（スワイプで閉じようとする動作が会った場合にコールされます）

- ViewController
  - isModalInPresentation（スワイプによるモーダル画面を閉じることを防ぎます）

個人的には、モーダル画面を下スワイプで閉じる動作を制御したい場合は、isModalInPresentationをtrueにして、presentationControllerDidAttemptToDismissでモーダル画面を閉じるかどうかを制御するほうがわかりやすくて簡単そうです。（後述しますが、モーダル画面のフローが複雑なため。）
例えば、メールアプリの場合に、モーダル画面で新規メール作成画面を表示し、閉じようとしたときに編集中だった場合は本当に閉じてよいかアラートを出すといったケースなどで使えそうです。

ただし注意したいのは、isModalInPresentationをtrueにして、presentationControllerDidAttemptToDismissで画面を閉じると、presentationControllerWillDismissやpresentationControllerDidDismissがコールバックされないことです。

また、isModalInPresentationがfalseでpresentationControllerShouldDismissでtrueを返却する場合、モーダル画面の上下スワイプを繰り返すとpresentationControllerShouldDismissとpresentationControllerWillDismissの両方が何度もコールされる仕様になっていることに注意する必要があります。実際閉じた場合はpresentationControllerDidDismissが一度だけコールされます。

なお、isModalInPresentationがfalseでpresentationControllerShouldDismissがfalseの場合は、presentationControllerDidAttemptToDismissがコールされます。

このようなモーダルのフローはとても複雑で、isModalInPresentationやpresentationControllerShouldDismissでの設定によってコールバックのメソッドが変わってくるため、そのフローを理解しておく必要があります。WWDC2019の[Modernizing Your UI for iOS 13](https://developer.apple.com/videos/play/wwdc2019/224/)のスライドにもありますが、モーダルフローとして以下のように紹介されています。

<img width="868" alt="スクリーンショット 2019-11-30 18.18.15.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/361214/257a2e04-1572-a47e-a6fe-f444205eae99.png">

### 補足1：dismissではpresentationControllerDidDismissが呼ばれない

モーダル画面はスワイプで閉じる以外にもナビゲーションバーの左上などに閉じるボタンを配置しているアプリも多いかと思います。

|モーダル画面シートスタイル（左上の閉じるボタン）|
|----|
|![modal_close.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/361214/74d7b14f-05cf-f052-840b-66b20e744a9f.png)|

テストしていてわかたですが、この閉じるボタンをタップしたときにdismissを呼んでいますが、この場合では遷移元の`presentationControllerWillDismiss`や`presentationControllerDidDismiss`はコールされないため、注意が必要です。

### 補足2：スワイプで閉じる操作をキャンセルするとviewDidAppearが何度も呼ばれる

シートスタイルのモーダル画面をスワイプで閉じるのを途中でやめるとシートスタイルのモーダル画面のViewControllerのviewDidAppearが何度も呼ばれる仕様のようなので、注意が必要です。
私が開発に携わっているアプリでviewDidAppearのコールバックでスクリーン計測を行っていたため、スワイプで閉じるのを途中でやめるとなんども計測されてしまうという現象が出てしましました。

ちなみにモーダル画面をスワイプで閉じるのを途中でやめると以下のようなAppearanceコールバックになります。

```
test viewWillDisappear
test viewWillAppear
test viewDidAppear
```

上記の通り、モーダル画面をスワイプで閉じようとするとviewWillDisappearがコールされ、途中で閉じる操作をやめるとviewWillAppear→viewDidAppearがコールされます。
完全にモーダル画面をスワイプで閉じないと`viewDidDisappear`についてはコールされないため、フラグを持たせ、viewDidDisappearで完全にモーダルが閉じていなければ表示中としてviewDidAppearのスクリーン計測処理を行うことにしました。

```swift:例）

    private var isViewAppeared = false

    // （途中省略）

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        if !isViewAppeared {
            sendScreenTracking()
        }
        isViewAppeared = true
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        isViewAppeared = false
    }

```