深入程序化的SwiftUI NavigationView导航

9
我试图通过编程的方式实现深度嵌套的导航栈。以下代码在手动导航时按预期工作(即:按链接)。当您按下“设置导航”按钮时,导航栈确实会更改,但不是预期的,并且最终导致堆栈损坏[start -> b -> bbb],并在视图之间频繁切换。
class NavState: ObservableObject {
    @Published var firstLevel: String? = nil
    @Published var secondLevel: String? = nil
    @Published var thirdLevel: String? = nil
}

struct LandingPageView: View {

    @ObservedObject var navigationState: NavState

    func resetNav() {
        self.navigationState.firstLevel = "b"
        self.navigationState.secondLevel = "ba"
        self.navigationState.thirdLevel = "bbb"
    }

    var body: some View {

        return NavigationView {
            List {
                NavigationLink(
                    destination: Place(
                        text: "a",
                        childValues: [ ("aa", [ "aaa"]) ],
                        navigationState: self.navigationState
                    ).navigationBarTitle("a"),
                    tag: "a",
                    selection: self.$navigationState.firstLevel
                ) {
                    Text("a")
                }
                NavigationLink(
                    destination: Place(
                        text: "b",
                        childValues: [ ("bb", [ "bbb"]), ("ba", [ "baa", "bbb" ]) ],
                        navigationState: self.navigationState
                    ).navigationBarTitle("b"),
                    tag: "b",
                    selection: self.$navigationState.firstLevel
                ) {
                    Text("b")
                }

                Button(action: self.resetNav) {
                    Text("Set Nav")
                }
            }
            .navigationBarTitle("Start")
        }
        .navigationViewStyle(StackNavigationViewStyle())
    }
}


struct Place: View {
    var text: String
    var childValues: [ (String, [String]) ]

    @ObservedObject var navigationState: NavState

    var body: some View {
        List(childValues, id: \.self.0) { childValue in
            NavigationLink(
                destination: NextPlace(
                    text: childValue.0,
                    childValues: childValue.1,
                    navigationState: self.navigationState
                ).navigationBarTitle(childValue.0),
                tag: childValue.0,
                selection: self.$navigationState.secondLevel
            ) {
                Text(childValue.0)
            }
        }
    }
}

struct NextPlace: View {
    var text: String
    var childValues: [String]

    @ObservedObject var navigationState: NavState

    var body: some View {
        List(childValues, id: \.self) { childValue in
            NavigationLink(
                destination: FinalPlace(
                    text: childValue,
                    navigationState: self.navigationState
                ).navigationBarTitle(childValue),
                tag: childValue,
                selection: self.$navigationState.thirdLevel
            ) {
                Text(childValue)
            }
        }
    }
}

struct FinalPlace: View {
    var text: String
    @ObservedObject var navigationState: NavState

    var body: some View {
        let concat: String = "\(navigationState.firstLevel)/\(navigationState.secondLevel))/\(navigationState.thirdLevel)/"

        return VStack {
            Text(text)
            Text(concat)
        }
    }
}

我最初尝试解决导航过渡动画问题,但是如何禁用NavigationView推送和弹出动画表明这是不可配置的。

是否有任何合理的示例可以演示多级编程式导航工作?

编辑:我想要在这里获得的部分内容也包括正确工作的初始状态导航 - 如果我从外部环境中带有导航状态进入(即从通知中带有一些应用内上下文开始,或者从保存到磁盘的编码状态开始),那么我希望能够加载顶部View并正确指向子视图。基本上,用真实值替换NavState中的nil。Qt的QML和ReactRouter都可以以声明方式完成此操作 - SwiftUI也应该可以。


1
有趣的是,如果您在视图中使用本地@State变量,则可以完美地实现此功能...因此,您可以通过初始化传递状态来进行深度链接。 - Manny
使用DefaultNavigationViewStyle。 - Manny
1
这可能最近有所改变 - 我提交了反馈报告,表示如果其他框架可以做到这一点,SwiftUI也应该可以。他们最近回复了一些东西(我想是13.4.5 beta),但我还没有跟进。 - Andrew Lipscomb
太好了,我期待着看看他们是否修复了这个问题。干杯! - Manny
3个回答

3

更新:Xcode 14 / SwiftUI4

现在我们有支持动态异构路径的NavigationStack。因此,更新后的方法如下。

注意:虽然原始版本现在可以大大简化:a)我想保留视图层次结构;b)我想展示处理不同模型类型的方法

已在Xcode 14 / iOS 16上进行测试。

demo

在GitHub上测试

struct LandingPageView2: View {
    class NavState: ObservableObject {
        @Published var path = NavigationPath()
        let level1 = [
            "a" : ["bb", "ba"],
            "b" : ["bb", "ba"]
        ]
        let level2 = [
            "bb" : ["baa", "bbb"],
            "ba" : ["baa", "bbb"]
        ]
    }
    struct Lev1: Hashable {
        var text: String
    }
    struct Lev2: Hashable {
        var text: String
    }
    struct Lev3: Hashable {
        var text: String
    }

    func resetNav() {
        self.navigationState.path.append(Lev1(text: "b"))
        self.navigationState.path.append(Lev2(text: "ba"))
        self.navigationState.path.append(Lev3(text: "bbb"))
    }

    @ObservedObject var navigationState: NavState

    var body: some View {
        NavigationStack(path: $navigationState.path) {
            List {
                NavigationLink("a", value: Lev1(text: "a"))
                NavigationLink("b", value: Lev1(text: "b"))
                Button(action: self.resetNav) {
                    Text("Set Nav")
                }
            }
            .navigationDestination(for: Lev1.self) {
                Place(text: $0.text, childValues: navigationState.level1[$0.text] ?? [])
            }
            .navigationDestination(for: Lev2.self) {
                NextPlace(text: $0.text, childValues: navigationState.level2[$0.text] ?? [])
            }
            .navigationDestination(for: Lev3.self) {
                FinalPlace(text: $0.text)
            }
            .navigationBarTitle("Start")
        }
        .navigationViewStyle(StackNavigationViewStyle())
        .environmentObject(navigationState)
    }
    
    // MARK: -
    struct Place: View {
        var text: String
        var childValues: [String]

        var body: some View {
            List(childValues, id: \.self) {
                NavigationLink($0, value: Lev2(text: $0))
            }
            .navigationTitle(text)
        }
    }
    struct NextPlace: View {
        var text: String
        var childValues: [String]

        var body: some View {
            List(childValues, id: \.self) {
                NavigationLink($0, value: Lev3(text: $0))
            }
            .navigationTitle(text)
        }
    }
    struct FinalPlace: View {
        var text: String
        @EnvironmentObject var navigationState: NavState

        var body: some View {
            VStack {
                Text(text)
            }
        }
    }
}

翻译

这是因为当动画完成时,会形成新的堆栈级别,这也是为什么在手动点击的情况下能够工作。

使用以下修改后,它就能工作:

func resetNav() {
    self.navigationState.firstLevel = "b"
    DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
        self.navigationState.secondLevel = "ba"
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            self.navigationState.thirdLevel = "bbb"
        }
    }
}

是的,我也尝试过这种方法 - 我稍微更新了一下问题以澄清。使用其他框架进行编程导航可以使初始状态得到很好的定义,因此进入视图时不应该加载-然后执行多个转换 - 它应该直接从数据告诉它的位置开始。我认为这里的问题在于苹果对 Navigation* 结构行为的严格要求。 - Andrew Lipscomb

1
将自己的解决方案放在这里,作为一个选项,以防有人遇到与NavigationView无法满足导航需求相同的情况。在我的书中,功能比图形元素和动画更难修复。受https://medium.com/swlh/swiftui-custom-navigation-view-for-your-applications-7f6effa7dbcf的启发。
它基于一个声明性根级别变量的想法来确定导航堆栈,绑定到单个变量似乎很奇怪,当像在上面的演示中从a链跳转到b链或从特定位置开始时所需的操作是必要的。
我使用了类似URI的字符串化路径的概念作为变量概念 - 这可能可以用更具表现力的模型(如一组枚举值的向量)来替换。

有一些重要的限制 - 它非常粗糙,没有动画效果,看起来不太本地化,使用了AnyView,您不能多次使用相同的节点名称,仅反映了StackNavigationViewStyle等等。如果我将其变成更漂亮、更合理和更通用的东西,我会在Gist上发布。

struct NavigationElement {
    var tag: String
    var title: String
    var viewBuilder: () -> AnyView
}

class NavigationState: ObservableObject {
    let objectWillChange = ObservableObjectPublisher()

    var stackPath: [String] {
        willSet {
            if rectifyRootElement(path: newValue) {
                _stackPath = newValue
                rectifyStack(path: newValue, elements: _stackElements)
            }
        }
    }

    /// Temporary storage for the most current stack path during transition periods
    private var _stackPath: [String] = []

    @Published var stack: [NavigationElement] = []

    var stackElements: [String : NavigationElement] = [:] {
        willSet {
            _stackElements = newValue
            rectifyStack(path: _stackPath, elements: newValue)
        }
    }

    /// Temporary storage for the most current stack elements during transition periods
    private var _stackElements: [String : NavigationElement] = [:]

    init(initialPath: [String] = []) {
        self.stackPath = initialPath
        rectifyRootElement(path: initialPath)
        _stackPath = self.stackPath
    }

    @discardableResult func rectifyRootElement(path: [String]) -> Bool {
        // Rectify root state if set from outside
        if path.first != "" {
            stackPath = [ "" ] + path
            return false
        }
        return true
    }

    private func rectifyStack(path: [String], elements: [String : NavigationElement]) {
        var newStack: [NavigationElement] = []
        for tag in path {
            if let elem = elements[tag] {
                newStack.append(elem)
            }
            else {
                print("Path has a tag '\(tag)' which cannot be represented - truncating at last usable element")
                break
            }
        }
        stack = newStack
    }
}

struct NavigationStack<Content: View>: View {
    @ObservedObject var navState: NavigationState
    @State private var trigger: String = "humperdoo"    //HUMPERDOO! Chose something that would not conflict with empty root state - could probably do better with an enum

    init(_ navState: NavigationState, title: String, builder: @escaping () -> Content) {
        self.navState = navState
        self.navState.stackElements[""] = NavigationElement(tag: "", title: title, viewBuilder: { AnyView(builder()) })
    }

    var backButton: some View {
        Button(action: { self.navState.stackPath.removeLast() }) {
            Text("Back")
        }
    }

    var navigationHeader: some View {
        HStack {
            ViewBuilder.buildIf( navState.stack.count > 1 ? backButton : nil )
            Spacer()
            ViewBuilder.buildIf( navState.stack.last?.title != nil ? Text(navState.stack.last!.title) : nil )
            Spacer()
        }
        .frame(height: 40)
        .background(Color(.systemGray))
    }

    var currentNavElement: some View {
        return navState.stack.last!.viewBuilder()
    }

    var body: some View {
        VStack {
            // This is an effectively invisible element which primarily serves to force refreshes of the tree
            Text(trigger)
            .frame(width: 0, height: 0)
            .onReceive(navState.$stack, perform: { stack in
                self.trigger = stack.reduce("") { tag, elem in
                    return tag + "/" + elem.tag
                }
            })
            navigationHeader
            ViewBuilder.buildBlock(
                navState.stack.last != nil
                ? ViewBuilder.buildEither(first: currentNavElement)
                : ViewBuilder.buildEither(second: Text("The navigation stack is empty - this is odd"))
            )
        }
    }
}

struct NavigationDestination<Label: View, Destination: View>: View {

    @ObservedObject var navState: NavigationState
    var tag: String
    var label: () -> Label

    init(_ navState: NavigationState, tag: String, title: String, destination: @escaping () -> Destination, label: @escaping () -> Label) {
        self.navState = navState
        self.tag = tag
        self.label = label
        self.navState.stackElements[tag] = NavigationElement(tag: tag, title: title, viewBuilder: { AnyView(destination()) })
    }

    var body: some View {
        label()
        .onTapGesture {
            self.navState.stackPath.append(self.tag)
        }
    }
}

还有一些基础使用代码

struct LandingPageView: View {

    @ObservedObject var navState: NavigationState

    var destinationA: some View {
        List {
            NavigationDestination(self.navState, tag: "aa", title: "AA", destination: { Text("AA") }) {
                Text("Go to AA")
            }
            NavigationDestination(self.navState, tag: "ab", title: "AB", destination: { Text("AB") }) {
                Text("Go to AB")
            }
        }
    }

    var destinationB: some View {
        List {
            NavigationDestination(self.navState, tag: "ba", title: "BA", destination: { Text("BA") }) {
                Text("Go to BA")
            }
            Button(action: { self.navState.stackPath = [ "a", "ab" ] }) {
                Text("Jump to AB")
            }
        }
    }

    var body: some View {
        NavigationStack(navState, title: "Start") {
            List {
                NavigationDestination(self.navState, tag: "a", title: "A", destination: { self.destinationA }) {
                    Text("Go to A")
                }
                NavigationDestination(self.navState, tag: "b", title: "B", destination: { self.destinationB }) {
                    Text("Go to B")
                }
            }
        }
    }
}

0

最简单的方法是删除后续级别的@Published。

 class NavState: ObservableObject {
  @Published var firstLevel: String? = nil
    var secondLevel: String? = nil
    var thirdLevel: String? = nil
 }

这也告诉您如何有条件地更改变量可能会影响导航行为。


你试过这个吗?它会破坏基于触摸的导航。我知道你想要什么(一个objectWillChange.send()点),但我仍然无法让它不做傻事并允许两种导航类型。 - Andrew Lipscomb
是的,我尝试过。这个解决方案直接解决了“much flipping”的问题。换句话说,您可能需要多个viewModels来处理每个层次。这样更有意义。 - E.Coms
你能提供一个更具体的例子吗?我上面发布的是我的实际问题的简化版本,它涉及动态数量的层和每个层中动态数量的元素 - 我无法想象如何在没有顶层导航的通用模型的情况下使您建议的东西起作用。同时,我已经更新了问题以反映初始状态对我也很重要。 - Andrew Lipscomb
@E.Coms 我尝试了使用navstate的解决方案,并将其与Asperi的重置nav函数相结合,但我看到了相同的弹跳效果。它在所有情况下都有效,除了当我在堆栈中的第1级时。程序链接在闪烁了半秒钟的预期视图后将我弹出到主视图。 - Black

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接