😬

[TCA] Tree-based Navigationで Destination Enumを使うべき理由

2024/09/01に公開

概要

画面遷移をTCAのTree-based Navigationを利用して実装したときに、遷移先が2つ以上ある場合は、以下の理由でDestination enumを利用すべきである。

  • コードが短く書ける
  • 遷移用StateとViewの不整合を防げる
  • 遷移用Stateの状態を理解しやすい
  • 遷移用Stateのとりうるすべての状態が有効

TCAのTree-based Navigationってなんだっけ?

https://zenn.dev/ueeek/articles/20240901tca_navigation
https://zenn.dev/ueeek/articles/20240901tca_tree_based_navigation

Destination Enumとは

ある画面に、複数の遷移先が存在する場合は、それぞれの遷移先に対してOptional Stateを用意するのではなく、1つのEnum(Destination Enum)で済ます方法。

比較

State

  • それぞれの遷移先に対してOptional Stateを用意する
    struct State: Equatable {
        // 各遷移先にStateを1つずつ
        @Presents var childA: ChildA.State?
        @Presents var ChildB: ChildB.State?
        @Presents var ChildC: ChildC.State?
    }
  • 1つのEnumで済ますと
@Reducer(state: .quatable)
enum Destination {
    case childA(ChildA)
    case childB(ChildB)
    case childC(ChildC)
}

struct state: Equatable {
    @Presents var destination: Destination.State?
}

Destination Enumを定義する必要があるが、Optional Stateの数は1つに減っている。

Action

  • それぞれの遷移先に対してOptional Stateを用意する
enum Action: Sendable {
    case childA(PresentationAction<ChildA.Action)
    case childB(PresentationAction<ChildB.Action)
    case childC(PresentationAction<ChildC.Action)
}
  • 1つのEnumで済ますと
enum Action: Sendable {
    case destination(PresentationAction<Destination.Action>)
}

1つのcaseで済むようになる。

Reducerのbody

  • それぞれの遷移先に対してOptional Stateを用意する
var body: some Reducer<State, Action> {
    Reduce { state, action in 
        // ...
    }
    .ifLet(\.$childA, action: \.childA) {
        ChildA()
    }
    .ifLet(\.$childB, action: \.childB) {
        ChildB()
    }
    .ifLet(\.$childC, action: \.childC) {
        ChildC()
    }
}
  • 1つのEnumで済ますと
var body: some Reducer<State, Action> {
    Reduce { state, action in 
        // ...
    }
    .ifLet(\.$destination, action: \.destination)
}

1つのifLetで済むようになる。

enum Destinationについている@Reducer Macroのおかげで、面倒なボイラープレートなコードの量が減っている。

Destination Enumを使うべき理由

コードが短くなる以外に以下の利点がある。

UIとStateの不整合を防げる

遷移先が複数ある場合、Optional Stateを使用すると、遷移先の数だけ、Optional Stateを用意する必要がある。
その場合、正しく運用しないと、同時に複数の遷移先のStateがnon-nilになってしまう可能性がある。
現在のSwiftUIでは、同時にPresentできるViewは1つなので、複数のStateがnon-nilになることは、UIとStateの整合性が失われることになる。

それに対し、Destination Enumでは、Stateは1つなので、同時に複数のViewがPresentされることはない。

とりうる状態数が少ないため、状態を理解しやすい

複数の遷移先のStateが存在すると、現在表示されているViewが何なのか調べるのが難しくなる。
すべてのOptional Stateを調べ、non-nilのものを見つける必要がある。また、複数のStateがnon-nilの場合の解釈も難しい。

それに対し、Destination Enumでは、遷移用のStateは1つなので、表示されているViewを理解しやすい。

無効な状態数がない

遷移先が複数ある場合、Optional Stateを使用すると、遷移先が増えるたびに、とりうる状態数が2倍になる。
とりうる状態数は、遷移先をN個とすると、2^N個。
その内、真に有効なものは、N+ 1しかない。
その他は、同時に複数の遷移先のStateがnon-nilになってしまう。

それに対し、Destination Enumでは、とりうる状態数はN + 1だけになり、すべて有効な状態となる。
Destination Enumを利用することで、コンパイル時に、同時に1つの遷移先のみが有効になることを保証できる。

結論

これらの3つの理由により、複数の遷移先が存在する場合は、それぞれの遷移先に対してOptional Stateを用意するより、1つのEnumで済むDestination Enumのほうが好まれる。

Sampleコードで理解する

以下の仕様を考える

  • 商品のリストを表示する画面(ItemListFeature)
  • 商品リストからは、3つの画面に遷移できる
    • 商品の詳細画面(ItemDetailFeature)
    • 商品の編集画面(ItemEditFeature)
    • 商品の追加画面(ItemAddFeature)

Viewの実装の例

どちらの実装方法でも、Viewは大体、以下のようになる。
(Destination Enumだと、scopeのところが、scope(\.destination?.itemDetail, action: \.destination.itemDetail)のようになる)

ItemListView.swift
struct ItemListView: View {
    @Bindable var store: StoreOf<ItemListFeature>

    var body: some View {
        NavigationStack {
            // ...
        }
        // Drill downで ItemDetailを表示
        .navigationDestination(item: $store.scope(state: \.itemDetail, action: \.itemDetail)) { store in
            ItemDetailView(store: store)
        }
        // Drill downで ItemEditを表示
        .navigationDestination(item: $store.scope(state: \.itemEdit, action: \.itemEdit)) { store in
            ItemEditView(store: store)
        }
        // Sheetで ItemAddを表示
        .sheet(item: $store.scope(state: \.itemAdd, action: \.itemAdd)) { store in
            ItemAddView(store: store)
        }
        .toolbar {
            // ...
        }
    }
}

Reducerの実装

Reducerの実装は2つの間で、かなり違いがある。

各遷移先にOptional Stateを用意する

ItemListFeature.swift
@Reducer
struct ItemListFeature {
    @ObservableState
    struct State: Equatable {
        // 各遷移先にStateを1つずつ
        @Presents var itemDetail: ItemDetailFeature.State?
        @Presents var itemEdit: ItemEditFeature.State?
        @Presents var itemAdd: ItemAddFeature.State?

        var itemList: [String] = items
    }

    enum Action: Sendable {
        // Button interaction
        case tapItem(item: String)
        case tapEdit(item: String)
        case tapAdd

        // For Navigation
        // 各遷移先にActionを1つずつ
        case itemDetail(PresentationAction<ItemDetailFeature.Action>)
        case itemEdit(PresentationAction<ItemEditFeature.Action>)
        case itemAdd(PresentationAction<ItemAddFeature.Action>)
    }

    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
            case .tapItem(let item):
                state.itemDetail = ItemDetailFeature.State(item: item)
                return .none
            case .tapEdit(let item):
                state.itemEdit = ItemEditFeature.State(item: item)
                return .none
            case .tapAdd:
                state.itemAdd = ItemAddFeature.State()
                return .none
            case .itemDetail, .itemEdit, .itemAdd:
                return .none
            }
        }
        // 各遷移先にifLetを1つずつ
        .ifLet(\.$itemDetail, action: \.itemDetail) {
            ItemDetailFeature()
        }
        .ifLet(\.$itemEdit, action: \.itemEdit) {
            ItemEditFeature()
        }
        .ifLet(\.$itemAdd, action: \.itemAdd) {
            ItemAddFeature()
        }
    }

Destination Enumを使用して実装する

ItemListFeature.swift
@Reducer
struct ItemListFeature {
    @Reducer(state: .equatable)
    enum Destination {
        // 各遷移先にcaseを1つずつ
        case itemDetail(ItemDetailFeature)
        case itemEdit(ItemEditFeature)
        case itemAdd(ItemAddFeature)
    }

    @ObservableState
    struct State: Equatable {
        // Stateはまとめて1つ
        @Presents var destination: Destination.State?

        var itemList: [String] = items2
    }

    enum Action: Sendable {
        case tapItem(item: String)
        case tapEdit(item: String)
        case tapAdd

        // Actionはまとめて1つ
        case destination(PresentationAction<Destination.Action>)
    }

    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
            case .tapItem(let item):
                state.destination = .itemDetail(ItemDetailFeature.State(item: item))
                return .none
            case .tapEdit(let item):
                state.destination = .itemEdit(ItemEditFeature.State(item: item))
                return .none
            case .tapAdd:
                state.destination = .itemAdd(ItemAddFeature.State())
                return .none
            case .destination:
                return .none
            }
        }
        // ifLetはまとめて1つ, 短かく書ける
        .ifLet(\.$destination, action: \.destination)
    }
}

比較してみると

各遷移先にOptional Stateを用意する Destination enumを使用する
State 各遷移先に1つずつ まとめて1つ
Action 各遷移先に1つずつ まとめて1つ
ifLet 各遷移先に1つずつ まとめて1つ
Reducer body ほぼ同じ ほぼ同じだが、少し短く書ける
コード量 より多い より少ない
その他 Destination enumを定義する必要がある

まとめ

画面遷移をTCAのTree-based Navigationを利用して実装したときに、遷移先が2つ以上ある場合は、以下の理由でDestination enumを利用すべきである。

  • コードが短く書ける
  • 遷移のStateとViewの不整合を防げる
  • 遷移のStateの状態を理解しやすい
  • 遷移のStateのとりうるすべての状態が有効

Envrionment

  • TCA v1.13.0

SampleCode

  • 各遷移先にOptional Stateを用意する

https://github.com/Ueeek/TCATreeBasedNavigationSample/blob/main/TCATreeBasedNavigationSample/Features/ItemList/ItemListFeature2.swift

  • Destination Enumを使用して実装する例

https://github.com/Ueeek/TCATreeBasedNavigationSample/blob/main/TCATreeBasedNavigationSample/Features/ItemList/ItemListFeature3.swift

Ref

Discussion