SwiftUI - IOS 16 - 如何在MVVM架构中使用新的NavigationStack和NavigationPath进行程序化导航?

9

描述

以前,您可以使用NavigationLink(isActive:, destination:, label:)进行编程式导航,当isActive参数为true时触发导航。在IOS 16中,这个函数已经被弃用,并引入了NavigationStack, NavigationLink(value:, label:)NavigationPath

要了解其用法,请按照以下链接:

https://developer.apple.com/documentation/swiftui/migrating-to-new-navigation-types https://www.hackingwithswift.com/articles/250/whats-new-in-swiftui-for-ios-16(搜索NavigationStack)

我的问题是:如果我想在不同的视图和它们的ViewModel中使用并维护包含导航栈内容的数组(如NavigationPath对象),该如何做呢?

如下代码所示,我创建了一个NavigationPath对象来保存BaseView或BaseView.ViewModel中的导航栈。这样我就可以从BaseView进行程序化导航到其他页面(Page1、Page2),这很棒。

但是,如果我进入Page1并尝试从那里进行程序化导航到Page2,我需要访问原始的NavigationPath对象,也就是我在BaseView中使用的那个。

如何访问这个原始对象最好呢?

我可能误解了此新功能的用法,但如果您有任何可能的解决方案来实现ViewModel的程序化导航,我将不胜感激:)

代码

我尝试过的:

struct BaseView: View {
    @StateObject var viewModel = ViewModel()
    
    var body: some View {
        NavigationStack(path: $viewModel.paths) {
            VStack {
                Button("Page 1", action: viewModel.goToPage1)
                Button("Page 2", action: viewModel.goToPage2)
            }
            .navigationDestination(for: String.self) { stringParam in
                Page1(stringParam: stringParam)
            }
            .navigationDestination(for: Int.self) { intParam in
                Page2(intParam: intParam)
            }
            
        }
    }
}

extension BaseView {
    @MainActor class ViewModel: ObservableObject {
        @Published var paths = NavigationPath()
        
        func goToPage1() {
            let param = "Some random string" // gets the parameter from some calculation or async network call
            
            paths.append(param)
        }
        
        func goToPage2() {
            let param = 19 // gets the parameter from some calculation or async network call
            
            paths.append(param)
        }
    }
}

struct Page1: View {
    @StateObject var viewModel = ViewModel()
    let stringParam: String
    
    var body: some View {
        VStack {
            Button("Page 2", action: viewModel.goToPage2)
        }
    }
}

extension Page1 {
    @MainActor class ViewModel: ObservableObject {
        func goToPage2() {
            // Need to add value to the original paths variable in BaseView.ViewModel
        }
    }
}

struct Page2: View {
    @StateObject var viewModel = ViewModel()
    let intParam: Int
    
    var body: some View {
        Text("\(intParam)")
    }
}

extension Page2 {
    @MainActor class ViewModel: ObservableObject {
    }
}

所有视图都需要共享同一个视图模型。在您所发布的示例中,每个视图都有自己的视图模型,因此它们无法共享变量“paths”。您应该为所有代码只创建一个“ViewModel”类,并与所有视图共享同一实例。 - HunterLion
对象通常称为“存储”(Store)或“模型数据”(ModelData),它是一个环境对象,因此不需要传递到视图初始化中。 - malhal
我最终使用了单例对象作为导航,它持有NavigationPaths变量。 - Ákos Morvai
2个回答

3

官方迁移指南 提供了许多有用的信息。

修饰符 navigationDestination(for:destination:) 可以自定义处理特定数据类型。

您可以将选择的数据类型“推送”到 NavigationPath 上,然后相关的 navigationDestination 块将处理它。

我创建了一些辅助函数来简化新的导航系统。

我将它们存储在一个自定义的 AppContext 类中,您将在下面看到提到 (appContext),但当然可以根据您自己的代码库放置和引用它们。

    /// The current navigation stack.
    @Published public var navStack = NavigationPath()
    
    /// Type-erased keyed data stored for a given view.
    var navData = Dictionary<String, Any>()
    
    /// Set to `true` the given "show view" bound Bool (i.e. show that view).
    /// Optionally, provide data to pass to that view.
    public func navigateTo(_ showViewFlag: Binding<Bool>,
                      _ navData: Dictionary<String, Any>? = nil) {
        if let navData { self.navData = navData }
        showViewFlag.wrappedValue = true
    }
    
    /// Pop & retrieve navigation data for the given key.
    /// (Generics undo the type-erasure produced by `navigateTo`)
    public func popNavData<T>(_ key: String) -> T {
        navData.removeValue(forKey: key)! as! T
    }

这个 destination 修饰符是官方的 navigationDestination 修饰符的更简洁版本:
@ViewBuilder
func destination(`for` show: Binding<Bool>,
                 _ destination: @escaping () -> some View ) -> some View {
    self.navigationDestination(isPresented: show) { DeferView(destination) }
}

它使用的DeferView定义如下:
import SwiftUI

public struct DeferView<Content: View>: View {
    let content: () -> Content
    public init(@ViewBuilder _ content: @escaping () -> Content) { self.content = content }
    public var body: some View { content() }
}

现在你可以这样做:
// Use the standard bound-bool "show view" var format.
@State var showMyView: Bool

// Code to load the view, e.g. in a Button `action`.
navigateTo($showMyView, ["param1": myData1, "param2", myData2])

// Modifier to handle the view load, e.g. on outermost View inside `body`.
.destination(for: $showMyView) {
    MyView(param1: appContext.popNavData("param1"),
           param2: appContext.popNavData("param2"),
           extraParam: $someOtherSource.info)
}

1
在SwiftUI中不需要MVVM,因为View结构加上属性包装已经相当于一个视图模型对象,但速度更快,错误更少。此外,在SwiftUI中,我们甚至没有访问传统的视图层 - 它会将我们的View数据结构与现有的UIView/NSView对象进行差异比较并创建/更新/删除,使用最适合平台/上下文的对象。如果您使用对象作为视图数据,则只会遇到SwiftUI旨在消除的同样的一致性问题。

遗憾的是,网络(以及哈佛大学)充斥着关于MVVM SwiftUI的文章,这些文章的作者没有好好学习它。幸运的是,情况正在改变:

我错了!MVVM不是构建SwiftUI应用程序的良好选择(Azam Sharp)

MVVM开发人员在SwiftUI中如何错误地使用MVVM:从视图模型到状态(Jim Lai)

停止在SwiftUI中使用MVVM(Apple开发者论坛)


非常有趣。您能否向我指出更详细的在线资源,以解释为什么不应该在SwiftUI中使用MVVM以及SwiftUI如何避免某些问题? - meaning-matters
1
Swift引入了值语义到iOS开发中,SwiftUI在其设计中利用了它。我建议观看所有的SwiftUI WWDC视频,例如在以下视频中的4:18处,他说:“EditorConfig可以维护其属性的不变量并可独立测试。而且因为EditorConfig是一个值类型,对EditorConfig的任何属性的更改,如其进度,都会作为对EditorConfig本身的更改可见。” https://developer.apple.com/videos/play/wwdc2020-10040/ 另外,第二部分指出,ObservableObject仅用于模型数据,而不是视图数据,就像MVVM一样。 - malhal
1
为您添加了一些链接供您查看。 - malhal
谢谢Malcolm,我会阅读这些的。我还遇到了以下更混乱的故事:https://developer.apple.com/forums/thread/699003 - meaning-matters
这对新开发者来说非常误导人。MVVM/MVC的整个目的是封装。如果你正在构建你的第一个待办事项列表,那么确实不需要那么多样板代码。但是,如果你正在构建一个大规模的应用程序,你需要能够轻松地理解应用程序的展示和行为之间的关系。 - Caleb Faruki
演示文稿自动生成并完全封装在框架的私有层中,值得在应用不同架构之前了解其工作原理,否则可能导致低效和无法解决的问题。 - malhal

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