122

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

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

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

由于SwiftUI NavigationLink强烈地绑定到视图,这根本不可能以它在更大的应用程序中也可扩展的方式进行。NavigationLink在那些小示例应用程序中,是的 - 但不是在您想在一个应用程序中重用多个视图时。也许还可以重用模块边界。(例如:在 iOS、WatchOS 等中重用 View……)

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

NavigationLink(destination: MyCustomView(item: item))

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

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

想法是将目标链接注入可重用视图。一般来说,这个想法是可行的,但不幸的是,这并不能扩展到真正的生产应用程序。一旦我有多个可重复使用的屏幕,我就会遇到一个逻辑问题,即一个可重复使用的视图 ( ViewA) 需要一个预配置的视图目标 ( ViewB)。但是如果ViewB还需要一个预配置的 view-destinationViewC怎么办?我需要ViewBViewC注入. _ 依此类推....但是由于当时必须传递的数据不可用,因此整个构造都失败了。ViewBViewBViewA

我的另一个想法是使用Environmentas 依赖注入机制为NavigationLink. 但我认为这或多或少应该被视为一种 hack,而不是大型应用程序的可扩展解决方案。我们最终将基本上使用环境来处理所有事情。但是因为 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)
    }
  }
}

我的回应是:

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

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

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

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

谢谢,达科

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


2021 年 9 月更新:AnyView对于这个问题,使用不是一个好的通用解决方案。在大型应用程序中,基本上所有视图都必须以可重用的方式设计。这意味着AnyViewget无处不在。我与两位 Apple 开发人员进行了一次会议,他们清楚地向我解释了AnyView创建比 View 更差的性能,它应该只在特殊情况下使用。其根本原因是AnyView在编译期间无法解析类型,因此必须在堆上分配。

4

13 回答 13

19

关闭就是你所需要的!

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/

于 2020-04-23T13:02:32.067 回答
10

我的想法几乎是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
}

whereItem只是一个可识别的结构,可以是其他任何东西(例如,某些元素的 id,如TableViewUIKit 中的 a )

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

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

到目前为止,这在我的应用程序中运行良好。我希望它有所帮助。

于 2020-04-23T13:14:59.940 回答
7

我将尝试一一回答您的观点。我将遵循一个小例子,我们的 View that should be reusable 是一个简单View的,显示 aText和 aNavigationLink将转到 some Destination。如果您想查看我的完整示例,我创建了一个要点:SwiftUI - 使用协调器进行灵活导航 。

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

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

但是,如果包含此 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 注入 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)
                    }
                )
            }
        )
    }
}

我整理了一个简单的示例,它使用Coordinators 传递依赖关系并创建视图。Coordinator 有一个协议,您可以在此基础上实现特定的用例。

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

    func createView() -> ReusableNavigationLinkTextView<Destination>
}

现在我们可以创建一个特定的协调器,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)
    }
}

如果需要,您还可以使用Coordinator来实现确定视图目标的自定义逻辑。以下 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)
    }
}

如果您有需要传递的数据,请在另一个协调器周围创建另一个 Coordinator 来保存该值。在这个例子中,我有一个TextField-> EmptyView->Text应该将 TextField 中的值传递给Text.TheEmptyView必须没有此信息。

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())
    }
}

这是通过调用其他协调器(或创建视图本身)来创建视图的协调器。它将值从TextFieldto传递,Text而 theEmptyView不知道这一点。

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协议和一些基本方法,但我想展示一个简单的例子来说明如何使用它们。

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

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

于 2020-05-07T13:28:27.820 回答
4

这是一个有趣的示例,它以编程方式无限钻取并更改下一个详细视图的数据

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())
    }
}
于 2020-04-23T15:43:40.253 回答
4

我想到的是,当你说:

但是如果 ViewB 还需要一个预配置的视图目标 ViewC 怎么办?在将 ViewB 注入 ViewA 之前,我需要以这样的方式创建 ViewB,即 ViewC 已经注入到 ViewB 中。依此类推....但是由于当时必须传递的数据不可用,因此整个构造都失败了。

这不完全正确。您可以设计可重用的组件,而不是提供视图,以便提供按需提供视图的闭包。

这样,按需生成 ViewB 的闭包可以为其提供按需生成 ViewC 的闭包,但视图的实际构建可能发生在您需要的上下文信息可用时。

于 2020-04-23T13:02:18.030 回答
3

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

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

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

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

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

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

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

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

于 2020-04-23T12:22:54.467 回答
1

我已经在一篇文章中发布了我的解决方案 - Routing in 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)
    }
}

custome .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

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

于 2020-10-07T12:28:24.340 回答
1

问题在于静态类型检查,即。构建NavigationLink我们需要为它提供一些特定的视图。因此,如果我们需要打破这种依赖关系,我们需要类型擦除,即。AnyView

这是一个想法的工作演示,基于 Router/ViewModel 概念,使用类型擦除的视图来避免紧密的依赖关系。使用 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)") })
    }
}

因此,我们拥有没有任何导航细节的纯 UI,并且该 UI 可以路由到何处的单独知识。以下是它的工作原理:

演示

建筑模块:

// 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)") })
    }
}
于 2020-07-15T07:39:09.560 回答
0

你们在这里讨论的这个话题真的很有趣。为了把我的便士放在这里,我将分享我的想法。我确实尝试主要关注这个问题,而不是过多地固执己见。

假设您正在构建一个 UI 组件框架,您需要在全球范围内的公司内发布该框架。然后,您的要求是构建“虚拟”组件,这些组件现在将如何展示自己以及一些额外的最小知识,例如它们是否可能具有导航。

假设:

  • ViewA 组件将存在于 UI 隔离的框架中。
  • ViewA 组件可能会知道它可以从那里以某种方式导航。但是 ViewA 不太关心其中的内容类型。它只会提供它自己的“潜在”可导航视图,仅此而已。因此,将要建立的“合同”是。一个高阶组件擦除类型构建器(受 React 启发,在 iOS 工作多年后他会告诉我:D)将从组件接收视图。这个构建器将提供一个视图。而已。ViewA 不需要知道其他任何东西。

视图A

/// UI Library Components framework.

struct ViewAPresentable: Identifiable {
    let id = UUID()
    let text1: String
    let text2: String
    let productLinkTitle: String
}

struct ViewA: View {
    let presentable: ViewAPresentable
    let withNavigationBuilder: (_ innerView: AnyView) -> AnyView

    var body: some View {
        VStack(alignment: .leading,
               spacing: 10) {
            HStack(alignment: .firstTextBaseline,
                   spacing: 8) {
                    Text(presentable.text1)
                    Text(presentable.text2)
                }

                withNavigationBuilder(AnyView(Text(presentable.productLinkTitle)))
        }
    }
}

然后;

  • 我们有一个 HostA,它将使用该组件,并且实际上希望在该 HOC 上提供一个可导航的链接。
/// HOST A: Consumer of that component.

struct ConsumerView: View {
    let presentables: [ViewAPresentable] = (0...10).map {
        ViewAPresentable(text1: "Hello",
                         text2: "I'm \($0)",
            productLinkTitle: "Go to product")
    }

    var body: some View {
        NavigationView {
            List(presentables) {
                ViewA(presentable: $0) { innerView in
                    AnyView(NavigationLink(destination: ConsumerView()) {
                        innerView
                    })
                }
            }
        }
    }
}

但实际上是另一个消费者 B。不想提供可导航链接,它将仅提供内部组件,因为消费者 B 中的要求是不可导航。

/// HOST B: Consumer of that component. (But here it's not navigatable)

struct ConsumerBView: View {
    let presentables: [ViewAPresentable] = (0...10).map {
        ViewAPresentable(text1: "Hello",
                         text2: "I'm \($0)",
            productLinkTitle: "Product description not available")
    }

    var body: some View {
        NavigationView {
            List(presentables) {
                ViewA(presentable: $0) { innerView in
                    AnyView(innerView)
                }
            }
        }
    }
}

通过检查上面的代码,我们可以通过建立最低限度的合约来隔离组件。我去类型擦除是因为实际上在这里,上下文隐式需要类型擦除。ViewA 实际上并不关心在其中放置什么。将由消费者负责。

然后基于此,您可以使用 FactoryBuilders、Coordinators 等进一步抽象您的解决方案。但实际上问题的根源已经解决了。

于 2020-07-20T09:27:14.997 回答
0

我决定也解决这个问题。

人们可以很容易地争辩说,通过环境进行依赖注入将是一种更清洁的方法,实际上在很多方面它都可以,但我决定反对它,因为它不允许在目标确定站点使用通用数据类型作为上下文信息。换句话说,如果没有事先专门化它们,就不能将泛型注入环境。

这是我决定改用的模式……</p>

在框架方面

Segue 协调协议

解决方案的核心是一种协议Segueing

protocol Segueing {
    associatedtype Destination: View
    associatedtype Segue
    
    func destination(for segue: Segue) -> Destination
}

它所做的是定义一个契约,任何附加到视图的 segue 协调器必须能够提供另一个视图,作为目的地,以响应具体的 segue。

请注意,segue 不必是枚举,但使用由关联类型增强的有限枚举来承载必要的上下文是实用的。

转义枚举

enum Destinations<Value> {
    case details(_ context: Value)
}

这是一个示例,它定义了单个 segue “详细信息”并采用任意类型的值来携带用户选择的上下文,并且以类型安全的方式。是对紧密协作的一组视图使用单个 segue 枚举还是让每个视图定义自己的视图,这是一种设计选择。如果每个视图都带有自己的泛型类型,则后者是更可取的选择。

看法

struct ListView<N: Segueing, Value>: View where N.Segue == Destinations<Value>, Value: CustomStringConvertible & Hashable {
    var segues: N
    var items: [Value]
    
    var body: some View {
        NavigationView {
            List(items, id: \.self) { item in
                NavigationLink(destination: self.segues.destination(for: .details(item))) {
                    Text("\(item.description)")
                }
            }
        }
    }
}

Value这是泛型类型的列表视图示例。N: Segueing我们还建立了 segue coordinator和 segue enumeration之间的关系Destinations。因此,此视图接受一个 segue 协调器,该协调器根据可用的 segues 响应目标查询Destinations,并将用户选择的值传递给协调器以进行决策。

可以通过有条件地扩展视图并引入新的便利初始化器来定义默认的 segue 协调器,如下所示。

extension ListView where N == ListViewSegues<Value> {
    init(items: [Value]) {
        self = ListView(segues: ListViewSegues(), items: items)
    }
}

这都是在框架或 swift 包中定义的。

在客户端

转场协调员

struct ListViewSegues<Value>: Segueing where Value: CustomStringConvertible {
    func destination(for segue: Destinations<Value>) -> some View {
        switch segue {
            case .details(let value):
            return DetailView(segues: DetailViewSegues(), value: value)
        }
    }
}

struct DetailViewSegues<Value>: Segueing where Value: CustomStringConvertible {
    func destination(for segue: Destinations<Value>) -> some View {
        guard case let .details(value) = segue else { return AnyView(EmptyView()) }
        return AnyView(Text("Final destination: \(value.description)")
                .foregroundColor(.white)
                .padding()
                .background(Capsule()
                .foregroundColor(.gray))
        )
    }
}

在客户端,我们需要创建一个 segue 协调器。上面我们可以看到通过从框架中实例化另一个视图来响应单个 segue 选择的示例DetailView。我们提供另一个 segue 协调器并将(用户选择的)值传递给详细视图。

在呼叫现场

var v1 = ListView(segues: ListViewSegues(), items: [7, 5, 12])
var v2 = ListView(segues: ListViewSegues(), items: ["New York", "Tokyo", "Paris"])
var v3 = ListView(items: ["New York", "Tokyo", "Paris"])

好处

  1. 可以使视图可重用并分解为单独的模块,例如框架或 swift 包。
  2. 导航目的地可以在客户端自定义,不需要预先配置。
  3. 强(上下文)类型信息可在视图构建现场获得。
  4. 深度视图层次结构不会导致嵌套闭包。
于 2020-08-26T12:27:33.373 回答
0

这是另一个使用路由器解耦视图和目标视图的建议解决方案。如您所见,呈现的视图类型和呈现样式是从呈现视图中抽象出来的。

如果您认为下面附加的解决方案或示例代码有任何架构缺陷,请告诉我。

路由器:

import SwiftUI

protocol DetailsFeatureRouting {
    func makePushDetailsView<Label: View>(viewModel: GrapeViewModel, @ViewBuilder label: () -> Label) -> AnyView
    func makeModalDetailsView<Label: View>(viewModel: GrapeViewModel, @ViewBuilder label: () -> Label) -> AnyView
}

extension DetailsFeatureRouting {
    func makePushDetailsView<Label: View>(viewModel: GrapeViewModel, @ViewBuilder label: () -> Label) -> AnyView {
        label()
            .makeNavigation {
                DetailsView.make(viewModel: viewModel)
            }
            .anyView
    }

    func makeModalDetailsView<Label: View>(viewModel: GrapeViewModel, @ViewBuilder label: () -> Label) -> AnyView {
        label()
            .makeSheet {
                NavigationView {
                    DetailsView.make(viewModel: viewModel)
                }
            }
            .anyView
    }
}

根视图

struct RootView: View {
    @StateObject var presenter: RootPresenter

    var body: some View {
        NavigationView {
            List {
                ForEach(presenter.viewModels) { viewModel in
                    presenter.makeDestinationView(viewModel: viewModel) {
                        VStack(alignment: .leading) {
                            Text(viewModel.title)
                                .font(.system(size: 20))
                                .foregroundColor(.primary)
                                .lineLimit(3)
                            Text(viewModel.subtitle)
                                .font(.caption)
                                .foregroundColor(.secondary)
                        }
                    }
                }
            }
            .navigationTitle("Grapes")
        }
    }
}

整个项目在这里https://github.com/nikolsky2/FeatureRoutingSwiftUI

于 2021-05-22T15:30:21.110 回答
0

我是Navigator的作者,这是一个ViewNavigationLink. 所有导航目的地都可以在运行时调用。没有硬编码的静态目标视图

它基本上创建了一个navigator可以View调用所有基本导航操作的委托对象

  • navigate(to:)将视图推送到NavigationView
  • pop弹出当前ViewNavigationView
  • popToRoot()弹出所有视图NavigationView以显示根View

使用底层 SwiftUI 导航范例 (NavigationLink),没有自定义导航或包装视图

它还跟踪导航堆栈并允许自定义导航逻辑。这是一个片段

struct DetailScreen: ScreenView {
    @EnvironmentObject var navigator: Navigator<Screens, MyViewFactory>    
    @State var showNextScreen: Bool = false
    var currentScreen: Screens
    
    var body: some View {
        VStack(spacing: 32) {
            Button("Next") {
                navigator.navigate(to: calculateNextScreen())
            }
            .tint(.blue)
            .buttonStyle(.borderedProminent)
            .buttonBorderShape(.automatic)
            .controlSize(.large)


            Button("Dismiss") {
                navigator.pop()
            }
            .tint(.blue)
            .buttonStyle(.borderedProminent)
            .buttonBorderShape(.automatic)
            .controlSize(.large)

        }
        .navigationTitle("Detail Screen")
        .bindNavigation(self, binding: $showNextScreen)
    }
}
于 2021-12-28T16:54:38.327 回答
0

尽管是一年前,但这是一个有趣且仍然实际的问题。恕我直言,我们仍然需要为常见问题找到好的解决方案和最佳实践。

我不认为,UIKIt 中的协调器模式对于它努力解决的问题来说是一个很好的解决方案,一个正确的应用程序会引起很多麻烦,并留下许多未解决的问题,如何将它与架构的其余部分集成。

在 SwiftUI 中,一切似乎都是静态的和“预定义的”,我们正在努力寻找一种方法来赋予它一些活力。所以,同样的问题在 SwiftUI 中也仍然存在。

以下方法将导航的三个方面(创建、转换和配置)中的两个解耦,并将转换方面留在应该保留的位置(恕我直言):在源视图中。

其他两个方面的创建(目标视图和配置)在专用的“协调器”视图中执行,该视图是视图层次结构中源视图的父视图。

注意:SwiftUI 视图不是 UIKit 中的视图。它只是一种创建和修改“视图”的方法,它位于幕后,将由 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 负责设置 Navigation 的机制,并将 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)
    }
}
于 2021-09-07T14:04:04.277 回答