SwiftUI - 如何避免将导航硬编码到视图中?

139

我试图为一个更大、生产就绪的SwiftUI应用做架构。然而,我总是遇到相同的问题,这指向了SwiftUI中的一个主要设计缺陷。

到目前为止,没有人能够给出一个完全可行、生产就绪的答案。

如何在SwiftUI中创建包含导航的可重用视图?

由于SwiftUI的NavigationLink与View强烈绑定,因此以这种方式创建的可重用视图在大型应用程序中无法扩展。在那些小样本应用程序中,NavigationLink可以工作,但是一旦你想要在一个应用程序中重复使用多个视图,甚至跨越模块边界进行重用(例如:在iOS、WatchOS等中重用视图),NavigationLink就无法胜任了。

设计问题:NavigationLinks已经硬编码到了View中。

NavigationLink(destination: MyCustomView(item: item))

但是,如果包含此NavigationLink的视图应该是可重用的,我无法硬编码目标。必须有一种机制提供目标。我在这里问过,得到了相当不错的答案,但仍然没有完整的答案:

SwiftUI MVVM Coordinator/Router/NavigationLink

想法是将目标链接注入可重用视图中。通常想法可以工作,但不幸的是,这不能扩展到真正的生产应用程序。一旦我拥有多个可重用屏幕,我就会遇到逻辑问题,即一个可重用视图(ViewA)需要预配置的视图目标(ViewB)。但是,如果也需要预配置的视图目标,我需要在中创建,使已经注入之前,我注入到中。等等...但由于此时必须传递的数据不可用,整个构造失败。

我另外一个想法是使用环境作为依赖注入机制来注入NavigationLink的目标。但我认为这应该被认为是一种hack,而不是大型应用程序的可伸缩解决方案。我们最终会基本上使用环境来处理所有事情。但由于环境也只能在视图中使用(不能在单独的协调器或视图模型中使用),这又会创建奇怪的构造。

像业务逻辑(e.g.视图模型代码)和视图必须分开,导航和视图也必须分开(e.g.协调器模式) 在UIKit 中,这是可能的,因为我们可以访问视图后面的UIViewControllerUINavigationControllerUIKit's MVC已经存在的问题是,它将许多概念混杂在一起,变成了有趣的名字"Massive-View-Controller"而不是"Model-View-Controller"。现在,在SwiftUI中出现了类似的问题,但我认为更糟。导航和视图耦合度很高,无法解耦。因此,如果它们包含导航,则无法重用视图。在UIKit中可以解决此问题,但现在我看不到在SwiftUI中的明智解决方案。不幸的是,Apple没有向我们提供有关如何解决此类架构问题的解释。我们只得到了一些小型示例应用程序。

我希望被证明是错误的。请向我展示一个干净的应用程序设计模式,可以解决大型生产就绪应用程序的这个问题。


更新:此奖励将在几分钟内结束,不幸的是,仍然没有人能够提供工作示例。但是,如果我找不到其他解决方案并链接到这里,则将开始新的赏金来解决此问题。感谢所有人的贡献!


更新2020年6月18日: 我从苹果获得了有关此问题的答案,建议类似以下内容以解耦视图和模型:

enum Destination {
  case viewA
  case viewB 
  case viewC
}

struct Thing: Identifiable {
  var title: String
  var destination: Destination
  // … other stuff omitted …
}

struct ContentView {
  var things: [Thing]

  var body: some View {
    List(things) {
      NavigationLink($0.title, destination: destination(for: $0))
    }
  }

  @ViewBuilder
  func destination(for thing: Thing) -> some View {
    switch thing.destination {
      case .viewA:
        return ViewA(thing)
      case .viewB:
        return ViewB(thing)
      case .viewC:
        return ViewC(thing)
    }
  }
}

我的回应如下:

感谢您的反馈。但是,正如您所看到的,视图中仍然存在强耦合。现在,“ContentView”需要知道它可以导航到所有视图(ViewA、ViewB、ViewC)。正如我所说的,在小型示例应用程序中可以工作,但它不适用于大型生产就绪的应用程序。

想象一下,在GitHub项目中创建了一个自定义视图。然后在我的应用程序中导入此视图。这个自定义视图不知道它可以导航到其他视图,因为它们是特定于我的应用程序的。

希望我解释得更清楚了。

我认为唯一干净的解决方案是像UIKit那样将导航和视图分离。(例如UINavigationController)

谢谢,Darko

所以目前还没有干净且可行的解决方案。期待2020年的WWDC。


2021年9月更新:

使用 AnyView 并不是解决这个问题的好方法。在大型应用程序中,基本上所有的视图都必须以可重用的方式设计。这意味着 AnyView 将被“无处不在”地使用。我和两位Apple开发人员会面,他们明确向我解释了 AnyView 的性能要比 View 差很多,它应该只在特殊情况下使用。其根本原因是,在编译时无法解析 AnyView 的类型,因此必须在堆上分配。


2022年6月更新:

今天在WWDC上,苹果推出了新的 SwiftUI NavigationStack

https://developer.apple.com/documentation/swiftui/navigationstack/

NavigationStack 允许通过使用 .navigationDestination 修饰符将目标视图与当前可见视图分离。这终于是一个干净的协调器方式。

感谢 @Apple 的倾听!


2
同意!我在“反馈助手”中几个月前创建了一个请求,但至今没有得到回应:https://gist.github.com/Sajjon/b7edb4cc11bcb6462f4e28dc170be245 - Sajjon
4
A写了一封信给苹果公司关于这件事。让我们看看是否会得到回复。 - Darko
1
太棒了!这将会是WWDC期间迄今为止最好的礼物! - Sajjon
1
如果您有一长串的依赖关系,请打破这些依赖关系。没有什么可以为您生成它。您可能需要详细说明“真实的生产应用程序”。对我来说,这听起来更像是一个设计问题,而不是语言限制,即不要设计需要长链依赖的视图。 - Jim lai
这是“SwiftUI很棒,你应该将所有开发都转换到它!”文章没有解决的问题。我也遇到了完全相同的问题。有时候过渡效果会出现奇怪的问题。SwiftUI很适合声明界面,但不适合管理导航。我将每个视图封装在一个UIViewController中,并在那里处理所有导航(使用协议)。我尝试了100%的SwiftUI,结果是一场噩梦。下面的答案也显示了“更好”的做事方式变得复杂的情况。我只使用一直有效的方法,让SwiftUI处理数据的演示/编辑。 - Joris Mans
显示剩余5条评论
14个回答

20

闭包就是你需要的!

struct ItemsView<Destination: View>: View {
    let items: [Item]
    let buildDestination: (Item) -> Destination

    var body: some View {
        NavigationView {
            List(items) { item in
                NavigationLink(destination: self.buildDestination(item)) {
                    Text(item.id.uuidString)
                }
            }
        }
    }
}

我写了一篇关于在SwiftUI中使用闭包取代委托模式的文章。https://swiftwithmajid.com/2019/11/06/the-power-of-closures-in-swiftui/


3
关闭是个好主意,谢谢!但是在深层次的视图层级中会是什么样子呢?想象一下我有一个NavigationView,它深入到 10 级,进入详细信息、进入详细信息,依此类推... - Darko
15
我希望邀请您展示一些仅涉及三个层级的简单示例代码。 - Darko
可以使用协调器+闭包来解决深层次的嵌套问题。 - ukim

13

我的想法基本上是将CoordinatorDelegate模式结合起来。首先,创建一个Coordinator类:


struct Coordinator {
    let window: UIWindow

      func start() {
        var view = ContentView()
        window.rootViewController = UIHostingController(rootView: view)
        window.makeKeyAndVisible()
    }
}

SceneDelegate 改为使用 Coordinator :

  func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            let coordinator = Coordinator(window: window)
            coordinator.start()
        }
    }

ContentView中,我们有以下代码:


struct ContentView: View {
    var delegate: ContentViewDelegate?

    var body: some View {
        NavigationView {
            List {
                NavigationLink(destination: delegate!.didSelect(Item())) {
                    Text("Destination1")
                }
            }
        }
    }
}

我们可以像这样定义 ContenViewDelegate 协议:

protocol ContentViewDelegate {
    func didSelect(_ item: Item) -> AnyView
}

其中,Item只是一个可识别的结构体,也可以是其他任何东西(例如在UIKit中的TableView中某个元素的ID)。

下一步是在Coordinator中采用此协议,并简单地传递要呈现的视图:

extension Coordinator: ContentViewDelegate {
    func didSelect(_ item: Item) -> AnyView {
        AnyView(Text("Returned Destination1"))
    }
}

这在我的应用中迄今为止运作良好。希望它能有所帮助。


这高度取决于您如何管理应用程序依赖项及其流程。如果您将依赖项放在一个地方,正如我认为的那样(也称为组合根),则不应遇到此问题。 - Nikola Matijevic
3
我很想看到一个具体的例子。就像我之前提到的,我们从Text("Returned Destination1")开始。如果这需要是MyCustomView(item: ItemType, destinationView: View),你会注入什么?我理解依赖注入、通过协议实现松耦合和协调器共享依赖项,但问题在于所需的嵌套。谢谢。 - Darko
我的当前印象是,一个工作且干净的解决方案需要一个依赖注入容器(例如Environment、Swinject等)。此外,@Mecids的通用闭包返回值想法很可能也会成为其中的一部分。如果你开始了一个GitHub项目,如果你接受或需要我的帮助,我会参与PR。谢谢。 - Darko
1
@NikolaMatijevic,这个问题有更新吗?你能否创建一个示例项目?我正在处理这个问题,如果你有解决方案,我会非常感激。谢谢! - nikmin
1
也许这篇文章会有所帮助,SwiftUI 中的协调器模式:https://quickbirdstudios.com/blog/coordinator-pattern-in-swiftui/ - user14542817
显示剩余9条评论

7
我会逐一回答您的问题。我将使用一个小例子来说明,我们应该可以重用的视图是一个简单的View,它显示一个Text和一个NavigationLink,该链接将转到某个Destination。 如果您想查看我的完整示例,我创建了一个Gist: SwiftUI - Flexible Navigation with Coordinators

设计问题:NavigationLinks被硬编码到View中。

在您的示例中,它绑定到了View上,但是正如其他答案已经展示的那样,您可以将目标注入到您的View类型struct MyView<Destination: View>: View中。现在,您可以使用任何符合View协议的类型作为您的目标。

但如果包含这个NavigationLink的视图应该是可重用的,我就不能将目的地硬编码进去。必须有一种机制提供目的地。

通过以上更改,有机制可以提供类型。其中一个例子是:
struct BoldTextView: View {
    var text: String

    var body: some View {
        Text(text)
            .bold()
    }
}

struct NotReusableTextView: View {
    var text: String

    var body: some View {
        VStack {
            Text(text)
            NavigationLink("Link", destination: BoldTextView(text: text))
        }
    }
}

将变成

struct ReusableNavigationLinkTextView<Destination: View>: View {
    var text: String
    var destination: () -> Destination

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

            NavigationLink("Link", destination: self.destination())
        }
    }
}

你可以像这样传入目标地址:

struct BoldNavigationLink: View {
    let text = "Text"
    var body: some View {
        ReusableNavigationLinkTextView(
            text: self.text,
            destination: { BoldTextView(text: self.text) }
        )
    }
}

一旦我拥有多个可重用的屏幕,就会遇到这样一个逻辑问题:一个可重用的视图(ViewA)需要一个预配置的视图目标(ViewB)。但如果ViewB也需要一个预配置的视图目标ViewC呢?我需要以这样一种方式创建ViewB,使得在将ViewB注入到ViewA之前,ViewC已经被注入到了ViewB中。以此类推。
显然,您需要某种逻辑来确定您的目标视图。在某些时候,您需要告诉视图下一个显示哪个视图。我想您试图避免的是这种情况:
struct NestedMainView: View {
    @State var text: String

    var body: some View {
        ReusableNavigationLinkTextView(
            text: self.text,
            destination: {
                ReusableNavigationLinkTextView(
                    text: self.text,
                    destination: {
                        BoldTextView(text: self.text)
                    }
                )
            }
        )
    }
}

我准备了一个简单的例子,使用Coordinator来传递依赖项和创建视图。有一个协议用于协调器,您可以基于该协议实现特定的用例。

protocol ReusableNavigationLinkTextViewCoordinator {
    associatedtype Destination: View
    var destination: () -> Destination { get }

    func createView() -> ReusableNavigationLinkTextView<Destination>
}

现在我们可以创建一个特定的 Coordinator,在点击导航链接时显示 BoldTextView
struct ReusableNavigationLinkShowBoldViewCoordinator: ReusableNavigationLinkTextViewCoordinator {
    @Binding var text: String

    var destination: () -> BoldTextView {
        { return BoldTextView(text: self.text) }
    }

    func createView() -> ReusableNavigationLinkTextView<Destination> {
        return ReusableNavigationLinkTextView(text: self.text, destination: self.destination)
    }
}

如果您愿意,您还可以使用Coordinator实现自定义逻辑来确定视图的目标。下面的协调器在链接被单击四次后显示ItalicTextView
struct ItalicTextView: View {
    var text: String

    var body: some View {
        Text(text)
            .italic()
    }
}

struct ShowNavigationLinkUntilNumberGreaterFourThenItalicViewCoordinator: ReusableNavigationLinkTextViewCoordinator {
    @Binding var text: String
    let number: Int
    private var isNumberGreaterThan4: Bool {
        return number > 4
    }

    var destination: () -> AnyView {
        {
            if self.isNumberGreaterThan4 {
                let coordinator = ItalicTextViewCoordinator(text: self.text)
                return AnyView(
                    coordinator.createView()
                )
            } else {
                let coordinator = ShowNavigationLinkUntilNumberGreaterFourThenItalicViewCoordinator(
                    text: self.$text,
                    number: self.number + 1
                )
                return AnyView(coordinator.createView())
            }
        }
    }

    func createView() -> ReusableNavigationLinkTextView<AnyView> {
        return ReusableNavigationLinkTextView(text: self.text, destination: self.destination)
    }
}


如果您有需要传递的数据,请创建另一个协调器来保存该值。在此示例中,我有一个TextField -> EmptyView -> Text,其中TextField的值应该传递给Text。 EmptyView不能具有这些信息。
struct TextFieldView<Destination: View>: View {
    @Binding var text: String
    var destination: () -> Destination

    var body: some View {
        VStack {
            TextField("Text", text: self.$text)

            NavigationLink("Next", destination: self.destination())
        }
    }
}

struct EmptyNavigationLinkView<Destination: View>: View {
    var destination: () -> Destination

    var body: some View {
        NavigationLink("Next", destination: self.destination())
    }
}

这是一个通过调用其他协调器(或自己创建视图)来创建视图的协调器。它将从 TextField 中获取的值传递到 Text,而EmptyView则不知道这一点。
struct TextFieldEmptyReusableViewCoordinator {
    @Binding var text: String

    func createView() -> some View {
        let reusableViewBoldCoordinator = ReusableNavigationLinkShowBoldViewCoordinator(text: self.$text)
        let reusableView = reusableViewBoldCoordinator.createView()

        let emptyView = EmptyNavigationLinkView(destination: { reusableView })

        let textField = TextFieldView(text: self.$text, destination: { emptyView })

        return textField
    }
}

为了总结一下,你还可以创建一个 MainView,它具有一些逻辑来决定使用哪个 View / Coordinator
struct MainView: View {
    @State var text = "Main"

    var body: some View {
        NavigationView {
            VStack(spacing: 32) {
                NavigationLink("Bold", destination: self.reuseThenBoldChild())
                NavigationLink("Reuse then Italic", destination: self.reuseThenItalicChild())
                NavigationLink("Greater Four", destination: self.numberGreaterFourChild())
                NavigationLink("Text Field", destination: self.textField())
            }
        }
    }

    func reuseThenBoldChild() -> some View {
        let coordinator = ReusableNavigationLinkShowBoldViewCoordinator(text: self.$text)
        return coordinator.createView()
    }

    func reuseThenItalicChild() -> some View {
        let coordinator = ReusableNavigationLinkShowItalicViewCoordinator(text: self.$text)
        return coordinator.createView()
    }

    func numberGreaterFourChild() -> some View {
        let coordinator = ShowNavigationLinkUntilNumberGreaterFourThenItalicViewCoordinator(text: self.$text, number: 1)
        return coordinator.createView()
    }

    func textField() -> some View {
        let coordinator = TextFieldEmptyReusableViewCoordinator(text: self.$text)
        return coordinator.createView()
    }
}

我知道我也可以创建一个Coordinator协议和一些基本方法,但我想展示一个简单的例子,演示如何使用它们。

顺便说一下,这与我在Swift UIKit 应用程序中使用Coordinator的方式非常相似。

如果您有任何问题、反馈或需要改进的地方,请告诉我。


结构体 ShowNavigationLinkUntilNumberGreaterFourThenItalicViewCoordinator:ReusableNavigationLinkTextViewCoordinator。你可能应该使用注释而不是超长的名称。 - Jim lai
5
相反,代码应该是“自我说明”的。采用长名称是正确的方式(事实上也是苹果公司所采用的方式)。 - Fattie

6
这是一个有趣的例子,它可以通过编程方式无限地深入细节并更改您的数据以供下一个详细视图使用。
import SwiftUI

struct ContentView: View {
    @EnvironmentObject var navigationManager: NavigationManager

    var body: some View {
        NavigationView {
            DynamicView(viewModel: ViewModel(message: "Get Information", type: .information))
        }
    }
}

struct DynamicView: View {
    @EnvironmentObject var navigationManager: NavigationManager

    let viewModel: ViewModel

    var body: some View {
        VStack {
            if viewModel.type == .information {
                InformationView(viewModel: viewModel)
            }
            if viewModel.type == .person {
                PersonView(viewModel: viewModel)
            }
            if viewModel.type == .productDisplay {
                ProductView(viewModel: viewModel)
            }
            if viewModel.type == .chart {
                ChartView(viewModel: viewModel)
            }
            // If you want the DynamicView to be able to be other views, add to the type enum and then add a new if statement!
            // Your Dynamic view can become "any view" based on the viewModel
            // If you want to be able to navigate to a new chart UI component, make the chart view
        }
    }
}

struct InformationView: View {
    @EnvironmentObject var navigationManager: NavigationManager
    let viewModel: ViewModel

    // Customize your  view based on more properties you add to the viewModel
    var body: some View {
        VStack {
            VStack {
                Text(viewModel.message)
                .foregroundColor(.white)
            }
            .frame(width: 300, height: 300)
            .background(Color.blue)


            NavigationLink(destination: navigationManager.destination(forModel: viewModel)) {
                Text("Navigate")
            }
        }
    }
}

struct PersonView: View {
    @EnvironmentObject var navigationManager: NavigationManager
    let viewModel: ViewModel

    // Customize your  view based on more properties you add to the viewModel
    var body: some View {
        VStack {
            VStack {
                Text(viewModel.message)
                .foregroundColor(.white)
            }
            .frame(width: 300, height: 300)
            .background(Color.red)
            NavigationLink(destination: navigationManager.destination(forModel: viewModel)) {
                Text("Navigate")
            }
        }
    }
}

struct ProductView: View {
    @EnvironmentObject var navigationManager: NavigationManager
    let viewModel: ViewModel

    // Customize your  view based on more properties you add to the viewModel
    var body: some View {
        VStack {
            VStack {
                Text(viewModel.message)
                    .foregroundColor(.white)
            }
            .frame(width: 300, height: 300)
            .background(Color.green)
            NavigationLink(destination: navigationManager.destination(forModel: viewModel)) {
                Text("Navigate")
            }
        }
    }
}

struct ChartView: View {
    @EnvironmentObject var navigationManager: NavigationManager
    let viewModel: ViewModel

    var body: some View {
        VStack {
            VStack {
                Text(viewModel.message)
                    .foregroundColor(.white)
            }
            .frame(width: 300, height: 300)
            .background(Color.green)
            NavigationLink(destination: navigationManager.destination(forModel: viewModel)) {
                Text("Navigate")
            }
        }
    }
}

struct ViewModel {
    let message: String
    let type: DetailScreenType
}

enum DetailScreenType: String {
    case information
    case productDisplay
    case person
    case chart
}

class NavigationManager: ObservableObject {
    func destination(forModel viewModel: ViewModel) -> DynamicView {
        DynamicView(viewModel: generateViewModel(context: viewModel))
    }

    // This is where you generate your next viewModel dynamically.
    // replace the switch statement logic inside with whatever logic you need.
    // DYNAMICALLY MAKE THE VIEWMODEL AND YOU DYNAMICALLY MAKE THE VIEW
    // You could even lead to a view with no navigation link in it, so that would be a dead end, if you wanted it.
    // In my case my "context" is the previous viewMode, by you could make it something else.
    func generateViewModel(context: ViewModel) -> ViewModel {
        switch context.type {
        case .information:
            return ViewModel(message: "Serial Number 123", type: .productDisplay)
        case .productDisplay:
            return ViewModel(message: "Susan", type: .person)
        case .person:
            return ViewModel(message: "Get Information", type: .chart)
        case .chart:
            return ViewModel(message: "Chart goes here. If you don't want the navigation link on this page, you can remove it! Or do whatever you want! It's all dynamic. The point is, the DynamicView can be as dynamic as your model makes it.", type: .information)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
        .environmentObject(NavigationManager())
    }
}

有些视图强制你始终只返回一种类型的视图。 - Darko
我的意思是 - 如果依赖注入是唯一的解决方案,那么我会勉强接受它。但这真的很不妙... - Darko
你也可以将其返回为AnyView并进行包装以进行类型擦除。不确定性能如何。你也可以简单地将navigationManager作为参数传递,而不是作为EnvironmentObject,但是在这一点上,EnvironmentObject是一个相当标准的用法。如果与navigationManager没有关联状态,你可以为每个视图构建一个并通过destinationForTitle方法传递所需内容。或者监听通知等。有很多方法可以解决这个问题。 - MScottWaller
1
我不明白为什么你不能在你的框架示例中使用它。如果你谈论的是一个提供未知视图的框架,我想它可能只需返回一些视图。我也不会惊讶,如果 NavigationLink 中的 AnyView 实际上并不是那么大的 pref 命中,因为父视图与子视图的实际布局完全分离。虽然我不是专家,但它必须经过测试。与其向每个人请求他们无法完全理解你的要求的示例代码,为什么不编写一个 UIKit 示例并请求翻译呢? - jasongregori
1
这个设计基本上就是我所工作的(UIKit)应用程序的工作方式。生成模型并链接到其他模型。中央系统确定该模型应加载哪个vc,然后父vc将其推入堆栈。 - jasongregori
显示剩余21条评论

5

iOS 16+

iOS 16 中,我们终于可以访问 NavigationStackNavigationPath

以下是一个非常简单的示例:

  1. 我们可以创建一个包含 NavigationPath 并操作它的对象:
class Coordinator: ObservableObject {
    @Published var path = NavigationPath()

    func show<V>(_ viewType: V.Type) where V: View {
        path.append(String(describing: viewType.self))
    }

    func popToRoot() {
        path.removeLast(path.count)
    }
}
  1. 然后我们创建一个RootView,它将包含NavigationStack。我们还需要提供navigationDestination,以便根据需要进行路由:
struct RootView: View {
    @StateObject private var coordinator = Coordinator()

    var body: some View {
        NavigationStack(path: $coordinator.path) {
            VStack {
                Button {
                    coordinator.show(ViewA.self)
                } label: {
                    Text("Show View A")
                }
                Button {
                    coordinator.show(ViewB.self)
                } label: {
                    Text("Show View B")
                }
            }
            .navigationDestination(for: String.self) { id in
                if id == String(describing: ViewA.self) {
                    ViewA()
                } else if id == String(describing: ViewB.self) {
                    ViewB()
                }
            }
        }
        .environmentObject(coordinator)
    }
}
  1. 所有后续的视图只需要一个Coordinator对象,不需要硬编码的路由控制。
struct ViewA: View {
    @EnvironmentObject private var coordinator: Coordinator

    var body: some View {
        VStack {
            Text("This is View A")
            Button {
                coordinator.popToRoot()
            } label: {
                Text("Go to root")
            }
        }
    }
}

struct ViewB: View {
    @EnvironmentObject private var coordinator: Coordinator

    var body: some View {
        VStack {
            Text("This is View B")
            Button {
                coordinator.show(ViewA.self)
            } label: {
                Text("Show View A")
            }
        }
    }
}

如何传递值? - Hector
第一次调用 coordinator.show(ViewA.self) 为什么不足以显示 ViewA,而需要调用 navigationDestination,但在后续调用 coordinator.show(SomeView.self) 时,您不需要调用 navigationDestination - yambo

4
我想到的是,当你说:

但是如果ViewB还需要一个预配置的视图目标ViewC呢?我需要以这样的方式创建ViewB,使得在将ViewB注入ViewA之前,ViewC已经被注入ViewB中。等等...但是,由于此时必须传递的数据不可用,整个构造过程就失败了。

实际上并不完全正确。您可以设计可重用组件,以便提供按需生成视图的闭包,而不是提供视图。
这样,按需生成ViewB的闭包可以为其提供按需生成ViewC的闭包,但是视图的实际构建可以在有上下文信息时进行。

但是这样创建“闭包树”与实际视图有什么不同呢?提供项目的问题将得到解决,但所需的嵌套并没有解决。我创建了一个创建视图的闭包-好的。但在那个闭包中,我已经需要提供下一个闭包的创建。在最后一个闭包中,下一个闭包也是如此。等等...但也许我误解了你的意思。一些代码示例会很有帮助。谢谢。 - Darko

3

这是完全凭借我的想象力回答的,可能会变成无意义的话,但我有些想用混合方法。

使用环境传递单个协调器对象 - 我们称它为NavigationCoordinator。

为可重复使用的视图提供某种动态设置的标识符。此标识符提供了与客户应用程序实际用例和导航层次结构相对应的语义信息。

使可重复使用的视图查询NavigationCoordinator以获取目标视图,传递其标识符和正在导航到的视图类型的标识符。

这将NavigationCoordinator作为单个注入点,并且它是可以在视图层次结构外部访问的非视图对象。

在设置期间,您可以注册正确的视图类以返回它,使用与运行时传递的标识符的某种匹配。在某些情况下,仅匹配目标标识符可能很简单。或针对主机和目标标识符对进行匹配。

在更复杂的情况下,您可以编写自定义控制器,考虑其他特定于应用程序的信息。

由于它是通过环境注入的,因此任何视图都可以在任何时候覆盖默认的NavigationCoordinator,并向其子视图提供不同的协调器。


3
我已经在一篇文章中发布了我的解决方案 - SwiftUI中的路由。两种 SwiftUI 中的路由解决方案
以下是概述: 1. 触发视图的路由器。 路由器将为所有可能的导航路线返回触发子视图,以将它们插入到呈现视图中。这样的子视图代码段将包含 NavigationLink.sheet 修饰符,以及指定的目标视图,并通过绑定使用存储在路由器中的状态属性。这样,呈现视图不会依赖于导航代码和目标,只依赖于路由器协议。
一个呈现视图的例子:
protocol PresentingRouterProtocol: NavigatingRouter {
    func presentDetails<TV: View>(text: String, triggerView: @escaping () -> TV) -> AnyView
}

struct PresentingView<R: PresentingRouterProtocol>: View {

    @StateObject private var router: R

    init(router: R) {
        _router = StateObject(wrappedValue: router)
    }

    var body: some View {
        NavigationView {
            router.presentDetails(text: "Details") {
                Text("Present Details")
                    .padding()
            }
        }
    }
}

一个路由器示例:
class PresentingRouter: PresentingRouterProtocol {

    struct NavigationState {
        var presentingDetails = false
    }

    @Published var navigationState = NavigationState()

    func presentDetails<TV: View>(text: String, triggerView: @escaping () -> TV) -> AnyView {
        let destinationView = PresentedView(text: text, router: BasePresentedRouter(isPresented: binding(keyPath: \.presentingDetails)))
        return AnyView(SheetButton(isPresenting: binding(keyPath: \.presentingDetails), contentView: triggerView, destinationView: destinationView))
    }
}
触发器视图中的SheetButton
struct SheetButton<CV: View, DV: View>: View {

    @Binding var isPresenting: Bool

    var contentView: () -> CV
    var destinationView: DV

    var body: some View {
        Button(action: {
            self.isPresenting = true
        }) {
            contentView()
                .sheet(isPresented: $isPresenting) {
                    self.destinationView
                }
        }
    }
}

源代码:https://github.com/ihorvovk/Routing-in-SwiftUI-with-trigger-views

2. 使用类型擦除的修饰符进行路由。 一个呈现视图将会通过通用修饰符来配置任何其他视图的呈现:.navigation(router), .sheet(router)。这些修饰符将通过绑定跟踪存储在路由器中的导航状态,并在路由器改变该状态时执行导航。路由器还将具有所有可能导航的函数。这些函数将更改状态并随之触发导航。

一个呈现视图示例:

protocol PresentingRouterProtocol: Router {
    func presentDetails(text: String)
}

struct PresentingView<R: PresentingRouterProtocol>: View {

    @StateObject private var router: R

    init(router: R) {
        _router = StateObject(wrappedValue: router)
    }

    var body: some View {
        NavigationView {
            Button(action: {
                router.presentDetails(text: "Details")
            }) {
                Text("Present Details")
                    .padding()
            }.navigation(router)
        }.sheet(router)
    }
}

定制的 .sheet 修饰符接受一个路由器作为参数:

struct SheetModifier: ViewModifier {

    @Binding var presentingView: AnyView?

    func body(content: Content) -> some View {
        content
            .sheet(isPresented: Binding(
                get: { self.presentingView != nil },
                set: { if !$0 {
                    self.presentingView = nil
                }})
            ) {
                self.presentingView
            }
    }
}

基础的路由器类:
class Router: ObservableObject {

    struct State {
        var navigating: AnyView? = nil
        var presentingSheet: AnyView? = nil
        var isPresented: Binding<Bool>
    }

    @Published private(set) var state: State

    init(isPresented: Binding<Bool>) {
        state = State(isPresented: isPresented)
    }
}

子类只需要实现可用路由的函数:

class PresentingRouter: Router, PresentingRouterProtocol {

    func presentDetails(text: String) {
        let router = Router(isPresented: isNavigating)
        navigateTo (
            PresentedView(text: text, router: router)
        )
    }
}

源代码: https://github.com/ihorvovk/Routing-in-SwiftUI-with-type-erased-modifiers

这两种解决方案将导航逻辑与视图层分开。两者都在路由器中存储导航状态。这使我们可以通过更改路由器的状态来执行导航和实现深度链接。


如何在列表视图的“did select”方法中使用“NavigationButton”? - Erkam KUCET
1
@ErkamKUCET,您需要使用一个简单的按钮,然后从其操作中调用路由器的函数。路由器的目的是将导航逻辑与UI解耦。 - Ihor Vovk

3
问题在于静态类型检查,即构造NavigationLink时我们需要为其提供一些特定的视图。因此,如果我们需要打破这些依赖关系,我们需要使用类型擦除,即AnyView
这里有一个基于路由器/视图模型概念的工作演示,使用类型擦除视图来避免紧密的依赖关系。已经在Xcode 11.4 / iOS 13.4上进行了测试。
让我们从最终结果开始,并分析它(在注释中):
struct DemoContainerView: View {
    var router: Router       // some router
    var vm: [RouteModel]     // some view model having/being route model

    var body: some View {
        RouteContainer(router: router) {    // route container with UI layout
          List {
            ForEach(self.vm.indices, id: \.self) {
              Text("Label \($0)")
                .routing(with: self.vm[$0])    // modifier giving UI element
                                               // possibility to route somewhere
                                               // depending on model
            }
          }
        }
    }
}

struct TestRouter_Previews: PreviewProvider {
    static var previews: some View {
        DemoContainerView(router: SimpleRouter(), 
            vm: (1...10).map { SimpleViewModel(text: "Item \($0)") })
    }
}


因此,我们拥有纯粹的用户界面,没有任何导航细节,并且分离了这个用户界面可以路由到哪里的知识。以下是它的工作原理:

demo

积木:

// Base protocol for route model
protocol RouteModel {}  

// Base protocol for router
protocol Router {
    func destination(for model: RouteModel) -> AnyView
}

// Route container wrapping NavigationView and injecting router
// into view hierarchy
struct RouteContainer<Content: View>: View {
    let router: Router?

    private let content: () -> Content
    init(router: Router? = nil, @ViewBuilder _ content: @escaping () -> Content) {
        self.content = content
        self.router = router
    }

    var body: some View {
        NavigationView {
            content()
        }.environment(\.router, router)
    }
}

// Modifier making some view as routing element by injecting
// NavigationLink with destination received from router based
// on some model
struct RouteModifier: ViewModifier {
    @Environment(\.router) var router
    var rm: RouteModel

    func body(content: Content) -> some View {
        Group {
            if router == nil {
                content
            } else {
                NavigationLink(destination: router!.destination(for: rm)) { content }
            }
        }
    }
}

// standard view extension to use RouteModifier
extension View {
    func routing(with model: RouteModel) -> some View {
        self.modifier(RouteModifier(rm: model))
    }
}

// Helper environment key to inject Router into view hierarchy
struct RouterKey: EnvironmentKey {
    static let defaultValue: Router? = nil
}

extension EnvironmentValues {
    var router: Router? {
        get { self[RouterKey.self] }
        set { self[RouterKey.self] = newValue }
    }
}


演示中显示的测试代码:
protocol SimpleRouteModel: RouteModel {
    var next: AnyView { get }
}

class SimpleViewModel: ObservableObject {
    @Published var text: String
    init(text: String) {
        self.text = text
    }
}

extension SimpleViewModel: SimpleRouteModel {
    var next: AnyView {
        AnyView(DemoLevel1(rm: self))
    }
}

class SimpleEditModel: ObservableObject {
    @Published var vm: SimpleViewModel
    init(vm: SimpleViewModel) {
        self.vm = vm
    }
}

extension SimpleEditModel: SimpleRouteModel {
    var next: AnyView {
        AnyView(DemoLevel2(em: self))
    }
}

class SimpleRouter: Router {
    func destination(for model: RouteModel) -> AnyView {
        guard let simpleModel = model as? SimpleRouteModel else {
            return AnyView(EmptyView())
        }
        return simpleModel.next
    }
}

struct DemoLevel1: View {
    @ObservedObject var rm: SimpleViewModel

    var body: some View {
        VStack {
            Text("Details: \(rm.text)")
            Text("Edit")
                .routing(with: SimpleEditModel(vm: rm))
        }
    }
}

struct DemoLevel2: View {
    @ObservedObject var em: SimpleEditModel

    var body: some View {
        HStack {
            Text("Edit:")
            TextField("New value", text: $em.vm.text)
        }
    }
}

struct DemoContainerView: View {
    var router: Router
    var vm: [RouteModel]

    var body: some View {
        RouteContainer(router: router) {
            List {
                ForEach(self.vm.indices, id: \.self) {
                    Text("Label \($0)")
                        .routing(with: self.vm[$0])
                }
            }
        }
    }
}

// MARK: - Preview
struct TestRouter_Previews: PreviewProvider {
    static var previews: some View {
        DemoContainerView(router: SimpleRouter(), vm: (1...10).map { SimpleViewModel(text: "Item \($0)") })
    }
}

0

尽管这是一年前的问题,但它仍然是一个有趣且实际的问题。在我看来,我们仍然需要发现常见问题的良好解决方案和最佳实践。

我认为,在UIKIt中,协调器模式并不是解决其所努力解决的问题的好方法,正确的应用会带来很多麻烦,并留下许多未解答的问题,如何将其与其他架构集成。

在SwiftUI中,一切似乎都是静态和“预定义”的,我们很难找到一种方法使其具有一定的动态性。因此,同样的问题在SwiftUI中仍然存在。

以下方法将导航的创建转换配置这三个方面中的两个方面解耦,并将转换方面留在源视图中(在我看来)。

目标视图的创建配置由专门的“协调器”视图执行,该视图是视图层次结构中源视图的父视图。

注意:SwiftUI 视图不像 UIKit 中的 View 那样。它只是一种创建和修改“View”的方式,这些“View”位于幕后,并由 SwiftUI 管理。因此,仅执行设置和配置的视图在我看来是完全有效和有用的方法。适当的命名和约定将有助于识别这些视图。
解决方案非常轻量级。如果需要进一步解耦某些方面 - 比如使目标视图的类型不仅取决于元素,还取决于某个环境中的某个属性,我不会采用像为 UIKit 发明的 Coordinator 模式之类的东西。在 SwiftUI 中,我们有更好的替代方案。我会使用常见的技术,如“Reader Monad”,它分解应用程序和配置,并使您能够在实现一个方面和另一个方面的两个“远离”的位置 - 这基本上是一种依赖注入形式。
因此,考虑到这种情况:
  • 我们有一个显示元素的列表视图
  • 每个元素都可以通过导航链接显示详细信息视图。
  • 详细视图的类型取决于元素的某些属性
import SwiftUI
import Combine

struct MasterView: View {

    struct Selection: Identifiable {
        let id: MasterViewModel.Item.ID
        let view: () -> DetailCoordinatorView  // AnyView, if you 
                                               // need strong decoupling
    }

    let items: [MasterViewModel.Item]
    let selection: Selection?
    let selectDetail: (_ id: MasterViewModel.Item.ID) -> Void
    let unselectDetail: () -> Void

    func link() -> Binding<MasterViewModel.Item.ID?> {
        Binding {
            self.selection?.id
        } set: { id in
            print("link: \(String(describing: id))")
            if let id = id {
                selectDetail(id)
            } else {
                unselectDetail()
            }
        }
    }

    var body: some View {
        List {
            ForEach(items, id: \.id) { element in
                NavigationLink(
                    tag: element.id,
                    selection: link()) {
                        if let selection = self.selection {
                            selection.view()
                        }
                    } label: {
                        Text("\(element.name)")
                    }
            }
        }
    }
}

主视图不了解详细视图。它只使用一个导航链接有效地显示不同类型的详细视图,并不知道决定详细视图类型的机制。 然而,它了解并确定转换的类型。
struct DetailView: View {
    let item: DetailViewModel.Item

    var body: some View {
        HStack {
            Text("\(item.id)")
            Text("\(item.name)")
            Text("\(item.description)")
        }
    }
}

这只是一个演示用的详细视图。

struct MasterCoordinatorView: View {
    @ObservedObject private(set) var viewModel: MasterViewModel

    var body: some View {
        MasterView(
            items: viewModel.viewState.items,
            selection: detailSelection(),
            selectDetail: viewModel.selectDetail(id:),
            unselectDetail: viewModel.unselectDetail)
    }

    func detailSelection() -> MasterView.Selection? {
        let detailSelection: MasterView.Selection?
        if let selection = viewModel.viewState.selection {
            detailSelection = MasterView.Selection(
                id: selection.id,
                view: {
                    // 1. Decision point where one can create 
                    //    different kind of views depending on 
                    //    the given element.
                    DetailCoordinatorView(viewModel: selection.viewModel)
                        //.eraseToAnyView()  // if you need 
                                             // more decoupling
                }
            )
        } else {
            detailSelection = nil
        }
        return detailSelection
    }
}

MasterCoordinatorView 负责设置导航的机制,并将 ViewModel 与 View 解耦。
struct DetailCoordinatorView: View {
    @ObservedObject private(set) var viewModel: DetailViewModel

    var body: some View {
        // 2. Decision point where one can create different kind
        // of views depending on the given element, using a switch
        // statement for example.
        switch viewModel.viewState.item.id {
        case 1:
            DetailView(item: viewModel.viewState.item)
                .background(.yellow)
        case 2:
            DetailView(item: viewModel.viewState.item)
                .background(.blue)
        case 3:
            DetailView(item: viewModel.viewState.item)
                .background(.green)
        default:
            DetailView(item: viewModel.viewState.item)
                .background(.red)
        }
    }
}

在这里,DetailCoordinatorView 负责选择详细视图。
最后,是视图模型:
final class MasterViewModel: ObservableObject {

    struct ViewState {
        var items: [Item] = []
        var selection: Selection? = nil
    }

    struct Item: Identifiable {
        var id: Int
        var name: String
    }

    struct Selection: Identifiable {
        var id: Item.ID
        var viewModel: DetailViewModel
    }

    @Published private(set) var viewState: ViewState

    init(items: [Item]) {
        self.viewState = .init(items: items, selection: nil)
    }

    func selectDetail(id: Item.ID) {
        guard let item = viewState.items.first(where: { id == $0.id } ) else {
            return
        }
        let detailViewModel = DetailViewModel(
            item: .init(id: item.id,
                        name: item.name,
                        description: "description of \(item.name)",
                        image: URL(string: "a")!)
        )
        self.viewState.selection = Selection(
            id: item.id,
            viewModel: detailViewModel)
    }

    func unselectDetail() {
        self.viewState.selection = nil
    }
}

final class DetailViewModel: ObservableObject {

    struct Item: Identifiable, Equatable {
        var id: Int
        var name: String
        var description: String
        var image: URL
    }

    struct ViewState {
        var item: Item
    }

    @Published private(set) var viewState: ViewState


    init(item: Item) {
        self.viewState = .init(item: item)
    }

}

对于游乐场:

struct ContentView: View {
    @StateObject var viewModel = MasterViewModel(items: [
        .init(id: 1, name: "John"),
        .init(id: 2, name: "Bob"),
        .init(id: 3, name: "Mary"),
    ])

    var body: some View {
        NavigationView {
            MasterCoordinatorView(viewModel: viewModel)
        }
        .navigationViewStyle(.stack)
    }
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(ContentView())


extension View {
    func eraseToAnyView() -> AnyView {
        AnyView(self)
    }
}

使用“AnyView”不是解决此问题的好方法。在大型应用程序中,基本上所有视图都必须以可重用的方式设计。这意味着“AnyView”将被用于_任何地方_。我与两位苹果开发人员进行了一次会话,他们清楚地向我解释了_AnyView_比View创建了更糟糕的性能,并且它应该仅在特殊情况下使用。其潜在原因是_AnyView_的类型无法在编译时解析,因此必须在堆上分配。 - Darko
@Darko 感谢您的评论。您说得对,AnyView 不应该被 普遍地 使用 - 它不需要用于在一个“场景”(页面、屏幕)内构建视图层次结构。在这种情况下,返回 AnyView,它会通过将返回的视图推入导航堆栈来启动完整的 _新流程_。如果您想要完全将目标视图与父视图解耦,则没有其他使用 AnyView 的方法。也没有性能问题。 - CouchDeveloper

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