SwiftUIでサスペンドに対応したonAppear・onDissapearを用意する

SwiftUIで画面表示開始時にAPIからデータ取得を行ったり、デバイスの状態を変更したりするようなことはよくあるかと思いますが、画面表示開始時に実行される処理に関して、サスペンド対策を講じていないことで不具合が発生してしまうシチュエーションがいくつかありました。例えば...

- アプリをサスペンド後、しばらく時間が経過して戻ってきた場合にもAPIに依存したデータが更新されない。
- 画面表示中のみAPIから継続的にデータを購読(ポーリング)するタスクを、画面表示時に`onAppear`で開始、非表示時に`onDisappear`で停止するように用意していると、アプリがバックグラウンドの間にも継続的にタスクが動いてしまう。
- 特定の画面を表示したときに`onAppear`で画面の明るさを上げる処理をいれていると、アプリをサスペンドしたときに明るさが戻らない。

SwiftUIの`onAppear`・`onDisappear`はアプリのサスペンド(iOSホーム画面に戻る等)やアプリへの復帰は検知してくれません。上記不具合も対処のためいろいろと試してみましたが、Viewの表示・非表示だけでなくサスペンド切り替えでも処理が動いてくれるモディファイアを作るといい感じでした。

## 実装

### 1. ScenePhase変更時に処理を実行するモディファイアを作成する
SwiftUIでは`@Environment(\.scenePhase)`を使うとアプリのサスペンドと復帰を検知することができます。モディファイアも`@Environment`の恩恵を受けることができるため、コールバック実行用のモディファイアを作成します。

ステートの`isPresented`は一見不要そうなのですが、NavigationViewでの遷移先でもサスペンド復帰時にonChangeが動いてしまうということがあったため、その対処に入れています。

```swift:ScenePhaseReader.swift
/// アプリサスペンド時にコールバックを実行する
/// - Parameter onSuspend: アプリサスペンド時の処理
/// - Parameter onResume: アプリフォアグラウンド復帰時の処理
struct ScenePhaseReader: ViewModifier {
    /// アプリサスペンド時の処理
    var onSuspend: (() -> Void)? = nil
    /// アプリフォアグラウンド復帰時の処理
    var onResume: (() -> Void)? = nil
    /// アプリのアクティブ状態
    @Environment(\.scenePhase) private var scenePhase
    /// アプリがサスペンド状態か
    @State private var isSuspended = false
    /// 要素が表示中か
    @State private var isPresented = false

    func body(content: Content) -> some View {
        content
            .onChange(of: scenePhase) { newScenePhase in
                if !isPresented {
                    return
                }
                switch newScenePhase {
                case .active:
                    if scenePhase == .inactive && isSuspended {
                        // アプリフォアグラウンド時
                        isSuspended = false
                        onResume?()
                    }
                case .inactive, .background:
                    if scenePhase == .active && !isSuspended {
                        // アプリサスペンド時
                        isSuspended = true
                        onSuspend?()
                    }
                default: break
                }
            }
            .onAppear {
                isPresented = true
            }
            .onDisappear {
                // Viewが非表示になったときに処理を実行しない
                isPresented = false
            }
    }
}
```

### 2. Viewのエクステンションとして定義する
そのままでもモディファイアとして使えるのですが、使い回しやすいように一連のハンドラをまとめてエクステンションにします。
```swift
extension View {
    /// View表示時の処理をアプリサスペンドにも紐づける
    /// - Parameter onActive: 画面表示時・アプリフォアグラウンド復帰時の処理
    /// - Parameter onInactive: 画面非表示時・アプリサスペンド時の処理
    func onActive(_ onActive: @escaping () -> Void, onInactive: (() -> Void)? = nil) -> some View {
        return self
            .onAppear(perform: onActive)
            .onDisappear(perform: onInactive)
            .modifier(Component.ScenePhaseReader(onSuspend: onInactive, onResume: onActive))
    }
}
```

### 3. Viewへの組み込み
これでサスペンド時も含めてViewが画面に表示されたとき、消えたときに処理を実行するモディファイアを使うことができます。
```swift
Text("表示を検知したいView")
    .onActive {
        print("文章表示")
    } onInactive: {
        print("文章非表示")
    }
```

## (蛇足) `onAppear`の実行順序を調整したい場合

SwiftUIの`onAppear`は画面表示後に処理が実行されるため、ナビゲーション遷移時に前Viewの`onDisappear`と次Viewの`onAppear`の実行順序が時により前後することがあるようです。これにより、画面遷移に紐づいてグローバルな画面要素の表示を切り替えるなどの操作を行うと、想定しない動作となってしまうことがあります。

実行順を固定するため、UIKitのライフサイクルイベントで処理を実行できるように`UIViewControllerRepresentable`を用意します。
[こちらの記事](https://www.yururiwork.net/archives/1058)のコードをそのまま流用させていただきました。

### 1-B. UIKitのライフサイクルで処理を実行するUIViewControllerを作成する
SwiftUIの`onAppear`は画面表示後に処理が実行されるため、ナビゲーション遷移時に前Viewの`onDisappear`と次Viewの`onAppear`の実行順序が時により前後することがあるようです。画面遷移に紐づいて要素をグローバルな画面要素の表示を切り替えるなどの操作を行うと、想定しない動作となることがあります。

実行順を固定するため、UIKitのライフサイクルイベントで処理を実行できるようにUIViewControllerRepresentableを用意します。コードは[こちらの記事](https://www.yururiwork.net/archives/1058)のコードを利用させていただきました。

```swift
/// Viewの表示前・非表示前に処理を実行する
/// ライフサイクルイベントに紐づいた処理を実行するViewController
/// - Parameter onWillAppear: View表示前の処理
private struct UIKitLifeCycleHandler: UIViewControllerRepresentable {
    typealias UIViewControllerType = UIViewController

    let onWillAppear: () -> Void

    init(onWillAppear: @escaping () -> Void) {
        self.onWillAppear = onWillAppear
    }

    func makeCoordinator() -> Self.Coordinator {
        Coordinator(onWillAppear: onWillAppear)
    }

    func makeUIViewController(context: UIViewControllerRepresentableContext<Self>) -> UIViewController {
        context.coordinator
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: UIViewControllerRepresentableContext<Self>) {
    }

    class Coordinator: UIViewController {
        let onWillAppear: () -> Void

        init(onWillAppear: @escaping () -> Void) {
            self.onWillAppear = onWillAppear
            super.init(nibName: nil, bundle: nil)
        }

        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }

        override func viewWillAppear(_ animated: Bool) {
            super.viewWillAppear(animated)
            onWillAppear()
        }
    }
}
```

## 3-B. Viewのエクステンションとして定義する
こちらのタイミングを使う場合はExtensionの定義はこのような感じになります。
```swift
extension View {
    /// View表示時の処理をアプリサスペンドにも紐づける
    /// - Parameter onActive: 画面表示時・アプリフォアグラウンド復帰時の処理
    /// - Parameter onInactive: 画面非表示時・アプリサスペンド時の処理
    func onActive(_ onActive: @escaping () -> Void, onInactive: (() -> Void)? = nil) -> some View {
        return self
            .background(onActive != nil ? UIKitLifeCycleHandler(onWillAppear: onActive) : nil)
            .onDisappear(perform: onInactive)
            .modifier(Component.ScenePhaseReader(onSuspend: onInactive, onResume: onActive))
    }
}
```

# 奥付
SwiftUIはまだまだナビゲーション周りや、ちょっとしたタイミングなど、おおらかだったりあやしい挙動があったりしますね・・。Viewの構造などによっては想定通り動かないこともあるかもしれないので、使う場合は要テストということでお願いします。