SwiftUI における Tree based・Stack based Navigation
SwiftUI の Navigation、いわゆる画面遷移 (これは正確ではないため後ほど補足します) は、状態駆動で表現できることが特徴です。
例えば SwiftUI で sheet を表示する方法としては、以下のようなものがあります。
struct SomeView: View {
@State private var isShowingSheet = false
ContentView()
.sheet(isPresented: $isShowingSheet) {
SheetContentView()
}
}
上記の場合、isPresented
は Binding<Bool>
となっているため、isShowingSheet
が true
になったタイミングで 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
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.
いわゆる NavigationStack
や TabView
など、container と見做せるようなものは Apple が定義している「Navigation」に当てはまるようです。
実際に Navigation 配下にある API を見てみても、
NavigationSplitView
NavigationStack
NavigationLink
navigationDestination
TabView
- ...
などが定義されているため、これらが Apple が定義する「Navigation」と言えそうです。
Modal presentations
次に 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 で発表したこともあるので、そちらを見ていただけると少し理解しやすいかもしれないです。
例えば、アイテム一覧画面からアイテム編集画面までドリルダウンして行って、最終的に 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