🔧

【SwiftUI】NavigationStackの階層が意図しないものになる原因と対処法

2023/04/14に公開

意図しない挙動

navigationDestination(for:destination:)を設定しているViewからNavigationLink(title:destination:)などで遷移した先のViewにて、設定した型のNavigationLink(title:value:)などで遷移しようとすると、遷移の階層がタップの順と異なるというもの。

環境

  • Macbook Air (M2 2022)
  • MacOS 13.3.1
  • Xcode 14.3

手順

View1からView2に遷移したのち、value2に遷移しようとすると、階層がView1/View2/value2ではなく、View1/value2/View2になってしまう。

意図しない挙動

コード

Views.swift
import SwiftUI

struct View1: View {
    var body: some View {
        NavigationStack {
            VStack {
                NavigationLink("value1", value: "value1")
                NavigationLink("View2", destination: View2())
            }
            .navigationTitle("View1")
            .navigationDestination(for: String.self) { value in
                Text(value)
                    .navigationTitle(value)
            }
        }
    }
}

struct View2: View {
    var body: some View {
        NavigationLink("value2", value: "value2")
            .navigationTitle("View2")
    }
}

原因

NavigationLink(title:destination:)navigationDestination(isPresented:destination:)などではNavigationStackpathが更新されず、これらの遷移がpathの遷移の最後に追加される仕様になってるっぽい。

対処法

destinationvalueと同様に扱うことで解決する。
しかし、ViewAnyViewHashableに準拠していないので、以下のようにラッパーを作成し、それを用いてNavigationLinknavigationDestinationを拡張する。

Navigation+.swift
import SwiftUI

extension NavigationLink {
    init<D: View>(_ titleKey: LocalizedStringKey, @ViewBuilder view: @escaping () -> D) where Destination == Never, Label == Text {
        self.init(titleKey, value: ViewWrapper(view))
    }
    
    init<S: StringProtocol, D: View>(_ title: S, @ViewBuilder view: @escaping () -> D) where Destination == Never, Label == Text {
        self.init(title, value: ViewWrapper(view))
    }
    
    init<D: View>(@ViewBuilder view: @escaping () -> D, @ViewBuilder label: () -> Label) where Destination == Never {
        self.init(value: ViewWrapper(view), label: label)
    }
}

extension View {
    func navigationDestinationForView() -> some View {
        self.navigationDestination(for: ViewWrapper.self) { wrapper in
            wrapper.view()
        }
    }
}

private struct ViewWrapper: Hashable {
    static func == (lhs: ViewWrapper, rhs: ViewWrapper) -> Bool {
        lhs.id == rhs.id
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
    
    let id = UUID()
    let view: () -> AnyView
    
    init<D: View>(_ view: @escaping () -> D) {
        self.view = { AnyView(view()) }
    }
}

そして、View1を以下のように更新すると、遷移の順とタップの順が同じと言う挙動が得られる。

View1
struct View1: View {
    var body: some View {
        NavigationStack {
            VStack {
                NavigationLink("value1", value: "value1")
                NavigationLink("View2", view: { View2() })
            }
            .navigationTitle("View1")
            .navigationDestination(for: String.self) { value in
                Text(value)
                    .navigationTitle(value)
            }
            .navigationDestinationForView()
        }
    }
}

上記を用いると、下のような挙動が得られる。

意図した挙動

GitHubで編集を提案

Discussion