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

Dar*_*rko 109 xcode ios swift swiftui swiftui-navigationlink

我尝试为更大的、可用于生产的 SwiftUI 应用程序构建架构。我一直在遇到同样的问题,这指向 SwiftUI 中的一个主要设计缺陷。

仍然没有人能给我一个完整的工作,生产就绪的答案。

如何做SwiftUI包含导航的可重用视图?

由于它SwiftUI NavigationLink与视图有很强的联系,这根本不可能以这样的方式在更大的应用程序中扩展。NavigationLink在那些小样本应用程序中,是的 - 但不是当您想在一个应用程序中重用许多视图时。也可能在模块边界上重用。(例如:在 iOS、WatchOS 等中重用 View...)

设计问题:导航链接被硬编码到视图中。

NavigationLink(destination: MyCustomView(item: item))
Run Code Online (Sandbox Code Playgroud)

但是如果包含这个的视图NavigationLink应该是可重用的,我就不能对目的地进行硬编码。必须有一种机制来提供目的地。我在这里问了这个问题并得到了很好的答案,但仍然不是完整的答案:

SwiftUI MVVM 协调器/路由器/导航链接

这个想法是将目标链接注入可重用视图。一般来说,这个想法可行,但不幸的是,这并不能扩展到真正的生产应用程序。一旦我有多个可重用的屏幕,我就会遇到一个逻辑问题,即一个可重用的视图 ( ViewA) 需要一个预配置的视图目标 ( ViewB)。但是如果ViewB还需要一个预配置的 view-destinationViewC呢?我需要建立ViewB在这样的方式已经ViewC被注入已经ViewB在我注入ViewBViewA。等等......但由于当时必须传递的数据不可用,整个构造失败。

我的另一个想法是使用Environmentas 依赖注入机制为NavigationLink. 但我认为这或多或少应该被视为一种黑客行为,而不是大型应用程序的可扩展解决方案。我们最终会基本上将环境用于所有事情。但是因为 Environment 也只能在 View 内部使用(而不是在单独的 Coordinators 或 ViewModels 中),所以在我看来这将再次创建奇怪的构造。

就像业务逻辑(例如视图模型代码)和视图必须分开,导航和视图也必须分开(例如协调器模式)UIKit因为我们访问视图UIViewControllerUINavigationController视图后面,所以这是可能的。UIKit'sMVC 已经有一个问题,它混合了很多概念,以至于它变成了有趣的名字“Massive-View-Controller”而不是“Model-View-Controller”。现在类似的问题仍在继续,SwiftUI但在我看来更糟。Navigation 和 Views 是强耦合的,不能解耦。因此,如果它们包含导航,则不可能进行可重用的视图。有可能解决这个问题,UIKit但现在我看不到一个理智的解决方案SwiftUI. 不幸的是,Apple 没有向我们解释如何解决这样的架构问题。我们只有一些小样本应用程序。

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

提前致谢。


更新:这个赏金将在几分钟后结束,不幸的是仍然没有人能够提供一个有效的例子。但是,如果我找不到任何其他解决方案并将其链接到此处,我将开始一个新的赏金来解决这个问题。感谢所有人的伟大贡献!


2020 年 6 月 18 日更新:我从 Apple 那里得到了关于这个问题的答案,提出了这样的建议来解耦视图和模型:

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)
    }
  }
}
Run Code Online (Sandbox Code Playgroud)

我的回答是:

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

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

我希望我能更好地解释这个问题。

我看到这个问题的唯一干净的解决方案是像 UIKit 一样分离导航和视图。(例如 UINavigationController)

谢谢,达科

所以对于这个问题仍然没有干净和有效的解决方案。期待 WWDC 2020。


Mec*_*cid 17

关闭就是你所需要的!

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)
                }
            }
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

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

  • 我想邀请您展示一些仅三层深度的简单示例代码。 (10认同)
  • 关闭是个好主意,谢谢!但在深度视图层次结构中,这会是什么样子呢?想象一下,我有一个 NavigationView,它深入、详细、详细、详细等 10 个级别…… (2认同)

小智 10

My idea would pretty much be a combination of Coordinator and Delegate pattern. First, create a Coordinator class:


struct Coordinator {
    let window: UIWindow

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

Run Code Online (Sandbox Code Playgroud)

Adapt the SceneDelegate to use the Coordinator :


struct Coordinator {
    let window: UIWindow

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

Run Code Online (Sandbox Code Playgroud)

Inside of ContentView, we have this:

  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()
        }
    }
Run Code Online (Sandbox Code Playgroud)

We can define the ContenViewDelegate protocol like this:


struct ContentView: View {
    var delegate: ContentViewDelegate?

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

Run Code Online (Sandbox Code Playgroud)

Where Item is just a struct which is identifiable, could be anything else (e.g id of some element like in a TableView in UIKit)

Next step is to adopt this protocol in Coordinator and simply pass the view you want to present:

protocol ContentViewDelegate {
    func didSelect(_ item: Item) -> AnyView
}
Run Code Online (Sandbox Code Playgroud)

This has so far worked nicely in my apps. I hope it helps.

  • 我很想看到一个具体的例子。正如我已经提到的,让我们从 `Text("Returned Destination1")` 开始。如果这需要是“MyCustomView(item: ItemType, destinationView: View)”怎么办?你要在那里注射什么?我了解依赖注入、通过协议的松散耦合以及与协调器的共享依赖关系。所有这些都不是问题——问题在于所需的嵌套。谢谢。 (3认同)

Yan*_*ick 7

我会尽量一一回答你的观点。我将遵循一个小示例,其中我们应该可重用的 View 是一个简单的View,显示 aText和 aNavigationLink将转到 some Destination。如果您想查看我的完整示例,我创建了一个要点:SwiftUI - 带有协调器的灵活导航

设计问题:导航链接被硬编码到视图中。

在您的示例中,它绑定到 View 但正如其他答案已经显示的那样,您可以将目标注入您的 View type struct MyView<Destination: View>: View。您现在可以使用任何符合 View 的 Type 作为您的目的地。

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

通过上述更改,有提供类型的机制。一个例子是:

struct BoldTextView: View {
    var text: String

    var body: some View {
        Text(text)
            .bold()
    }
}
Run Code Online (Sandbox Code Playgroud)
struct NotReusableTextView: View {
    var text: String

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

Run Code Online (Sandbox Code Playgroud)

将更改为

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

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

            NavigationLink("Link", destination: self.destination())
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

你可以像这样传入你的目的地:

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

一旦我有多个可重用的屏幕,我就会遇到一个逻辑问题,即一个可重用的视图 (ViewA) 需要一个预配置的视图目标 (ViewB)。但是如果 ViewB 还需要一个预配置的视图目标 ViewC 呢?在将 ViewB 注入到 ViewA 之前,我需要以这样一种方式创建 ViewB,即 ViewC 已经注入到 ViewB 中。等等....

好吧,显然您需要某种逻辑来确定您的Destination. 在某些时候,您需要告诉视图接下来是什么视图。我想你要避免的是:

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)
                    }
                )
            }
        )
    }
}
Run Code Online (Sandbox Code Playgroud)

我整理了一个简单的示例,该示例使用Coordinators 传递依赖项并创建视图。Coordinator 有一个协议,您可以基于该协议实现特定的用例。

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

    func createView() -> ReusableNavigationLinkTextView<Destination>
}
Run Code Online (Sandbox Code Playgroud)

现在我们可以创建一个特定的协调器,BoldTextView当点击NavigationLink.

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)
    }
}
Run Code Online (Sandbox Code Playgroud)

如果需要,您还可以使用Coordinator来实现确定视图目标的自定义逻辑。以下 Coordinator 显示了ItalicTextView四次点击链接后的情况。

struct ItalicTextView: View {
    var text: String

    var body: some View {
        Text(text)
            .italic()
    }
}
Run Code Online (Sandbox Code Playgroud)
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)
    }
}

Run Code Online (Sandbox Code Playgroud)

如果您有需要传递的数据,请在另一个协调器周围创建另一个协调器来保存值。在这个例子中,我有一个TextField-> EmptyView-> Text,其中 TextField 的值应该传递给Text.The EmptyViewmust not have this information。

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())
    }
}
Run Code Online (Sandbox Code Playgroud)

这是通过调用其他协调器(或创建视图本身)来创建视图的协调器。它传递值 from TextFieldtoText并且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
    }
}
Run Code Online (Sandbox Code Playgroud)

总而言之,您还可以创建一个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()
    }
}
Run Code Online (Sandbox Code Playgroud)

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

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

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

  • 相反,代码应该是**自记录**。长名字是必经之路(苹果也是这样做的) (4认同)

MSc*_*ler 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())
    }
}
Run Code Online (Sandbox Code Playgroud)


paw*_*222 6

iOS 16+

在iOS 16中我们终于可以访问NavigationStackNavigationPath了。

这是一个非常简单的演示:

  1. 我们可以创建一个包含 a 的对象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)
    }
}
Run Code Online (Sandbox Code Playgroud)
  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)
    }
}
Run Code Online (Sandbox Code Playgroud)
  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")
            }
        }
    }
}
Run Code Online (Sandbox Code Playgroud)


Sam*_*ane 3

这是一个完全脱离我的想法的答案,所以可能会被证明是无稽之谈,但我很想使用混合方法。

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

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

让可重用视图向 NavigationCoordinator 查询目标视图,传递它们的标识符和它们要导航到的视图类型的标识符。

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

在设置过程中,您可以使用与运行时传递的标识符进行某种匹配来注册正确的视图类以使其返回。在某些情况下,像与目的地标识符匹配这样简单的事情可能会起作用。或者根据一对主机和目标标识符进行匹配。

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

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