こんにちは、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 // 閉じるための必要な処理 } } } } }
FullScreenCoverItem
は enum
で定義して、全画面モーダルで開けるViewをすべてここに書くことで、仕様が理解しやすくなります。新しい画面をモーダルで開きたいときの定義もここだけで完結します。
AppEnvironment
/// @EnvironmentObjectにセットされる前提のAppEnvironmentクラス final class AppEnvironment: ObservableObject { let fullScreenCoverItemTrigger = PassthroughSubject<FullScreenCoverItem, Never>() }
アプリ全体に通知できるように、SwiftUIのグローバル変数 @EnvironmentObject
にセットする AppEnvironment
クラスを作成し、 FullScreenCoverItem
の Publisher
を定義します。これでどの画面からも 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
の定義です。表示する側で currentItem
を nil
に変えるクロージャーを渡し、実行することで、モーダルを閉じることができます。ユーザー操作でモーダルを閉じる画面には必ず 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) } } }
同じ appEnvironment
を UserNotificationHandler
と RootView
の .environmentObject
に注入することで、Viewとは関係ないコードから appEnvironment
を通して全Viewに通知することが可能になります。
なお、一番外側の RootView
だけが全画面モーダルを開く状態ではないのですが、 .onReceive(appEnvironment.fullScreenCoverItemTrigger)
にSubscribeして最初の全画面モーダルを開くコードを仕込む必要があります。この例では、RootView
を FullScreenCoverView
に入れて、 currentItem
に nil
をセットしています。誤解が生みそうならば、 RootView
が独自でSubscribe処理をすると良いでしょう。
注意点
最後に注意点です。原因は不明ですが、全画面モーダルを最初に開いたViewで、FullScreenCoverItem
を開放してすべてのモーダルを閉じようとしても、数枚しか閉じない現象が発生しました。今回の要件は、ユーザーが手動でViewを閉じていくので問題ありませんが、自動ですべての画面を閉じて、ルートまで戻る要件がある場合は要注意です。
まとめ
本記事では、Wrapper Viewで全画面モーダルを無限に重ねる方法を紹介しました。これによって、各Viewの実装者がViewの遷移方法を意識しなくても良くなりました。新しい画面をモーダルで開きたくなったら、 FullScreenCoverItem
に処理を追加するだけで遷移の実装が終わります。
このような要件が出たら、是非使ってみてください。なお、この実装方法は私たちが手探りでたどり着いた方法なので、もっといい方法もあるよ!という方がいらっしゃったら、ぜひ教えてください!勉強させていただきます。
採用のお知らせ
Quipper ではスタディサプリの開発に関わる iOS エンジニア および シニア iOS エンジニア を積極的に募集しています。
カジュアル面談も行っているので、ぜひお気軽にご連絡ください!