こんにちは、iOSエンジニアの @chuymaster です!iOSDC Japan 2021の「スタディサプリ」がFull SwiftUIを選択した先に見えてきたもの。のトークセッションで紹介された、プログラムで画面遷移を制御する方法について詳しく解説します。トークで話しきれなかった背景等についても触れます。
※本記事のサンプルコードは Xcode 12.5.1、iOS14で作成しています。
実現したい機能要件
APIと通信して条件に適した場合のみ、一覧画面から詳細画面をプッシュ遷移したい。
よくある要件だと思いますが、UIKitでは navigationController?.pushViewController
と書けば済むものの、SwiftUIだと一筋縄では行かないので、私たちが使っている方法をご紹介します。
NavigationLinkとは
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) } ) } } } }
このように実装すると、一覧画面からタップして、詳細画面に遷移できます。
なお、Previewsで動作確認したいときは、 NavigationView
で囲ってあげるとPreviewsで画面遷移を行うことができるようになります。
struct SimpleNavigationLinkView_Previews: PreviewProvider { static var previews: some View { NavigationView { SimpleNavigationLinkView() } } }
なぜプログラムで画面遷移を制御する必要があるか
上記の例では、ユーザーのタップ入力を受ける前提なので、プログラムでの遷移には使えません。そのため、下記のような要件を実現できない課題があります。
- 講座を開始する前に権限があるかどうかをチェックして、権限がある場合のみ画面遷移する
- 情報入力画面でPOSTでデータを送信して、正常なレスポンスが来た場合のみ次の画面に遷移する
このような、「通信が完了したら遷移する」処理を実装するには、 NavigationLink
の isActive
引数を取るイニシャライザーを使います。
/// isActiveイニシャライザーのNavigationLink NavigationLink( destination: Text("Hello World!"), isActive: $isActive, label: { Text("Tap Me") } )
isActive
は Binding<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") } } ) } }
上記の例では、Button
を label
にセットして、ユーザーがタップできるようにしました。処理としては DispatchQueue.main.asyncAfter(deadline: .now() + 1)
でディレイを入れているだけですが、実際は通信を行って、その後にフラグを変えると良いでしょう。
※画像は分かりやすいようにローディングオーバーレイを表示しています。
なぜ隠し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内でタップしたらすぐ遷移する」ことを回避するには、 NavigationLink
とは別にトリガーを作る必要があります。その代わり、 NavigationLink
の label
プロパティに 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") } } } } }
NavigationLink
が List
の中にあると、タップを拾ってしまうので、 List
の外に置く必要があります。これでボタンの処理が有効になって、遷移を遅延させることができました。ただし、 NavigationLink
で自動で追加される >
アイコンが消えてしまうので、カスタムでボタンの見た目を変える必要があります。
なお、 List
を使わず、 VStack
でテーブルUIを作る場合、隠しNavigationLinkを使わないで、 NavigationLink
の label
に Button
をセットしても問題ありません。その代わり、 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
引数には Selection
の isSelected
プロパティをバインディングさせています。アイテムが選択されるとこの分岐に入り、 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の @State
で Selection
オブジェクトを管理していますが、本番のMVVMのアプリではViewModelの @Published
プロパティで管理しています。そして、各通信処理の結果によって Selection
の値がどう変わるべきかのユニットテストを書いています。
UIKitの場合、画面遷移のテストを書くには、対象のViewControllerが表示されているかどうかを見ないといけなくて大変な印象です。それに対してSwiftUIは、バインディングに使うフラグが画面遷移に紐付いているので、そのフラグを見るだけで挙動を担保できます。
まとめ
「APIと通信して条件に適した場合のみ、一覧画面から詳細画面へプッシュ遷移したい。」という要件に対して、 隠しNavigationLink
と Selection
を組み合わせることで実装できました。SwiftUIの画面遷移の実装に悩んでいる方の助けになれば幸いです。
採用のお知らせ
Quipper ではスタディサプリの開発に関わる iOS エンジニア および シニア iOS エンジニア を積極的に募集しています。
カジュアル面談も行っているので、ぜひお気軽にご連絡ください!