iOS16以前、TCA View間のナビゲーションは、主に NavigationLink を使用して実現されていました。これは既に古くなっているかもしれませんが、iOS16 ではより良い解決策が提供されています。ただし、iOS15 を最低サポートとしているアプリケーションにとっては、古い API を使用せざるを得ない状況かもしれません。
今回はNavigationLinkお使って画面遷移する方法を紹介しようと思います。前回の記事の最後我々は数字当てゲームを作りました。
- 数字当てゲーム:プログラムが-100から100の範囲で数字をランダムに選び、ユーザーが数字を入力し、プログラムがその数字が正しいかどうかを判断します。正しくない場合、フィードバックとしてhigherまたはlowerと返し、ユーザーに次の数字の入力を求めます、正解した場合緑チェックを励ましてくれます。
結果を記録してデータを表示する
CounterViewの「Next」ボタンが押されると、新しい問題が開始されます。私たちは、数字を推測する後(正しいかどうかに関係なく)、「Next」ボタンが押されたときに結果を記録し、後でどのデータを推測したかを見れるようにしたいです。
各推測の結果は、以下のGameResult型で表されます:
struct GameResult: Equatable {
let secret: Int
let guess: Int
}
このデータを持つComponentを一個作ります。
先にreducerの方作ります:
public struct GameReducer: ReducerProtocol {
public struct State: Equatable {
var counter: CounterReducer.State = .init()
var results: [GameResult] = []
}
public enum Action {
case counter(CounterReducer.Action)
}
public var body: some ReducerProtocol<State, Action> {
Reduce { state, action in
switch action {
case .counter(.playNext):
let result = GameResult(secret: state.counter.secret, guess: state.counter.count)
state.results.append(result)
return .none
default:
return .none
}
}
Scope(state: \.counter, action: /Action.counter) {
CounterReducer()
}
}
}
extension GameReducer {
struct GameResult: Equatable {
let secret: Int
let guess: Int
var correct: Bool { secret == guess }
}
}
例えば、最初のsecret数字が10の場合、「Next」ボタンを押す前にカウンターを10に変更します。「Next」ボタン押しとGameResult(secret: 10, guess: 10)
を記録します。記録された結果は、resultsの配列に保存されます。
viewの方はこちらです:
struct GameView: View {
let store: StoreOf<GameReducer>
var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
VStack {
Text("ゲーム回数:" + String(viewStore.results.count))
CounterView(store: store.scope(state: \.counter, action: GameReducer.Action.counter))
}
}
}
}
新しいComponent作る時、既に以前作成済みのComponent(ここの場合はCounterView)をそのまま組み合わせできる、これはTCA(The Composable Architecture)の中核的な概念の一つです。我々はState、Action、Reduce内に子Componentの要素それぞれ定義して、View側でstore.scope
でComponentを作成するとComponentの組み合わせは完了です。
Reducer内のScopeについて、Scope(state: \.counter, action: /Action.counter)
を書くと子Component(ここはCounterView)のActionが親Component(ここはGameView)受け取る事が出来ます。実際この行入れる事によってstateの同期(pullback)も実現してます、これについては後で説明します。
CounterViewをGameViewに書き換えて、RunしますとTextとCounterViewが表示され、「Next」を押すとデータをresults
に保存されて、ゲーム回数も変わります。
IdentifiedArray を使用して改造する
実際の開始前に、results
配列にいくつかの改造を加える必要があります:TCA で定義された IdentifiedArray を単純な Swift Array の代わりに使用します。
public struct State: Equatable {
var counter: CounterReducer.State = .init()
- var results: [GameResult] = []
+ var results = IdentifiedArrayOf<GameResult>()
}
コンパイルエラーが発生するですが、一旦エラーは置いておいて、Arrayと比較してIdentifiedArrayの利点を見てみましょう:
- 通常のArrayと同様に、IdentifiedArrayも要素の順序を尊重し、indexを基にしたO(1)のランダムアクセスをサポートしています。さらに、Arrayと互換性のあるAPIを提供しています。
- ただし、Arrayとは異なり、IdentifiedArrayはその中の要素がIdentifiableプロトコルに従うことを要求します。つまり、idプロパティを含むインスタンスだけが含まれることができ、そのidは一意であることを保証し、同じidを持つ要素が含まれないようにする必要があります。
- Identifiableと唯一性の保証があることで、IdentifiedArrayはidを使用して要素を素早く検索するような方法を利用できます。(Dictionaryのような)
一連のデータをArrayを使用してモデル化することは、最も簡単でシンプルなアイデアです。しかし、アプリが複雑になると、Arrayの処理はパフォーマンスの低下やエラーの原因になりやすいです。
- Array.firstIndex(of:)を使用すると、O(n)の複雑さが必要になります。
- インデックスを使用して要素を取得することはO(1)ですが、非同期の状況を処理する場合、非同期操作が開始された時点のインデックスとその後のインデックスが一致しない可能性があり、エラーが発生する可能性があります(非同期の間に同期的な方法でいくつかの要素を削除した場合を想像してみてください:非同期操作の前に保存されたインデックスは無効になり、そのインデックスにアクセスすると異なる要素が取得される可能性があり、クラッシュが発生する可能性があります)。
IdentifiedArrayはIdentifiableに基づいたアクセス方法を提供することにより、上記の2つの問題を同時に解決できます。このシンプルな例では、Arrayを使用しても問題ありませんが、TCAの世界、または一般的なSwift開発の中で、上記の問題に直面する場合、IdentifiedArrayを使用して対処できます。
アプリに戻って、IdentifiedArrayOf が有効になるようにするために(これは IdentifiedArray< Element.ID, Element >
の型です)、GameResultがIdentifiableを満たす必要があります。CounterReducer.Stateが既にIdentifiableを満たしているので、GameResultを簡単に修正してCounterReducer.Stateを直接含める方法があります:
extension GameReducer {
- struct GameResult: Equatable {
+ struct GameResult: Equatable, Identifiable {
- let secret: Int
- let guess: Int
+ let counter: CounterReducer.State
- var correct: Bool { secret == guess }
+ var correct: Bool { counter.secret == counter.count }
+ var id: UUID { counter.id }
}
}
Reducerを更新して、ビルドエラー解消します:
public var body: some ReducerProtocol<State, Action> {
Reduce { state, action in
switch action {
case .counter(.playNext):
- let result = GameResult(secret: state.counter.secret, guess: state.counter.count)
+ let result = GameResult(counter: state.counter)
state.results.append(result)
return .none
default:
return .none
}
}
コンパイルして実行すると、アプリの動作は変わらないはずですが、内部的には結果データの保存に IdentifiedArray を使用するように変更されました。
resultのfeatureを単独で作る
Game featureと同様に、結果リストの画面も、状態(state)、リデューサー(reducer)、アクション(action)などの要素で構成されています。再度強調しますが、これがTCAの素晴らしい点です:単純な小さなコンポーネントを作成し、それらを組み合わせて大きなコンポーネントを構築するのがとても楽し。
State関しては、現段階は配列一個必要と思います、単純に IdentifiedArrayOf を使用して表現できます。新しい GameResultListViewReducer.swift ファイルを作成し、以下の内容を追加します:
public struct GameResultListReducer: ReducerProtocol {
public struct State: Equatable {
var results = IdentifiedArrayOf<GameReducer.GameResult>()
}
public enum Action {
case remove(offset: IndexSet)
}
public var body: some ReducerProtocol<State, Action> {
Reduce { state, action in
switch action {
case .remove(let offset):
state.results.remove(atOffsets: offset)
return .none
}
}
}
}
表示するだけでなく、結果を削除することもできるようにしたいので、ActionとReducer内にremoveを追加しました。
これらの部分については、すでに詳しく理解されていると思いますかな。最後に、GameResultListView.swift を作成し、これらの要素を組み合わせるだけです。
struct GameResultListView: View {
let store: StoreOf<GameResultListReducer>
var body: some View {
WithViewStore(store) { viewStore in
List {
ForEach(viewStore.state.results) { result in
HStack {
Image(systemName: result.correct ? "checkmark.circle" : "x.circle")
Text("Secret: \(result.counter.secret)")
Text("Answer: \(result.counter.count)")
}.foregroundColor(result.correct ? .green : .red)
}
}
}
}
}
まだGameResultListViewをアプリに追加していないので、それを検証するためにはプレビューを追加できます。
struct GameResultListView_Previews: PreviewProvider {
static var previews: some View {
GameResultListView(
store: .init(
initialState: .init(results: [
GameReducer.GameResult(counter: .init(id: .init(), secret: 20, count: 20)),
GameReducer.GameResult(counter: .init())
]),
reducer: GameResultListReducer()))
}
}
削除機能を入れる
SwiftUIでデフォルトの削除操作を追加するのは非常に簡単で、セルにonDeleteを追加するだけです。EditButtonも追加します。
struct GameResultListView: View {
let store: StoreOf<GameResultListReducer>
var body: some View {
WithViewStore(store) { viewStore in
List {
ForEach(viewStore.state.results) { result in
// ...
}
+ .onDelete { viewStore.send(.remove(offset: $0)) }
}
+ .toolbar {
+ EditButton()
+ }
}
}
}
onDeleteの中で、viewStoreに.removeアクションを送信し、それによってReducerがトリガーされ、Stateが更新されるようにします。プレビューで実行を選択すると、表示されているアイテムをプレビュー画面内で直接削除することができます。
基本的なNavigation
次に、この新しく作成した GameResultListView をナビゲーションの方法で表示します。リスト機能とアプリの他の機能(GameView)を組み合わせる方法は変わりません。つまり、子コンポーネントのstate、action、reducer、viewをすべて親コンポーネントに統合することです。
ここでは、ナビゲーションバーに「Detail」ボタンを追加し、NavigationLinkを使用して結果リストを表示する予定です。まず、CounterApp.swiftにNavigationViewを追加し、アプリ全体のコンテナとします:
@main
struct CounterApp: App {
var body: some Scene {
WindowGroup {
+ NavigationView {
GameView(store: .init(initialState: .init(), reducer: GameReducer()._printChanges()))
+ }
}
}
}
State
GameReducer.State内には既にvar results: IdentifiedArrayOf<GameResult>
のデータソースが存在していますが(最初の時作ったresults配列です)、これを直接リスト画面のstateとして使用するにはGameResultListReducer.Stateに包む必要があります。
public struct State: Equatable {
var counter: CounterReducer.State = .init()
- var results = IdentifiedArrayOf<GameReducer.GameResult>()
+ var resultList = GameResultListReducer.State(results: IdentifiedArrayOf<GameReducer.GameResult>())
}
Action
GameResultListViewでresultの配列を操作すると同時に、結果をGameReducer.State.resultList
にも取り戻したいと考えています。そのために、GameResultListActionを処理するアクションが必要です。GameReducer.Actionに新しいメンバーを追加してみましょう:
public enum Action {
case counter(CounterReducer.Action)
+ case gameResultList(GameResultListReducer.Action)
}
Reducer
Reducerを修正して、GameResultListReducerが.gameResultListのアクションに基づいてAction結果をresultListに反映するようにします(pullback)。Reducer内で最後にScopeを追加します:
public var body: some ReducerProtocol<State, Action> {
Reduce { state, action in
// ...
}
Scope(state: \.counter, action: /Action.counter) {
CounterReducer()
}
+ Scope(state: \.resultList, action: /Action.gameResultList) {
+ GameResultListReducer()
+ }
}
これにより、.gameResultListアクションを受け取ると、GameResultListReducerによって引き起こされる結果(新しいresults、すなわちIdentifiedArrayOf)が、GameReducer.State.resultListにも反映します。
最後に、body内でNavigationLinkを作成し、scopeを使用してresultListを切り出し、新しいStore使ってGameResultListView作成してNavigationに渡す。これでナビゲーションが完了します。
var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
VStack {
- Text("ゲーム回数:" + String(viewStore.results.count))
+ Text("ゲーム回数:" + String(viewStore.resultList.results.count))
CounterView(store: store.scope(state: \.counter, action: GameReducer.Action.counter))
}
}
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ NavigationLink("Detail") {
+ GameResultListView(store: store.scope(state: \.resultList, action: GameReducer.Action.gameResultList))
+ }
+ }
+ }
}
Runします!数字を当てて、「Next」ボタンを押すと、データが保存されます。「Detail」ボタン押すとリストに遷移されて、保存されたデータ表示されます。removeして、最初の画面に戻るとゲーム回数:0
が表示されます。
実はここにバグがあります、何回「Next」押してもなぜかゲーム回数:1
ど表示される、リストのデータも一個のみ、このバグの解消はあなたにお任せします!(ヒント:CounterのplayNextが怪し)
存在する問題
TCAやElmのようなアーキテクチャ形式の特徴の一つは、StateがUIを完全に決定することです。これはUIテストを行う際に非常に重要な手段です。適切なState(モデル層)を構築できれば、固定されたUIが期待できます。これにより、アプリ全体のインターフェースは「Pure Functions(純粋関数)」になります:UI = F(State)。
しかし、残念ながら、上記の単純なナビゲーション形式はこの公式を壊してしまいます。メインページを表示する際のStateとリストページを表示する際のStateは区別できず、同じ状態が異なるUIに対応する可能性があります。これは、ナビゲーションを管理する状態がSwiftUI内部に存在し、私たちのStateには反映されていないためです。
アプリの厳密さについてあまり気にしないのであれば、この単純なナビゲーション関係も受け入れられるかもしれません。しかし、純粋関数の要件を満たすために、SwiftUIが提供する別のナビゲーション方法Binding値に基づくナビゲーションがあります、どのようにTCAと協調して機能するか見てみましょう。
Binding Navigation
init(_:destination:)
のような単純なものだけでなく、NavigationLinkにはBindingを使用するいくつかのバリエーションがあります。例えば:
init(
_ titleKey: LocalizedStringKey,
isActive: Binding<Bool>,
@ViewBuilder destination: () -> Destination
)
init<V>(
_ titleKey: LocalizedStringKey,
tag: V,
selection: Binding<V?>,
@ViewBuilder destination: () -> Destination
) where V : Hashable
前者はBindingを受け取り、このBindingはナビゲーションの状態を制御します、Bindingを変える方法は二種類あります:
- ユーザーがUIを通じてナビゲーションをトリガーした場合、SwiftUIはこの値をtrueに設定します。戻るボタンを使用して戻る際には、SwiftUIはこの値をfalseに設定します。
- このBinding値をtrueまたはfalseにコードで設定することによって、ナビゲーションをコントロールします。
前者のBool
と比較して、後者はV?
型のバインディング値と、現在のNavigationLinkを表すタグ値を受け取ります。selection
のV
とtag
のV
が同じである場合、ナビゲーションが有効になり、destinationの内容が表示されます。この同一性を判断するために、SwiftUIはV
がHashable
を満たす必要があります。
これらの2つのバリエーションは、TCAにとって、ナビゲーションの状態をStateを介して制御する機会を提供しています。GameReducer.State
にナビゲーション状態を示す変数を追加するだけで、この変数をBindingに変換し、設定することで、状態とUIを一致させることができます:つまり、stateがtrueまたはnon-nilの場合、リスト画面が表示されます。それ以外の場合、falseまたはnilの場合、ゲーム画面が表示されます。
Identified
この例では、Binding<V?>
を使用して制御します。GameReducer.State内に遷移用のState追加します:
public struct State: Equatable {
var counter: CounterReducer.State = .init()
var resultList = GameResultListReducer.State(results: IdentifiedArrayOf<GameReducer.GameResult>())
+ var selectionResultList: Identified<UUID, GameResultListReducer.State>?
}
Binding<V?>
の場合、V
はHashable
を満たす必要があります。最も簡単な方法は、Identified
でラップすることです。
BindingとNavigationの処理
多分(前回の記事)TCAのBinding値をどのように処理するかを覚えていると思いますかな。`viewStore.binding
を使用してBinding値を操作すると、この値が変化した際にTCAがActionを送信するようになります。私たちは、Reducer内でこのActionをキャッチし、selectionResultListに適切な値を設定する必要があります。GameReducer.Actionにナビゲーションを制御するActionを追加します:
public enum Action {
case counter(CounterReducer.Action)
case gameResultList(GameResultListReducer.Action)
+ case setNavigation(UUID?)
}
GameReducer 内で、このアクションをキャッチして処理します:
public var body: some ReducerProtocol<State, Action> {
Reduce { state, action in
// ...
+ case .setNavigation(.some(let id)):
+ state.selectionResultList = .init(state.resultList, id: id)
+ return .none
+ case .setNavigation(.none):
+ state.resultList.results = state.selectionResultList?.value.results ?? []
+ state.selectionResultList = nil
+ return .none
default:
return .none
}
}
Scope(state: \.counter, action: /Action.counter) {
CounterReducer()
}
+ .ifLet(\State.selectionResultList, action: /Action.gameResultList) {
+ Scope(state: \Identified<UUID, GameResultListReducer.State>.value, action: .self) {
+ GameResultListReducer()
+ }
}
}
gameReducer 内で、このアクションをキャッチして処理します。
.id
を持つ.setNavigation
Actionを受け取った場合、selectionResultListを手動で設定し、ナビゲーションがトリガーされます。ユーザーがナビゲーションを終了した場合、.setNavigation(.none)
を受け取り、このときstate.resultList.resultsを更新して、selectionResultListをnilに設定してナビゲーションから戻ります。
.ifLet
はオプショナルのstateを処理する為入れました、中身のScopeは先と同じリスト画面の変更をGame画面にも反映する為入れました。
最後View側を修正します。Reducerと同じ、TCAはStore内のオプショナル属性を処理する際に、IfLetStore
を使用してラップします。これは、内部の状態のオプション値がnilかどうかに基づいて異なるビューを構築します。
struct GameView: View {
let store: StoreOf<GameReducer>
var body: some View {
// ...
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
- NavigationLink("Detail") {
- GameResultListView(store: store.scope(state: \.resultList, action: GameReducer.Action.gameResultList))
- }
+ WithViewStore(store, observe: { $0 }) { viewStore in
+ NavigationLink("Detail", tag: UUID(), selection: viewStore.binding(get: \.selectionResultList?.id, send: GameReducer.Action.setNavigation), destination: {
+ IfLetStore(store.scope(state: \.selectionResultList?.value,
+ action: GameReducer.Action.gameResultList),
+ then: { GameResultListView(store: $0) })
+ })
+ }
}
}
}
}
至此、私たちはBindingを使用してナビゲーションを行う改修を完成しました。アプリを実行すると、アプリ全体の動作が単純なナビゲーションと大差ないことに気づくでしょう。しかし、適切なGameStateを構築することによって、直接的に結果の詳細ページを表示することができるようになりました。これにより、アプリのトラッキングとデバッグが非常に便利になります。これこそがTCAの強力な部分です。例えば、CounterApp内にいくつかのサンプルを追加することができます:
@main
struct CounterApp: App {
var body: some Scene {
+ let sample: IdentifiedArrayOf<GameReducer.GameResult> = [
+ .init(counter: .init(id: .init(), secret: 10, count: 10)),
+ .init(counter: .init()),
+ ]
+ let testState = GameReducer.State(
+ counter: .init(),
+ resultList: .init(results: sample),
+ selectionResultList: .init(.init(results: sample), id: UUID(uuidString: "348D742D-15D0-4D51-BAB0-C24AAE5AA439") ?? UUID())
+ )
WindowGroup {
NavigationView {
- GameView(store: .init(initialState: .init(), reducer: GameReducer()._printChanges()))
+ GameView(store: .init(initialState: testState, reducer: GameReducer()._printChanges()))
}
}
}
}
NavigationLink
側のtag
とtestState
を合わせてアプリを実行すると、直接リスト画面に遷移されます。唯一の状態が唯一のUIに対応することを確認することで、開発者は問題を迅速に特定できます。問題が発生した際の状態を提供するだけで、理論的には安定した再現と即座のデバッグを開始できるはずです。
最後
NavigationLinkは既に時代遅れかもしれないですが、一応どう動くを理解して損はないと思います、ここまで見れた方ありがとうございました。