【SwiftUI】インジケーターを最前面に表示する方法


## はじめに

最近、SwiftUIで開発していたアプリで、アクティビティインジケーターを最前面に表示したいという場面がありました。
アプリについては以下からダウンロードできますので、興味があればぜひダウンロードしてみてください。

[App Storeのリンク](https://apps.apple.com/jp/app/%E6%94%AF%E6%89%95%E3%81%84-%E3%82%B5%E3%83%96%E3%82%B9%E3%82%AF%E7%AE%A1%E7%90%86-%E3%81%BE%E3%81%AD%E3%81%8B%E3%82%93/id6605935725)

<img width="400" alt="アプリアイコン" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3087076/109a36f2-caa5-6f08-4858-bdb595cd0ea0.png">

問題となったのは、以下のように、インジケーターを表示するとナビゲーションバーやタブバーが覆われずに表示されてしまうことです。
**ZStack** や **ライブラリ** などを使用して簡単に実装できそうに思えて、実はうまくいかないことがあります。

<img width="200" alt="パッケージ使用前" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3087076/5a9254e7-f9f1-45d4-6779-04cbd541e489.gif">

インジケーターを最前面に表示したいのに、ナビゲーションバーやタブバーが覆われずに表示されてしまい、その場合、 **バー部分が操作可能** な状態になってしまうため、今回は、このような悩みを解決するため、インジケーターを最前面に正しく表示し、ユーザーの操作を一時的に無効化する方法を解説します。

後述しますが、本記事の解決策でインジケーターを最前面に表示するためのパッケージを作成しています。

https://github.com/sakes9/ForegroundActivityIndicator

## 原因

SwiftUIは構造体を使用して、階層構造でビューを構築します。そのため、子ビュー（例えば、`AnalyticsView`）で表示したインジケーターは、その子ビューの中でのみ表示され、親ビュー（`TabView`や`NavigationView`）には表示されません。

```swift
MainView {
    TabView {
        NavigationView {
            HomeView()
        }
        NavigationView {
            AnalyticsView() {
                IndicatorView() // ❌ 子ビュー内でのみ表示される
            }
        }
    }
}
```

そのため、子ビュー内で表示されたインジケーターは、ナビゲーションバーやタブバーを覆わずに表示されてしまいます。

## 解決策

### その1. ZStackを親ビューに配置する

インジケーターを表示させたい親ビューの上に、`ZStack`を使用してインジケーターを重ねて表示します。

`global`は`ObservableObject`を継承したクラスで、`isVisible`はインジケーターの表示状態を管理する`@Published`プロパティとします。この方法では、インジケーターがナビゲーションバーやタブバーの下に隠れず、親ビューの最前面に表示されます。

```swift
MainView {
    ZStack {
        TabView {
            NavigationView {
               HomeView()
            }
            NavigationView {
                AnalyticsView()
            }
        }

        if global.isVisible {
            IndicatorView() // ✅ 親ビューの上に表示される
        }
    }
}
```

この方法では各画面がインジケーター表示のために親のビューに依存してしまいます。
個人的には各画面ごとに状態を管理して、なるべくビューが分割された実装が好きなので、グローバルオブジェクトを使わずに、**各画面がそれぞれの状態を管理** し、必要に応じてインジケーターを最前面に表示する方法を検討することにしました。

### その２. 最前面の画面を取得してインジケーターを表示する

SwiftUI単体では、画面の最前面のビューを取得することはできませんが、UIKitの **UIApplication** や **UIWindow** を使うことで、最前面にある画面を取得できます。

この方法を使って、最前面のビューの上にインジケーターを表示することが可能です。

以下は、パッケージの内容を一部抜粋した、最前面のビューコントローラを取得する方法です。これにより、タブビューのコントローラや、ナビゲーションビューのコントローラを取得できます。

- 現在アクティブなビューコントローラを取得する
現在アクティブというのは、例えば、タブバーの選択されているビューコントローラや、ナビゲーションコントローラの最初にプッシュされたビューコントローラなどです。

```swift
// 現在アクティブなビューコントローラを取得
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
      let rootViewController = windowScene.windows.first(where: { $0.isKeyWindow })?.rootViewController else {
    return
}
```

- 最前面のビューコントローラを取得する

プレゼンテッドビュー（モーダルやポップアップ）がある場合は、画面遷移群が本流から外れるため、最前面のビューコントローラを取得するために **再帰的** にビューコントローラを取得して、最前面のビューコントローラを取得するアプローチを取っています。

```swift
// 最前面のビューコントローラを取得
let topViewController = findTopViewController(rootViewController)

/// 最前面のビューコントローラを見つけるヘルパーメソッド
/// - Parameter rootViewController: ルートビューコントローラ
/// - Returns: 最前面のビューコントローラ
func findTopViewController(_ rootViewController: UIViewController) -> UIViewController {
    // プレゼンテッドビューコントローラがある場合は再帰的に呼び出す
    if let presentedViewController = rootViewController.presentedViewController {
        return findTopViewController(presentedViewController)
    }

    // ナビゲーションコントローラーがある場合は、最後にプッシュされたビューコントローラを返す
    if let navigationController = rootViewController as? UINavigationController {
        return navigationController.visibleViewController ?? navigationController
    }

    // タブバーコントローラーがある場合は、選択されているビューコントローラを返す
    if let tabBarController = rootViewController as? UITabBarController {
        return tabBarController.selectedViewController ?? tabBarController
    }

    return rootViewController
}
```

この方法を使えば、どこの画面からインジケーターを表示しても、最前面のビューコントローラを見つけることができます。あとは、このビューコントローラのビューを取得し、その上にインジケーターを表示することで、ナビゲーションバーやタブバーの上にインジケーターを配置できます。

これにより、子画面が親画面に依存することなく、インジケーターを表示させることができます。

<img width="200" alt="パッケージ使用後" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3087076/c75efcc4-2bfd-e68c-add7-cdafc1964302.gif">

詳細な実装については、パッケージ化したソースをご参照ください。

## パッケージ化

上記の方法をパッケージ化して使用できるようにしました。

https://github.com/sakes9/ForegroundActivityIndicator

画面にモディファイアを追加することで、簡単にインジケーターを最前面に表示することができます。
インジケーターの表示・非表示を管理するための `@State` プロパティは画面ごとに持つことができます。

### 使い方

1. SPMでパッケージを追加します

2. 各画面にモディファイアを追加して表示します

```swift
@State var isVisible = false

AnayticsView()
    .activityIndicator(isVisible: $isVisible,
                       backgroundColor: .gray.withAlphaComponent(0.5),
                       indicatorColor: .white)
```

パラメーターは以下の通りです。

| パラメーター | 型 | 説明 | デフォルト
| --- | --- | --- | ---
| `isVisible` | `Binding<Bool>` | アクティビティインジケーターの表示状態を管理する変数 | -
| `backgroundColor` | `UIColor` | オーバーレイの背景色 | `.clear`
| `indicatorColor` | `UIColor` | アクティビティインジケーターの色 | `.darkGray`

README.mdにも使い方が記載されていますので、そちらのコードをコピペすれば、タブバーやナビゲーションバーの上にインジケーターが表示されることを確認できます。

<details><summary>README.mdに記載されているサンプルコード</summary>

```swift
import ForegroundActivityIndicator // パッケージをインポート
import SwiftUI

struct ContentView: View {
    @State var isShowingActivityIndicator = false // アクティビティインジケーターの表示状態

    var body: some View {
        TabView {
            NavigationView {
                VStack(spacing: 20) {
                    Image(systemName: "1.circle.fill")
                        .resizable()
                        .frame(width: 100, height: 100)

                    Button(action: {
                        isShowingActivityIndicator = true

                        // 3秒後に非表示
                        DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
                            isShowingActivityIndicator = false
                        }
                    }, label: {
                        Text("表示")
                            .padding(.vertical, 5)
                            .padding(.horizontal, 40)
                            .foregroundColor(.white)
                            .background(Color.blue)
                            .cornerRadius(5)
                    })
                }
                .activityIndicator(isVisible: $isShowingActivityIndicator, backgroundColor: .gray.withAlphaComponent(0.5), indicatorColor: .white) // モディファイアを適用
                .navigationTitle("画面1")
                .toolbarBackground(.visible, for: .navigationBar)
                .toolbarBackground(Color.blue, for: .navigationBar)
                .toolbarColorScheme(.dark, for: .navigationBar)
                .navigationBarTitleDisplayMode(.inline)
            }
            .tag(0)
            .tabItem { Label("One", systemImage: "1.circle") }

            NavigationView {
                VStack {
                    Image(systemName: "2.circle.fill")
                        .resizable()
                        .frame(width: 100, height: 100)
                }
            }
            .tag(1)
            .tabItem { Label("Two", systemImage: "2.circle") }
        }
    }
}
```

</details>

## まとめ

今回は、インジケーターを最前面に表示する方法をご紹介しました。モディファイアとして実装することで、ビューの構造をシンプルに保つことができます。また、画面間の依存を減らすことができるため、コードの保守性も向上します。