スタディサプリ Product Team Blog

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

SwiftUIの隠しNavigationLinkを使って画面遷移をプログラムで制御する

こんにちは、iOSエンジニアの @chuymaster です!iOSDC Japan 2021の「スタディサプリ」がFull SwiftUIを選択した先に見えてきたもの。トークセッションで紹介された、プログラムで画面遷移を制御する方法について詳しく解説します。トークで話しきれなかった背景等についても触れます。

完成イメージ

※本記事のサンプルコードは Xcode 12.5.1、iOS14で作成しています。

実現したい機能要件

APIと通信して条件に適した場合のみ、一覧画面から詳細画面をプッシュ遷移したい。

よくある要件だと思いますが、UIKitでは navigationController?.pushViewController と書けば済むものの、SwiftUIだと一筋縄では行かないので、私たちが使っている方法をご紹介します。

NavigationLink は画面遷移を制御するViewです。 NavigationView と一緒に使うことでプッシュ遷移を行うことができます。Appleのチュートリアルが分かりやすく実装方法を解説しているので、初めて使う方はご参照ください。

/// チュートリアル内容を元に書いたコード
struct SimpleNavigationLinkView: View {
    private let items = ["Apple", "Banana", "Cat", "Dog"]

    var body: some View {
        List {
            ForEach(items, id: \.self) { item in
                NavigationLink(
                    destination: Text(item),
                    label: {
                        Text(item)
                    }
                )
            }
        }
    }
}

このように実装すると、一覧画面からタップして、詳細画面に遷移できます。

シンプルなNavigationLinkの実装

なお、Previewsで動作確認したいときは、 NavigationView で囲ってあげるとPreviewsで画面遷移を行うことができるようになります。

struct SimpleNavigationLinkView_Previews: PreviewProvider {
    static var previews: some View {
        NavigationView {
            SimpleNavigationLinkView()
        }
    }
}

なぜプログラムで画面遷移を制御する必要があるか

上記の例では、ユーザーのタップ入力を受ける前提なので、プログラムでの遷移には使えません。そのため、下記のような要件を実現できない課題があります。

  • 講座を開始する前に権限があるかどうかをチェックして、権限がある場合のみ画面遷移する
  • 情報入力画面でPOSTでデータを送信して、正常なレスポンスが来た場合のみ次の画面に遷移する

このような、「通信が完了したら遷移する」処理を実装するには、 NavigationLinkisActive 引数を取るイニシャライザーを使います。

/// isActiveイニシャライザーのNavigationLink
NavigationLink(
    destination: Text("Hello World!"),
    isActive: $isActive,
    label: {
        Text("Tap Me")
    }
)

isActiveBinding<Bool> を渡して、そのフラグが true になると遷移が行われます。なので、プログラムでフラグを変えれば、遷移させることができます。@State でフラグ管理しておいて、通信が完了したらフラグを変えると良いです。

/// プログラムで遷移を制御する例
struct ProgrammableNavigationLinkView: View {
    @State private var isActive = false
    
    var body: some View {
        NavigationLink(
            destination: Text("Hello World!"),
            isActive: $isActive,
            label: {
                Button(action: {
                    // 通信を行う模擬実装
                    DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                        isActive = true
                    }
                }) {
                    Text("Tap Me")
                }
            }
        )
    }
}

上記の例では、Buttonlabel にセットして、ユーザーがタップできるようにしました。処理としては DispatchQueue.main.asyncAfter(deadline: .now() + 1) でディレイを入れているだけですが、実際は通信を行って、その後にフラグを変えると良いでしょう。

プログラムでNavigationLinkを制御

※画像は分かりやすいようにローディングオーバーレイを表示しています。

なぜ隠しNavigationLinkが必要なのか

では、記事のタイトル名にもなっている 隠しNavigationLink について説明します。

前述のコードは、ボタン単体による遷移には使えますが、一覧画面を作る際に必要な List の中で使うと予期しない挙動になります。 Button で処理を書いているに関わらず、ユーザーがタップするとすぐ遷移が行われてしまい、プログラムで制御することができないのです。

/// NG例
struct ProgrammableNavigationLinkView: View {
    @State private var isActive = false
    
    var body: some View {
        List {
            NavigationLink(
                destination: Text("Hello World!"),
                isActive: $isActive,
                label: {
                    Button(action: {
                        // 通信を行う模擬実装
                        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                            isActive = true
                        }
                    }) {
                        Text("Tap Me")
                    }
                }
            )
        }
    }
}

List内ではボタンの処理が無効になる

「List内でタップしたらすぐ遷移する」ことを回避するには、 NavigationLink とは別にトリガーを作る必要があります。その代わり、 NavigationLinklabel プロパティに EmptyView をセットして、非表示状態にします。NavigationLink が見えなくなるので、私たちは 隠しNavigationLink と呼ぶ訳です。

/// 隠しNavigationLinkの実装例
struct ProgrammableNavigationLinkView: View {
    @State private var isActive = false
    
    var body: some View {
        ZStack {
            NavigationLink(
                destination: Text("Hello World!"),
                isActive: $isActive,
                label: {
                    EmptyView()
                }
            )
            List {
                Button(action: {
                    // 通信を行う模擬実装
                    DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                        isActive = true
                    }
                }) {
                    Text("Tap Me")
                }
            }
        }
    }
}

NavigationLinkList の中にあると、タップを拾ってしまうので、 List の外に置く必要があります。これでボタンの処理が有効になって、遷移を遅延させることができました。ただし、 NavigationLink で自動で追加される > アイコンが消えてしまうので、カスタムでボタンの見た目を変える必要があります。

隠しNavigationLinkで実装

なお、 List を使わず、 VStack でテーブルUIを作る場合、隠しNavigationLinkを使わないで、 NavigationLinklabelButton をセットしても問題ありません。その代わり、 List のUIとタップ時のハイライト等を自分で実装する必要があります。

隠しNavigationLinkを使って一覧画面から詳細画面へ遷移する

一覧画面から詳細画面への遷移は、一つのフラグ操作だけでなく、選択したアイテムを詳細画面に渡す必要があります。そのために、選択したアイテムを保持しておく Selection を作成し、画面遷移のトリガーに利用します。

struct Selection<T> {
  var isSelected: Bool // isActiveフラグのBinding先として定義
  var item: T? {
    didSet {
      isSelected = item != nil // アイテムをセットしてフラグを更新
    }
  }
  init(item: T?) {
    self.item = item
    isSelected = item != nil
  }
}

Viewに Selection@State 変数として定義します。初期状態は何も選択されていないのでnilで初期化しておきます。

struct ProgrammableNavigationLinkMasterDetailView: View {
    @State private var selection = Selection<String>(item: nil)
    private let items = ["Apple", "Banana", "Cat", "Dog"]
    ...
}

次に、隠しNavigationLinkを作成します。 body が長くなりすぎないように、変数として定義します。

@ViewBuilder
private var navigationLinkIfPossible: some View {
    if let selectedItem = selection.item {
        NavigationLink(
            destination: Text(selectedItem),
            isActive: $selection.isSelected) {
            EmptyView()
        }
    } else {
        EmptyView()
    }
}

if 分岐で、アイテムがあった場合のみ、 NavigationLink を生成しています。 isActive 引数には SelectionisSelected プロパティをバインディングさせています。アイテムが選択されるとこの分岐に入り、 isSelected フラグも true なので、画面遷移が自動的に行われる仕組みです。

ちなみに、上記のように、異なるタイプのViewを返却したい場合、 @ViewBuilder を使うと AnyView にキャストしなくて済むので便利です。

そして、ZStack を使って隠しNavigationLinkを設置して、最後に List を作成し、アイテムを選択した際に Selection にセットすれば完成です。

struct ProgrammableNavigationLinkMasterDetailSelectionView: View {
    @State private var selection = Selection<String>(item: nil)
    private let items = ["Apple", "Banana", "Cat", "Dog"]

    var body: some View {
        ZStack {
            List {
                ForEach(items, id: \.self) { item in
                    Button(action: {
                        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                            // 選択されたアイテムをselectionにセットして遷移させる
                            selection = .init(item: item)
                        }
                    }) {
                        Text("\(item)")
                    }
                }
            }
            navigationLinkIfPossible
        }
    }
    ...
}

これで、非同期処理完了後、選択されたアイテムが Selection にセットされ、遷移が行われます。

一覧画面から詳細画面に遷移

メリット

この実装で、プログラムによって遷移を制御できるので、通信等の非同期処理に限らず、イベントログ送信処理等を仕込むこともできます。また、プログラムで画面遷移を制御できると、ユニットテストも書けるようになります。

今回の例では、Viewの @StateSelection オブジェクトを管理していますが、本番のMVVMのアプリではViewModelの @Published プロパティで管理しています。そして、各通信処理の結果によって Selection の値がどう変わるべきかのユニットテストを書いています。

UIKitの場合、画面遷移のテストを書くには、対象のViewControllerが表示されているかどうかを見ないといけなくて大変な印象です。それに対してSwiftUIは、バインディングに使うフラグが画面遷移に紐付いているので、そのフラグを見るだけで挙動を担保できます。

まとめ

APIと通信して条件に適した場合のみ、一覧画面から詳細画面へプッシュ遷移したい。」という要件に対して、 隠しNavigationLinkSelection を組み合わせることで実装できました。SwiftUIの画面遷移の実装に悩んでいる方の助けになれば幸いです。

採用のお知らせ

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

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