スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

SwiftUIで全画面モーダルを無限に重ねる

こんにちは、iOSエンジニアの @chuymaster です!iOSDC Japan 2021の「スタディサプリ」がFull SwiftUIを選択した先に見えてきたもの。トークセッションの時間の都合上、ご紹介できなかった「全画面モーダルを重ねる」実装方法をご紹介します。

全画面モーダルを重ねるとは?

全画面モーダルを重ねるとは、一度全画面モーダルを開いたあと、その上にさらに全画面モーダルを開くことです。このようなイメージの遷移方法になります。

モーダルを重ねるイメージ

なぜモーダルを重ねる必要があるか?

私たちが新規開発している学習系アプリの機能要件には、プッシュ通知の受信によるDeep Linkがあります。Deep Linkを開く際、なるべく体験を阻害しないために、今開いている画面の上に新しい画面を開きたいのです。

そして、アプリの画面には、モーダルも含まれています。プッシュ通知は、その画面を開いているときに来る可能性もあり、その場合でも既存のモーダルを破棄せず、さらにモーダルを重ねる必要があります。

UIKitなら keyWindow のルートを取り出して、どの画面からも新しいViewControllerを表示させることができてあまり気にしなくても良いですが、SwiftUIだとそうはいかないので、私たちの実装方法を解説していきたいと思います。

/// UIKitで新しいViewControllerをモーダル表示する例
UIApplication.shared.windows
    .first { $0.isKeyWindow }?
    .rootViewController?
    .present(UIViewController(), animated: true, completion: nil)

SwiftUIの全画面モーダルの実装方法

まず、普通の全画面モーダルを実装します。iOS14から .fullScreenCover のModifierが追加されたので、簡単です。

/// .fullScreenCoverの使用例
struct ContentView: View {
    @State private var isFullScreenCoverViewPresented = false
    var body: some View {
        Button(action: {
            isFullScreenCoverViewPresented = true
        }) {
            Text("Open FullScreen Modal")
        }
        // 表示フラグをisPresentedにバインディング
        .fullScreenCover(isPresented: $isFullScreenCoverViewPresented) {
            Button(action: {
                isFullScreenCoverViewPresented = false
            }) {
                Text("Close")
            }
        }
    }
}

全画面モーダルの例

さらに、 .fullScreenCover はネストされたViewでも実装可能なので、全画面モーダルの上に全画面モーダルを重ねることもできます。

/// ネストされた.fullScreenCoverの例
struct ContentView: View {
    @State private var isFirstFullScreenCoverViewPresented = false
    @State private var isSecondFullScreenCoverViewPresented = false
    var body: some View {
        Button(action: {
            isFirstFullScreenCoverViewPresented = true
        }) {
            Text("Open First FullScreen Modal")
        }
        .fullScreenCover(isPresented: $isFirstFullScreenCoverViewPresented) {
            VStack(spacing: 16) {
                Button(action: {
                    isSecondFullScreenCoverViewPresented = true
                }) {
                    Text("Open Second FullScreen Modal")
                }
                // .fullScreenCoverを子Viewにネストする
                .fullScreenCover(isPresented: $isSecondFullScreenCoverViewPresented) {
                    Button(action: {
                        isSecondFullScreenCoverViewPresented = false
                    }) {
                        Text("Close")
                    }
                }
                Button(action: {
                    isFirstFullScreenCoverViewPresented = false
                }) {
                    Text("Close")
                }
            }
        }
    }
}

全画面モーダルを重ねる例

.sheet のシート型モーダルも同じく重ねることができます。ただし、こうしたモーダル表示の Modifier は同じViewでは一つのみ有効なので、重ねたい場合は、モーダル側のViewにつける必要があります。

Wrapper Viewで無限に重ねられるモーダルを実装

無限に重ねられる全画面モーダルを実装するために、上記のコードを使って、すべてのViewに .fullScreenCoverバインディング用の @State 変数を定義するのは現実的ではありません。コードが冗長になるし、新しい画面を実装する際に忘れる可能性もあります。

この課題を解決するため、私たちは .fullScreenCover を定義したWrapper Viewを作成して、表示したいコンテンツをその中に入れるようにしています。FullScreenCoverView と名付けました。

/// Wrapper View全体コード
struct FullScreenCoverView<Content: View>: View {

    @EnvironmentObject private var appEnvironment: AppEnvironment

    @Binding var currentItem: FullScreenCoverItem?
    @State private var nextItem: FullScreenCoverItem?
    
    let content: () -> Content
    
    var body: some View {
        content()
            .onReceive(appEnvironment.fullScreenCoverItemTrigger) { item in
                if nextItem == nil {
                    nextItem = item
                }
            }
            .fullScreenCover(item: $nextItem) { item in
                item.buildView(with: $nextItem)
            }
    }
}

View以外にもたくさんのコードが登場したので、解説していきます。

状態遷移図

先に、全体像を図で解説します。

データの流れ

データの流れはこのようになっています。Viewや通知管理クラスから全画面モーダルを開きたいときに、 AppEnvironment の Publisher に対して画面情報を送れば、受け取り可能な FullScreenCoverView がその情報を拾ってモーダルを開きます。複数の呼び出し元があっても、全画面モーダルを開く関数を一つに集約できるメリットがあります。

複数の全画面モーダルがあった場合の処理フロー

次に、既に全画面モーダルが開かれている場合、FullScreenCoverView で定義した分岐によって、 FullScreenCoverItem が来ても何もしません。図の FullScreenCoverView B のように、まだ自身が全画面モーダルを開いていない、一番手前のViewがそのアイテムを拾ってモーダルを開きます。これがいくつも続くことができ、(メモリーが許す限り)無限にモーダルを重ねることができます。

FullScreenCoverItem

前提として、全画面モーダルを重ねる要件は、プッシュ通知またはDeep Linkでの起動のみになります。Deep Linkで判定した画面を、 FullScreenCoverItem として定義して、アプリ内で持ち回れるようにします。

/// 開くべき画面情報を持つenum
enum FullScreenCoverItem: Identifiable {
    var id: String {
        switch self {
        case .learn(let id):
            return id
        }
    }
    // サンプルとして、学習画面のみが全画面モーダルで開く
    case learn(id: String)
    
    @ViewBuilder
    // Viewの描画を定義
    func buildView(with item: Binding<FullScreenCoverItem?>) -> some View {
        switch self {
        case .learn(let id):
            // Wrapper Viewで囲ってから対象Viewを定義
            FullScreenCoverView(currentItem: item) {
                LearnView(title: id) {
                    item.wrappedValue = nil // 閉じるための必要な処理
                }
            }
        }
    }
}

FullScreenCoverItemenum で定義して、全画面モーダルで開けるViewをすべてここに書くことで、仕様が理解しやすくなります。新しい画面をモーダルで開きたいときの定義もここだけで完結します。

AppEnvironment

/// @EnvironmentObjectにセットされる前提のAppEnvironmentクラス
final class AppEnvironment: ObservableObject {
    let fullScreenCoverItemTrigger = PassthroughSubject<FullScreenCoverItem, Never>()
}

アプリ全体に通知できるように、SwiftUIのグローバル変数 @EnvironmentObject にセットする AppEnvironment クラスを作成し、 FullScreenCoverItemPublisher を定義します。これでどの画面からも Subscribe できるようになります。

FullScreenCoverView

Wrapper Viewの解説に入ります。

/// 変数
// このView自身が開いている全画面モーダル情報
@Binding var currentItem: FullScreenCoverItem?
// 次に開く全画面モーダル情報
@State private var nextItem: FullScreenCoverItem?

FullScreenCoverView が持っている変数は2つです。自分自身が開いている画面の currentItem と、次に開く画面の nextItem です。FullScreenCoverView は必ず全画面モーダルとして表示されるので、 currentItem には前画面からもらった FullScreenCoverItemバインディングして情報を保持します。 currentItem.fullScreenCover の表示フラグになっているので、 nil を設定すれば画面を閉じることができます。

/// body
var body: some View {
    // 内包されるView。なんでも良い。
    content()
        // `fullScreenCoverItemTrigger` Publisherから画面情報を受け取って、全画面モーダルを開く
        .onReceive(appEnvironment.fullScreenCoverItemTrigger) { item in
            // 既に自Viewが全画面モーダルを開いていない場合のみ、全画面モーダルを開く
            if nextItem == nil {
                nextItem = item
            }
        }
        .fullScreenCover(item: $nextItem) { item in
            item.buildView(with: $nextItem)
        }
}

appEnvironment.fullScreenCoverItemTrigger をSubscribeしています。画面情報を受けたら、全画面モーダルを開きます。ここで大事なのが、既に自身のViewが全画面モーダルを開いている場合、何もしないことです。既にモーダルを開いた状態で nextItem にアイテムをセットすると、開かれたモーダルが閉じられてしまうからです。

appEnvironment.fullScreenCoverItemTrigger がPublisherなので、アプリ全体に通知がされます。まだ nextItem がない FullScreenCoverView が現在一番手前に表示されているViewなので、そのViewだけが FullScreenCoverItem を受け取って、新しいモーダルを開きます。この処理が繰り返され、無限に重ねることができます。

LearnView

モーダルの中身を見てみましょう。

struct LearnView: View {
    let title: String
    let closeAction: () -> Void
    
    var body: some View {
        VStack(spacing: 16) {
            Text("学習画面").bold()
            Text(title).frame(maxWidth: 200)
            Button(action: closeAction) {
                Text("閉じる")
            }
        }
    }
}

このViewのポイントは、 closeAction の定義です。表示する側で currentItemnil に変えるクロージャーを渡し、実行することで、モーダルを閉じることができます。ユーザー操作でモーダルを閉じる画面には必ず closeAction を作っています。

Deep Linkによる画面遷移

上記の実装で、無限に重ねる全画面モーダルの準備ができました。最後に、プッシュ通知からDeep Linkを受け取って、全画面モーダルを開く実装例を紹介します。

まず、プッシュ通知を受信できるように、UIKitと同様、 UNUserNotificationCenterDelegate を実装します。ただし、AppDelegateは存在しないので、別のクラスで実装します。

/// 通知ハンドラークラス
class UserNotificationHandler: NSObject, ObservableObject {
    private let appEnvironment: AppEnvironment
    
    required init(appEnvironment: AppEnvironment) {
        self.appEnvironment = appEnvironment
        super.init()
        UNUserNotificationCenter.current().delegate = self
    }
}

extension UserNotificationHandler: UNUserNotificationCenterDelegate  {
    func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
        completionHandler([.banner, .sound, .badge])
    }

    func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
        // シンプルにするため、固定で学習画面を開くが、実際はDeep Linkを抽出する処理が入る。
        appEnvironment.fullScreenCoverItemTrigger.send(.learn(id: UUID().uuidString))
        completionHandler()
    }
}

本来は didReceive response でプッシュ通知のmeta情報から開きたい画面を特定しますが、シンプルにするため、固定で「学習画面」をPublisherに通知します。

そして、 @main 関数に組み込むと完成です。

@main
struct ExampleApp: App {
    
    private let appEnvironment: AppEnvironment
    private let userNotificationHandler: UserNotificationHandler
    
    init() {
        let appEnvironment = AppEnvironment()
        self.appEnvironment = appEnvironment
        self.userNotificationHandler = UserNotificationHandler(appEnvironment: appEnvironment)
    }
    var body: some Scene {
        WindowGroup {
            FullScreenCoverView(currentItem: .constant(nil)) {
                RootView()
            }
            .environmentObject(appEnvironment)
        }
    }
}

同じ appEnvironmentUserNotificationHandlerRootView.environmentObject に注入することで、Viewとは関係ないコードから appEnvironment を通して全Viewに通知することが可能になります。

なお、一番外側の RootView だけが全画面モーダルを開く状態ではないのですが、 .onReceive(appEnvironment.fullScreenCoverItemTrigger) にSubscribeして最初の全画面モーダルを開くコードを仕込む必要があります。この例では、RootViewFullScreenCoverView に入れて、 currentItemnil をセットしています。誤解が生みそうならば、 RootView が独自でSubscribe処理をすると良いでしょう。

プッシュ通知実装イメージ

注意点

最後に注意点です。原因は不明ですが、全画面モーダルを最初に開いたViewで、FullScreenCoverItem を開放してすべてのモーダルを閉じようとしても、数枚しか閉じない現象が発生しました。今回の要件は、ユーザーが手動でViewを閉じていくので問題ありませんが、自動ですべての画面を閉じて、ルートまで戻る要件がある場合は要注意です。

まとめ

本記事では、Wrapper Viewで全画面モーダルを無限に重ねる方法を紹介しました。これによって、各Viewの実装者がViewの遷移方法を意識しなくても良くなりました。新しい画面をモーダルで開きたくなったら、 FullScreenCoverItem に処理を追加するだけで遷移の実装が終わります。

このような要件が出たら、是非使ってみてください。なお、この実装方法は私たちが手探りでたどり着いた方法なので、もっといい方法もあるよ!という方がいらっしゃったら、ぜひ教えてください!勉強させていただきます。

採用のお知らせ

Quipper ではスタディサプリの開発に関わる iOS エンジニア および シニア iOS エンジニア を積極的に募集しています。

カジュアル面談も行っているので、ぜひお気軽にご連絡ください!