🚀

SwiftUI における Tree based・Stack based Navigation

2023/09/23に公開

SwiftUI の Navigation、いわゆる画面遷移 (これは正確ではないため後ほど補足します) は、状態駆動で表現できることが特徴です。

例えば SwiftUI で sheet を表示する方法としては、以下のようなものがあります。

struct SomeView: View {
  @State private var isShowingSheet = false
  
  ContentView()
    .sheet(isPresented: $isShowingSheet) {
      SheetContentView()
    }
}

上記の場合、isPresentedBinding<Bool> となっているため、isShowingSheettrue になったタイミングで sheet が表示され、false になったタイミングでは sheet が非表示になるという、まさに状態駆動で Navigation が表現されています。

sheet の他にも、アプリにおける Push 遷移を表現するための API として、navigationDestination が存在しています。
この API は例えば以下のように利用できます。

struct SomeView: View {
  @State private var showDetails = false

  NavigationStack {
    ContentView()
      .navigationDestination(isPresented: $showDetails) {
        DetailView()
      }
      .navigationTitle("...")
  }
}

この navigationDestination も、先ほどの sheet と同様に Binding<Bool>true になったタイミングで Push 遷移が実行され、false になったタイミングでは遷移先の画面から今の View に戻ってくるという形で、状態駆動の Navigation となっています。

さて、ここまでで SwiftUI は状態駆動で Navigation を表現できることがある程度説明できたと思いますが、この記事ではそんな SwiftUI の Navigation について以下のような説明をしていきます。

  • Apple が定義している Navigation
  • Point-Free が定義している Navigation
    • Tree-based navigation
    • Stack-based navigation

これから説明していく話が理解できてくると、SwiftUI における Navigation の全体像がある程度掴めて、Navigation のための状態を正しく管理できる考え方に繋がると思っています。
なお、Tree-based navigation と Stack-based navigation のメリット・デメリットやどのように使うべきかという話については、別に記事を書く予定です (記事のボリュームが大きくなってきたため)。

Apple が定義している Navigation

いきなり Point-Free が定義している Navigation から説明し始めてしまうのはあまり良くなさそうだと思うので、まずは Apple が定義している Navigation について簡単に説明します。

ここで説明するのは、HIG 的に見た Navigation という話ではなく (もちろん関連はしますが)、SwiftUI の Apple Developer Documentation の話となります。

Developer Documentation を見てみると、Navigation に関わりそうな項目として以下の二つがあることがわかります。

それぞれについて簡単に見てみましょう。

Navigation のトップページに表示されている Overview を引用します。

Use navigation containers to provide structure to your app’s user interface, enabling people to easily move among the parts of your app.

Apple は「Navigation」を「navigation containers というものを使って UI に構造を持たせ、アプリのパーツ間を簡単に移動できるようにするもの」と定義しているように見えます。

「navigation containers」とは何か、という疑問についてはすぐ下に回答があります。

For example, people can move forward and backward through a stack of views using a NavigationStack, or choose which view to display from a tab bar using a TabView.

いわゆる NavigationStackTabView など、container と見做せるようなものは Apple が定義している「Navigation」に当てはまるようです。

実際に Navigation 配下にある API を見てみても、

  • NavigationSplitView
  • NavigationStack
  • NavigationLink
  • navigationDestination
  • TabView
  • ...

などが定義されているため、これらが Apple が定義する「Navigation」と言えそうです。

次に Navigation のすぐ下にある「Modal presentations」というものを見てみます。

こちらも先ほどと同様に Overview を引用します。

To draw attention to an important, narrowly scoped task, you display a modal presentation, like an alert, popover, sheet, or confirmation dialog.

Apple が定義する「Modal presentations」というのは、「重要でスコープが狭いタスクに注意を引くための alert, popover, sheet, confirmation dialog などをモーダルプレゼンテーションする」ことを指しているようです。

こちらも実際に利用できる API を見てみると、

  • alert
  • popover
  • sheet
  • confirmationDialog
  • inspector
  • ...

など、重要かつスコープが狭いタスクに適した API たちがずらりと並んでいることがわかります。

簡単に Apple が定義している「Navigation」と「Modal presentations」について見てみましたが、これらの定義からすると SwiftUI における画面遷移などの動作をまとめて「Navigation」と呼んでしまうことには一定の違和感が生じてしまうと思います。

とは言え、Apple が定義している「Navigation」「Modal presentations」を一つにまとめて呼べる単語があれば便利です。
そして Point-Free はそれらを一つにまとめて「Navigation」と定義しています。

次は彼らがどのように「Navigation」を定義しているかを見ていこうと思います。

Point-Free が定義している Navigation

Point-Free は swiftui-navigation という SwiftUI の Navigation 用のライブラリを開発していたり、swift-composable-architecture でも Navigation support を導入するなど、Navigation に対して様々な取り組みを行ったり、考察したりしています。

そして今紹介した二つのライブラリの Documentation には、それぞれ「What is navigation?」という Article が用意されています。

個人的にこれらはとても参考になるドキュメントだと思っており、これらの中に Point-Free が定義する「Navigation」についての説明が記載されています。
この二つのドキュメントにはある程度の重複があるため、ここからは主に swift-composable-architecture の方を参考にした説明をしていこうと思います。

Point-Free は swift-composable-architecture のドキュメントの中で、以下のように「Navigation」を定義しています。

Navigation is a change of mode in the application.

「Navigation」の定義はアプリにおける mode の変更としているようです。
ここで登場した「a change of mode」についてもドキュメントの中で定義されています。

A change of mode is when some piece of state goes from not existing to existing, or vice-versa.

日本語に訳すと、「mode の変更とは、ある状態が存在しない状態から存在する状態になること、あるいはその逆のことである」となります。

要するに、Point-Free は Navigation を a change of mode と定義することで、Apple が定義していた「Navigation」および「Modal presentations」をひっくるめて呼べるようにしていることがわかります。
もちろん、「Modal presentations」が含まれているため、この「Navigation」には先ほど紹介した alert や confirmationDialog も含まれています。

ここまでで Point-Free が色々なものを含めて「Navigation」と言っていることがわかりましたが、彼らはさらに「Navigation」を大きく以下の二つに分類しています。

  • Tree-based navigation
  • Stack-based navigation

それでは、これらが何なのかについて見ていくことにしようと思います。

Tree-based navigation

冒頭で SwiftUI の Navigation は状態駆動であるという話をしていましたが、その「状態」をどのように表すかによって、Tree-based と Stack-based に分類されるという話が書かれています。

具体的には、Tree-based navigation は Swift における Optional で状態を表すものと定義されています。
もう少し具体的に見ていきましょう。

例えば、以下のような機能を持ったアプリがあったとします。

  • InventoryView というアイテム一覧を表す画面がある
  • DetailView というアイテム詳細を表す画面がある
  • EditItemView というアイテムを編集できる画面がある
  • EditItemView ではアラートが表示できる

これらの機能はそれぞれ以下のように連携するとします。

  • InventoryView の各アイテムをタップすると DetailView に遷移する
  • DetailView の Edit Button をタップすると EditItemView に遷移する
  • EditItemView でアイテムの編集を保存せずに画面を閉じようとすると Alert が表示される

以下は TCA の場合のコードの説明になってしまいますが、TCA の知識はなくても伝わると思うのと、TCA 固有の話ではないため、適宜補足しながら説明します。

まず、アイテム一覧画面の状態を表すコードは以下のようになります。

// TCA における Reducer ですが、Pure SwiftUI における
// ObservableObject 実装部分と同じようなものと捉えてもらえれば大丈夫です。
struct InventoryFeature: Reducer {
  struct State {
    // @PresentationState は TCA において Navigation を便利に管理するための
    // Property Wrapper くらいに捉えておいてもらえれば大丈夫です。
    @PresentationState var detailItem: DetailItemFeature.State?
  }
}

上記のコードを見てみると、アイテム一覧画面の状態として遷移先であるアイテム詳細画面の状態を Optional として保持していることがわかります。
この Optional な値が nil ではなくなったタイミングで画面遷移が行われ、nil であれば画面遷移は起きていないということを表すことができています。

続いて、アイテム詳細画面の状態を表すコードについても見てみます。

struct DetailItemFeature: Reducer {
  struct State {
    @PresentationState var editItem: EditItemFeature.State?
    // ...
  }
  // ...
}

構造は先ほどと同じで、今度はアイテム詳細画面の遷移先のアイテム編集画面の状態を Optional な値として保持しています。

最後に、アイテム編集画面の状態を表すコードについても見てみます。

struct EditItemFeature: Reducer {
  struct State {
    // AlertState は swiftui-navigation というライブラリによって用意されているもの。
    // Alert の状態を表すための便利なツールくらいに捉えてもらえれば大丈夫です。
    @PresentationState var alert: AlertState<AlertAction>?
    // ...
  }
  // ...
}

アイテム編集画面では alert を表示するため、alert の状態を optional なものとして保持しています。

ここまで説明してきたコードのように、遷移元の画面が遷移先の画面の状態を保持するようなコードにしておくと、Deep Link が容易になります。
この辺りの話については、過去の iOSDC で発表したこともあるので、そちらを見ていただけると少し理解しやすいかもしれないです。

https://fortee.jp/iosdc-japan-2022/proposal/0b6f453a-68f0-4300-9ab2-cb1e3457eb53

例えば、アイテム一覧画面からアイテム編集画面までドリルダウンして行って、最終的に alert を表示するという挙動までコードで以下のように表現できます。

InventoryView(
  store: Store(
    initialState: InventoryFeature.State(
      detailItem: DetailItemFeature.State(      // Drill-down to detail screen
        editItem: EditItemFeature.State(        // Open edit modal
          alert: AlertState {                   // Open alert
            TextState("...")
          }
        )
      )
    )
  ) {
    InventoryFeature()
  }
)

そして、上記の Navigation の構造こそが Point-Free が定義している Tree-based navigation そのものとなっています。
実際、アプリの各機能 (アイテム一覧、詳細など) は tree の各 node を表すものと捉えることができ、各機能からの遷移先は node からの分岐を表していると捉えることができます。

Stack-based navigation

Tree-based navigation についての説明が終わったので、次は Stack-based navigation というものが何なのか見ていくことにしましょう。

先ほど紹介した Tree-based navigation は Navigation を駆動するための状態を Optional として表現したものでした。
一方で、Stack-based navigation はその状態を Collection として表現します。
SwiftUI においては、Navigation の状態を Collection として表現できる API には NavigationStack が代表的なものとして挙げられます。

Collection による Navigation の状態管理では、何らかのアイテムが Collection に追加される時は、新しい画面が push されることを意味し、何らかのアイテムが Collection から削除される時は、画面が pop されることを意味します。

この Stack-based navigation では、通常 Navigation できる画面全ての機能を保持する enum を定義します。
Tree-based navigation の際に用いた例と同じようなものを表現する場合、アイテム一覧画面からの Navigation を表すその enum は以下のように表現できます。

enum Path {
  case detail(DetailItemFeature.State)
  case edit(EditItemFeature.State)
  // ...
}

そして、Navigation を表現する Collection は以下のように表現できます。

let path: [Path] = [
  .detail(DetailItemFeature.State(item: item)),
  .edit(EditItemFeature.State(item: item)),
  // ...
]

上記の path では、アイテム一覧画面からアイテム詳細画面に遷移し、その後アイテム編集画面に遷移しているという Navigation を表現しています。

これが Point-Free が定義している Stack-based navigation になります。

おわりに

この記事では、Apple が定義する Navigation (正確には「Navigation」および「Modal presentations」) と Point-Free が定義している Navigation について説明しました。

Point-Free が定義している Navigation における「Tree-based navigation」と「Stack-based navigation」のメリット・デメリットや、実際に使ってみてわかってきた使い分けの勘所のようなものについては別記事で紹介できればと思っています。

本記事が、SwiftUI における Navigation に対しての解像度を高められるものになっていれば嬉しいです🙏

Discussion