SwiftUIのディープリンク対応:プッシュ通知から画面遷移する方法

こんにちは!2020年9月からQuipperにジョインした、iOSエンジニアの @chuymaster です!現在新規サービスのiOSアプリ開発を担当しており、SwiftUIを本格的に採用したプロジェクトになります。

背景

ネイティブアプリ開発に当たって、プッシュ通知を受信して、ユーザーが開いたら特定の画面を開く、いわゆるディープリンク対応が必ずといっていいほど要件に入ります。プッシュ通知こそがウェブアプリに比べて、ネイティブアプリの最大の強みと言っても過言ではないでしょう。

そんな大事な機能ですが、SwiftUIに関してはベストプラクティスが確立しておらず、チュートリアルも少ないのが現状です。実際にストアに出したアプリではないと、プッシュ通知の運営はしないからだと思います。SwiftUI自体はiOS13からのサポートなので、ユーザー数が多い既存アプリを運営している企業もなかなか移行に踏み切れていないでしょう。

この記事では、iOS14以降のSwiftUIのiOSアプリで、どうやったらプッシュ通知の受信をして画面遷移を行えばいいかを試行錯誤した結果、出てきた一つの方法を詳しく紹介します。

プッシュ通知によるDeep Linkingの一連の流れ

f:id:quipper-ja:20201223110425p:plain
Deep Linkingの一連の流れ

一連の流れは上記の図の通りです。今回説明するのは受信側なので、一番左の配信側に関しては説明しません。

課題

SwiftUIにおいて、プッシュ通知の受信はUIKitと変わらないので、ノウハウがたくさんあって困らないですが、そのディープリンクをSwiftUIでどうハンドルするかが課題になります。

画面を遷移するパターンは、この3つがほとんどで、UIKitでのやり方は馴染み深いでしょう。

  1. プッシュ遷移:NavigationControllerで pushViewController(_:animated:)
  2. モーダル表示:UIViewControllerで present(_:animated:completion:)
  3. タブを選択する: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 から確認できます。

画面構成

f:id:quipper-ja:20201223110613p:plain
画面構成

画面構成をツリー状態にすると、このようになります。

  • RootView
    • TabView
      • NavigationView
        • ListView
          • DetailView
      • SettingView
    • PopupView

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 を追加します。

f:id:quipper-ja:20201223110516p:plain
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が使えます。

次は AppDelegateUNUserNotificationCenterDelegate を実装して、通知の内容を受信します。

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がプリントされました。とてもシンプルです。

なお、アプリが未起動の場合、AppDelegateUIApplication.shared.open(url) の時点ではViewは初期化されていませんが、Viewの初期化後に onOpenURL() が呼ばれることを確認しました。Appleが考慮してくれているかもしれません。

4. URLから開きたい画面を抽出

SwiftUIと直接関係ありませんが、URLからどの画面を開きたいかを抽出します。開きたい画面が多い場合はHelperクラスで管理した方が良いかもしれません。今回はURLのExtensionで処理を書いて、 Deeplinkenumを取得します。

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上で画面遷移をする準備ができました。ここからは遷移処理を実装します。画面フローはこの図の通りとなります。

f:id:quipper-ja:20201223110551p:plain
画面フロー

ScreenCoordinator.swift

画面遷移状態に関するプロパティを一括で管理する ScreenCoordinator クラスを作成します。

今回はディープリンクを通して

  1. タブ選択
  2. モーダル表示
  3. プッシュ遷移

に遷移できるということで、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.isSelectedtrue になれば表示される画面なので、閉じるボタンを押すと .isSelectedfalse に変えて閉じさせます。

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() を書く必要もなく、きれいだと思いますが、このあと後述する RootViewselectionnil にしてもなぜか画面が閉じられない問題があり、 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 を変えることで、 ListViewNavigationLink をトリガーしてプッシュ遷移します。詳細画面を開くのは ListView の責務なので、 ScreenCoordinator を使うことで、 ListView の値を変えて遷移させることができます。

上記の例では、アプリを使っているときにディープリンクのURLを開いた場合を想定した挙動です。その時、タブが変わっているかもしれないし、モーダルが既に表示されているかもしれないので、全部リセットしてからプッシュ遷移させています。厳密に状態を制御することも、SwiftUIの一つの考え方です。

因みに、 DispatchQueue.main.asyncAfter を入れなくても画面が切り替わりますが、戻るアニメーションがないと違和感があるかと思ってあえて遅延させました。

まとめ

f:id:quipper-ja:20201223110856g:plain
シミュレーターでの動作

SwiftUIのディープリンク対応方法として、プッシュ通知の受信から、URLスキームを使って、よくある3パターンの画面遷移の実装方法を紹介しました。

このように、SwiftUIではどの画面がどう遷移するかを厳格に書く必要があり、画面遷移に関してきちんと設計する必要があります。 @EnvironmentObject に登録した ScreenCoordinatorを使わず、View間で @State@Binding を使って遷移状態を制御することもできますが、RootView→ListView→DetailViewのように、フラグを延々とBindingする必要があって柔軟性が低いと個人的に感じます。

ご紹介した実装方法はベストかというとそうではないと思います。私たちのチームでは、まずこの方針をベースに、新規開発のアプリの画面遷移を管理していって、よりよい方法を探し続けていきたいと思います。SwiftUIのディープリンク対応はこのやり方もあるよ!という方がいましたら、ぜひ教えてください!!