NavigationViewの最初のViewを指定する

NavigationViewの最初のViewを指定する

作成日
Apr 16, 2021 9:13 PM

SwiftUIにおいて、次のような画面遷移をするNavigationViewがあるとします。

Category -> Foods -> Cart

このNavigationViewはsheetによって表示され、親のViewからは直接中間以降の画面を開けるボタンが配置されています。

これらを愚直に実装する場合、次のようなパターンのNavigationViewを実装することになります。

1. Category -> Foods -> Cart
2. Foods -> Cart
3. Cart

TCAでこれらを実装する場合、それぞれのパターンごとに根元のStateを保持する必要があります。

struct State {
  var category: Category.State?
  var foods: Foods.State?
  var cart: Cart.State?
}

しかし、この実装方法はreducerが複雑になる問題があります。

例えば、cartのdoneボタンを親が検知するには次のようなreducerを書く必要があります。

switch action {
  case .category(.foods(.card(.doneButtonTapped))), .foods(.card(.doneButtonTapped)), .cart(.doneButtonTapped):
  // something
}

各パターンを網羅する必要があり、1つの遷移パターンのdoneボタンをタップするだけでは正常に動作するかを確認できません。

これらの問題を解決するには、親になるStateに各画面遷移先を持たせ最初の画面に応じてNavigationLinkを組み立てる必要があります。

func body() -> some View {
  switch initialPage {
    case .category:
			categoryView()
		case .foods:
			foodsView()
		case .cart:
			cartView()
  }
}
func categoryView() -> some View {
	IfLetStore(store.category, then: CategoryView.init(store:))
		.link(foodsView())
}

func foodsView() -> some View {
	IfLetStore(store.foods, then: FoodsView.init(store:))
		.link(cartView())
}

func cartView() -> some View {
	ifLetStore(store.cart, then: CartView.init(store:))
}

また、TCAではIfLetStoreで生成されるViewはstateの型一致を求められるため、次のようなModifierを作って後からNavigationLinkを埋め込めるようにしています。

public struct NavigationLinkModifier<T: View>: ViewModifier {
    public init(destination: T, isActive: Binding<Bool>) {
        self.destination = destination
        self.isActive = isActive
    }
    
    let destination: T
    let isActive: Binding<Bool>

    public func body(content: Content) -> some View {
        Group {
            content
            NavigationLink(destination: destination, isActive: isActive, label: {
                EmptyView()
            })
        }
    }
}

public extension View {
    func link<T: View>(destination: T, isActive: Binding<Bool>) -> some View {
        modifier(NavigationLinkModifier(destination: destination, isActive: isActive))
    }
}

これによって、Stateがネストすることなく親の下にフラットに並べることが出来ました。

Actionも次のようにシンプルになります

switch action {
  case .cart(.doneButtonTapped):
  // something
}

また、通常であれば親は1階層下のNavigationLinkしか制御できませんがこの方式を採用すると2階層以下の画面遷移も親が制御出来るというメリットがあります。

追記:

TCAではIfLetStoreで生成されるViewはstateの型一致を求められるため

これは嘘でした

then: { store in NavigationView { View(store: store) } }

こうすれば、thenの中で諸々組めます