こんにちは!2020年9月からQuipperにジョインした、iOSエンジニアの @chuymaster です!現在新規サービスのiOSアプリ開発を担当しており、SwiftUIを本格的に採用したプロジェクトになります。
背景
ネイティブアプリ開発に当たって、プッシュ通知を受信して、ユーザーが開いたら特定の画面を開く、いわゆるディープリンク対応が必ずといっていいほど要件に入ります。プッシュ通知こそがウェブアプリに比べて、ネイティブアプリの最大の強みと言っても過言ではないでしょう。
そんな大事な機能ですが、SwiftUIに関してはベストプラクティスが確立しておらず、チュートリアルも少ないのが現状です。実際にストアに出したアプリではないと、プッシュ通知の運営はしないからだと思います。SwiftUI自体はiOS13からのサポートなので、ユーザー数が多い既存アプリを運営している企業もなかなか移行に踏み切れていないでしょう。
この記事では、iOS14以降のSwiftUIのiOSアプリで、どうやったらプッシュ通知の受信をして画面遷移を行えばいいかを試行錯誤した結果、出てきた一つの方法を詳しく紹介します。
プッシュ通知によるDeep Linkingの一連の流れ
一連の流れは上記の図の通りです。今回説明するのは受信側なので、一番左の配信側に関しては説明しません。
課題
SwiftUIにおいて、プッシュ通知の受信はUIKitと変わらないので、ノウハウがたくさんあって困らないですが、そのディープリンクをSwiftUIでどうハンドルするかが課題になります。
画面を遷移するパターンは、この3つがほとんどで、UIKitでのやり方は馴染み深いでしょう。
- プッシュ遷移:NavigationControllerで
pushViewController(_:animated:)
- モーダル表示:UIViewControllerで
present(_:animated:completion:)
- タブを選択する:UITabBarControllerで
selectedIndex
を変える
しかしSwiftUIだと、考え方が全部変わって、すべての手法が使えなくなりました。
先行研究
SwiftUIで上記の課題を解決した記事を見つけました。Programmatic navigation in SwiftUI
この記事では、下記の実装例を紹介しています。
- タブ選択
- モーダル表示
- プッシュ遷移
ScreenCoordinator
という @EnvironmentObject
に画面遷移用の変数を格納して、アプリのどこからでも画面遷移を制御できるようにしています。 今回はこの例を拡張して、プッシュ通知の受信から画面遷移をする流れ を実装しました。
実装に入る前に、上記の記事から私が感銘を受けた文を紹介します。
due to its declarative nature, SwiftUI requires each view to list all its possible navigation paths up front
宣言的な言語であるSwiftUIは、それぞれのViewが遷移できる経路を予め定義しておく必要がある
UIKitでは、どの画面にいても navigationController?.pushViewController()
をすれば新しい画面を開けますが、SwiftUIでは不可能です。 その画面に、「あなたはHogeViewをプッシュ遷移で表示できる」と明示的に書かないといけないのです。この事実を覚えておきましょう。
実装
それではサンプルアプリを紹介しながら解説します。
サンプルアプリの構成
Xcode 12.2でiOS14.2 をターゲットにしたアプリを作ります。アプリのLife Cycleは SwiftUI
です。コードは GitHub Repository から確認できます。
画面構成
画面構成をツリー状態にすると、このようになります。
- RootView
- TabView
- NavigationView
- ListView
- DetailView
- ListView
- SettingView
- NavigationView
- PopupView
- TabView
1. プッシュ通知受信の準備
プッシュ通知を受け取るには、ユーザーから通知許諾を得る必要があります。ここは AppDelegate
を使う必要があります。UIKitと同じです。
AppDelegate.swift
UNUserNotificationCenter.current().requestAuthorization()
で通知許諾を得ます。
import Foundation import NotificationCenter import UIKit class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { UNUserNotificationCenter.current() .requestAuthorization(options: [.alert, .sound, .badge]) { (granted, _) in print("Permission granted: \(granted)") } UNUserNotificationCenter.current().delegate = self return true } }
DeeplinkApp.swift (@main
ファイル)
SwiftUIのエントリーポイントで AppDelegate
を参照して、起動時に didFinishLaunchingWithOptions()
が呼ばれるようにします。
import SwiftUI @main struct DeeplinkApp: App { // AppDelegateを参照する @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate var body: some Scene { WindowGroup { ContentView() } } }
Info.plist
ブラウザからアプリを起動できるように、Custom URL Schemeに対応させます。プッシュ通知の受信のみであればURLスキームを使わなくても良いですが、ブラウザからのアプリ起動も要件として入る確度が高いので、仕組みを共通化しておきたいと思います。
サンプルとして deeplink://
というURLスキームからアプリを起動できるようにします。Info.plist
ファイルに URL Types
を追加します。
2. シミュレーターでプッシュ通知の受信
Xcode 11.4からSimulatorにプッシュ通知を送れるようになったので、その仕組を使って動作確認します。下記の .apns
ファイルを用意して、Payloadを作ります。( .apns
ファイルはサンプルコードの /apns
フォルダーに置いてあります)
notification.apns
{ "Simulator Target Bundle": "com.example.push-deeplink", "aps": { "alert": "Push Notifications Test", "sound": "default", "badge": 1 }, "url": "deeplink://tab?index=1" }
url
パラメータに記載するURLがアプリに開いて欲しい画面となります。上記の例では、アプリ内のタブを切り替えるコマンドだと想定して tab
と記載し、クエリパラメータ index
で切り替えたいタブのインデックスを渡します。ブラウザからディープリンクでアプリを開く場合もこのURLが使えます。
次は AppDelegate
で UNUserNotificationCenterDelegate
を実装して、通知の内容を受信します。
AppDelegate.swift
extension AppDelegate: UNUserNotificationCenterDelegate { // アプリ起動中にプッシュ通知の表示するかの判定 func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { completionHandler([.banner, .sound]) } // Push通知をタップした際の処理 func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { guard let urlString = response.notification.request.content.userInfo["url"] as? String, let url = URL(string: urlString) else { return } UIApplication.shared.open(url) completionHandler() } }
PayloadからURLを抽出して、 UIApplication.shared.open(url)
でそのURLを開きます。そうすることで、ブラウザのディープリンクから起動した場合と同じ挙動になります。
3. ViewでディープリンクURLを受信
iOS14 からは onOpenURL()
でURLスキームをSwiftUIのViewで受信できるようになりました。参考記事:Handling deeplinks in iOS 14 with onOpenURL
.onOpenURL(perform: { url in print(url.absoluteString) })
Viewに上記を追加するだけで、デバッグコンソールにURLがプリントされました。とてもシンプルです。
なお、アプリが未起動の場合、AppDelegate
の UIApplication.shared.open(url)
の時点ではViewは初期化されていませんが、Viewの初期化後に onOpenURL()
が呼ばれることを確認しました。Appleが考慮してくれているかもしれません。
4. URLから開きたい画面を抽出
SwiftUIと直接関係ありませんが、URLからどの画面を開きたいかを抽出します。開きたい画面が多い場合はHelperクラスで管理した方が良いかもしれません。今回はURLのExtensionで処理を書いて、 Deeplink
の enum
を取得します。
URL+Extension.swift
import Foundation extension URL { enum Deeplink { case tab(index: Int) case popup(id: String) case detail(id: String) } func getDeeplink() -> Deeplink? { guard self.scheme == "deeplink", let host = self.host, let queryUrlComponents = URLComponents(string: self.absoluteString) else { return nil } switch host { case "tab": if let indexString = queryUrlComponents.getParameterValue(for: "index"), let index = Int(indexString) { return Deeplink.tab(index: index) } case "popup": if let id = queryUrlComponents.getParameterValue(for: "id") { return Deeplink.popup(id: id) } case "detail": if let id = queryUrlComponents.getParameterValue(for: "id") { return Deeplink.detail(id: id) } default: return nil } return nil } } extension URLComponents { func getParameterValue(for parameter: String) -> String? { self.queryItems?.first(where: { $0.name == parameter })?.value } }
これでViewの .onOpenURL
の処理を置き換えると、画面遷移の振り分け機能ができます。
.onOpenURL(perform: { url in if let deeplink = url.getDeeplink() { switch deeplink { case .tab(let index): break case .popup(let id): break case .detail(let id): break } } })
.onOpenURL
イベントが受け取れるViewは、表示されているViewである必要があるので、一番外側である RootView
で実装します。
5. 特定の画面に遷移
さて、ようやくSwiftUI上で画面遷移をする準備ができました。ここからは遷移処理を実装します。画面フローはこの図の通りとなります。
ScreenCoordinator.swift
画面遷移状態に関するプロパティを一括で管理する ScreenCoordinator
クラスを作成します。
今回はディープリンクを通して
- タブ選択
- モーダル表示
- プッシュ遷移
に遷移できるということで、3つの変数を用意します。変数が変わったらViewの状態も変わるように通知してほしいので、 ObservableObject
を継承して変数の宣言に @Published
を適用します。
import SwiftUI final class ScreenCoordinator: ObservableObject { @Published var selectedTab: Int = 0 @Published var selectedDetailId = Selection<String>(isSelected: false, item: nil) @Published var selectedPopupId = Selection<String>(isSelected: false, item: nil) } struct Selection<T> { var isSelected = false var item: T? }
詳細画面などは、画面を開くと同時に、IDを渡してAPI通信してデータを取得する想定なので、汎用的な Selection
構造体を用意して、セットでデータの受け渡しをしています。
この ScreenCoordinator
クラスを @EnvironmentObject
として登録し、アプリのどこからでも呼び出せるようにします。
DeeplinkApp.swift
import SwiftUI @main struct DeeplinkApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate var body: some Scene { WindowGroup { RootView() .environmentObject(ScreenCoordinator()) } } }
ここからは各画面の実装に入ります。
PopupView.swift
モーダル表示の画面を作成します。
import SwiftUI struct PopupView: View { @EnvironmentObject private var screenCoordinator: ScreenCoordinator let id: String var body: some View { VStack { Text("Popup \(id)") .font(.title) .bold() Divider() Button(action: { screenCoordinator.selectedPopupId = Selection(isSelected: false, item: nil) }){ Text("Close") } } } }
@EnvironmentObject private var screenCoordinator: ScreenCoordinator
を書いて、EnvironmentにあるScreenCoordinator
のオブジェクトにアクセスします。
この画面は screenCoordinator.selectedPopupId.isSelected
が true
になれば表示される画面なので、閉じるボタンを押すと .isSelected
を false
に変えて閉じさせます。
DetailView.swift
リストの詳細画面を作成します。
import SwiftUI struct DetailView: View { @EnvironmentObject private var screenCoordinator: ScreenCoordinator let id: String var body: some View { VStack { Text("Detail \(id)") Divider() Button(action: { screenCoordinator.selectedDetailId = Selection(isSelected: false, item: nil) }){ Text("Close") } } } }
表示のトリガー変数が違うだけで、ほかはPopupView
と同じです。
ListView.swift
一覧画面を作成します。
import SwiftUI struct ListView: View { @EnvironmentObject private var screenCoordinator: ScreenCoordinator private let data = ["1", "2", "3", "4", "5"] var body: some View { NavigationView { ScrollView { LazyVStack { if let id = screenCoordinator.selectedDetailId.item { NavigationLink( destination: DetailView(id: id), isActive: $screenCoordinator.selectedDetailId.isSelected, label: { EmptyView() }) } ForEach(data, id: \.self) { id in Button(action: { screenCoordinator.selectedDetailId = Selection(isSelected: true, item: id) }) { Text("List \(id)") .font(.title2) .padding() } } } } .navigationTitle("Deeplink App") } } }
NavigationLink
の作成方法が特殊で、 ForEach
内で NavigationLink
を作成せず、外で一つだけ作成して、遷移のトリガーとしました。 List
内の Button
をタップしたら ScreenCoordinator
の選択状態を変更してトリガーを活性化します。
if let id = screenCoordinator.selectedDetailId.item { NavigationLink( destination: DetailView(id: id), isActive: $screenCoordinator.selectedDetailId.isSelected, label: { EmptyView() }) }
isActive
が $screenCoordinator.selectedDetailId.isSelected
にBindingされています。本当はtag, selectionのInitializer)を使う方が、 EmptyView()
を書く必要もなく、きれいだと思いますが、このあと後述する RootView
で selection
を nil
にしてもなぜか画面が閉じられない問題があり、 isActive
の方は問題がないので、ちょっとややこしい書き方ですが、こちらを選びました。
RootView.swift
最後に、すべてのViewの親であり、Deeplinkを受け取って画面遷移を振り分ける RootView
を作成します。RootViewは常時表示されているので、 .onOpenURL
を書く最適な場所となります。
import SwiftUI struct RootView: View { @EnvironmentObject private var screenCoordinator: ScreenCoordinator var body: some View { TabView(selection: $screenCoordinator.selectedTab) { ListView() .tabItem { VStack { Image(systemName: "house") Text("ホーム") } }.tag(0) SettingView() .tabItem { VStack { Image(systemName: "gearshape") Text("設定") } }.tag(1) } .sheet(isPresented: $screenCoordinator.selectedPopupId.isSelected) { PopupView(id: screenCoordinator.selectedPopupId.item!) } .onOpenURL(perform: { url in if let deeplink = url.getDeeplink() { switch deeplink { case .tab(let index): // タブを選択 screenCoordinator.selectedTab = index case .popup(let id): // 画面をモーダルで表示 screenCoordinator.selectedPopupId = Selection(isSelected: true, item: id) case .detail(let id): // プッシュ遷移で表示 screenCoordinator.selectedTab = 0 screenCoordinator.selectedPopupId = Selection(isSelected: false, item: nil) screenCoordinator.selectedDetailId = Selection(isSelected: false, item: nil) DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { screenCoordinator.selectedDetailId = Selection(isSelected: true, item: id) } } } }) } }
それぞれの遷移方法を詳しくみましょう。
タブ選択
case .tab(let index): // タブを選択 screenCoordinator.selectedTab = index
上記で TabView(selection: $screenCoordinator.selectedTab)
が変わって別のタブが選択されます。
モーダル表示
case .popup(let id): // 画面をモーダルで表示 screenCoordinator.selectedPopupId = Selection(isSelected: true, item: id)
上記で .sheet(isPresented: $screenCoordinator.selectedPopupId.isSelected)
が変わってモーダルシートが表示されます
プッシュ遷移
case .detail(let id): // プッシュ遷移で表示 screenCoordinator.selectedTab = 0 screenCoordinator.selectedPopupId = Selection(isSelected: false, item: nil) screenCoordinator.selectedDetailId = Selection(isSelected: false, item: nil) DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { screenCoordinator.selectedDetailId = Selection(isSelected: true, item: id) }
screenCoordinator.selectedDetailId
を変えることで、 ListView
の NavigationLink
をトリガーしてプッシュ遷移します。詳細画面を開くのは ListView
の責務なので、 ScreenCoordinator
を使うことで、 ListView
の値を変えて遷移させることができます。
上記の例では、アプリを使っているときにディープリンクのURLを開いた場合を想定した挙動です。その時、タブが変わっているかもしれないし、モーダルが既に表示されているかもしれないので、全部リセットしてからプッシュ遷移させています。厳密に状態を制御することも、SwiftUIの一つの考え方です。
因みに、 DispatchQueue.main.asyncAfter
を入れなくても画面が切り替わりますが、戻るアニメーションがないと違和感があるかと思ってあえて遅延させました。
まとめ
SwiftUIのディープリンク対応方法として、プッシュ通知の受信から、URLスキームを使って、よくある3パターンの画面遷移の実装方法を紹介しました。
このように、SwiftUIではどの画面がどう遷移するかを厳格に書く必要があり、画面遷移に関してきちんと設計する必要があります。 @EnvironmentObject
に登録した ScreenCoordinator
を使わず、View間で @State
と @Binding
を使って遷移状態を制御することもできますが、RootView→ListView→DetailViewのように、フラグを延々とBindingする必要があって柔軟性が低いと個人的に感じます。
ご紹介した実装方法はベストかというとそうではないと思います。私たちのチームでは、まずこの方針をベースに、新規開発のアプリの画面遷移を管理していって、よりよい方法を探し続けていきたいと思います。SwiftUIのディープリンク対応はこのやり方もあるよ!という方がいましたら、ぜひ教えてください!!