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

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

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

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

由于 SwiftUI NavigationLink是强烈的视图绑定,这是根本不可能的,以这样的方式,它的规模也在较大的应用程序。NavigationLink在这些小样本应用程序的工作,是的-但不尽快你想重用一个应用程序中的许多视图。也许还可以在模块边界上重用。(例如: 在 iOS、 WatchOS 等系统中重用 View..。.)

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

NavigationLink(destination: MyCustomView(item: item))

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

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

其想法是将目标链接注入到可重用视图中。一般来说,这个想法是可行的,但不幸的是,这个想法并不适用于真正的生产应用程序。一旦我有了多个可重用屏幕,我就会遇到一个逻辑问题,即一个可重用视图(ViewA)需要一个预配置的视图-目的地(ViewB)。但是,如果 ViewB也需要预先配置的视图目标 ViewC,那该怎么办呢?我需要创建 ViewB已经在这样的方式,ViewC已经注射到 ViewB之前,我注射到 ViewAViewB。等等。但由于当时必须传递的数据不可用,整个构造就会失败。

我的另一个想法是使用 Environment作为依赖注入机制,为 NavigationLink注入目的地。但我认为这或多或少应该被视为一种技巧,而不是大型应用程序的可扩展解决方案。我们最终会把环境基本上用于所有的事情。但是因为环境也可以在视图中使用 只有(不在单独的协调器或视图模型中) ,在我看来这又会产生奇怪的结构。

就像业务逻辑(例如视图模型代码)和视图必须分开一样,导航和视图也必须分开(例如协调器模式)在 UIKit中这是可能的,因为我们访问视图后面的 UIViewControllerUINavigationControllerUIKit's MVC 已经有了这样的问题,它混合了这么多的概念,它成为一个有趣的名称“大规模视图控制器”,而不是“模型-视图-控制器”。现在类似的问题继续在 SwiftUI,但在我看来甚至更糟。导航和视图是强耦合的,不能解耦。因此,如果包含导航,就不可能执行可重用视图。在 UIKit中解决这个问题是可能的,但是现在我在 SwiftUI中看不到一个理智的解决方案。不幸的是,苹果并没有向我们解释如何解决这样的架构问题。我们只有一些小样本应用程序。

我很乐意被证明是错的。请告诉我一个干净的应用程序设计模式,解决这个大生产准备应用程序。

先谢谢你。


更新: 这个奖金将在几分钟内结束,不幸的是,仍然没有人能够提供一个工作的例子。但是如果我找不到其他的解决方法,我会开始一个新的悬赏来解决这个问题。感谢所有人的伟大贡献!


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)

谢谢,达科

因此,仍然没有清洁和工作的解决方案,这个问题。期待2020年 WWDC。


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


2022年6月更新:

苹果今天在全球开发者大会上推出了新的 SwiftUI NavigationStack

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

通过使用 .navigationDestination修饰符,NavigationStack允许将目标视图与当前可见视图分离。这终于成为一个干净的协调员了。

感谢收听@Apple!

13828 次浏览

这是一个完全凭空想出来的答案,所以可能会被证明是无稽之谈,但我会倾向于使用混合方法。

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

给你的可重用视图一些动态设置的标识符。此标识符提供与客户端应用程序的实际用例和导航层次结构对应的语义信息。

让可重用视图为目标视图查询导航协调器,传递它们的标识符和它们导航到的视图类型的标识符。

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

在安装过程中,您可以使用与运行时传递的标识符进行某种匹配来注册要返回的正确视图类。与目标标识符匹配这样简单的操作在某些情况下可能有效。或者匹配一对主机和目标标识符。

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

因为它是通过环境注入的,所以任何视图都可以在任何时候覆盖默认的 NavigationAgreement,并为其子视图提供一个不同的浏览协调器。

我突然想到,当你说:

但是如果 ViewB 也需要预配置的视图目标 ViewC 呢?在将 ViewB 注入 ViewA 之前,我需要已经在 ViewB 中注入 ViewC 的方式来创建 ViewB。等等。但由于当时必须传递的数据不可用,整个构造就会失败。

不完全正确。与提供视图不同,您可以设计可重用的组件,以便提供根据需要提供视图的闭包。

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

你只需要了结这一切!

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/

我的想法基本上是 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"))
}
}

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

下面是一个有趣的例子,它可以无限深入,并通过编程方式更改下一个详细视图的数据

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

我将试着一个一个地回答你的问题。我将遵循一个小的例子,其中我们的视图,应该是可重用的是一个简单的 View,显示了一个 Text和一个 NavigationLink,将去一些 Destination。 我创建了一个 < a href = “ https://Gist.github.com/yrave/1797f2e66b7e1e328cd5b8500d913cdd”rel = “ noReferrer”> 要点: SwiftUI-使用协调器的灵活导航 如果你想看看我的完整例子。

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

在您的示例中,它被绑定到视图,但是正如其他答案已经显示的那样,您可以将目标注入到视图类型 struct MyView<Destination: 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 注入 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)
}
)
}
)
}
}

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

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


func createView() -> ReusableNavigationLinkTextView<Destination>
}

现在我们可以创建一个特定的协调器,它将在单击 NavigationLink时显示 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的方式非常相似。

如果你有任何问题,反馈或事情来改善它,让我知道。

问题在于静态类型检查。为了构建 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)") })
}
}


因此,我们拥有纯粹的 UI/o 任何导航细节,并且对这个 UI 可以路由到哪里有单独的知识。它是这样运作的:

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

你们现在讨论的这个话题很有意思。把我的硬币放在这里,我将分享我的想法。我确实试着把注意力主要集中在这个问题上,而没有过多地表达自己的观点。

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

假设:

  • ViewA 组件将存在于 UI 隔离的 Framework 中。
  • ViewA 组件很可能知道它可以从那里进行导航。但是 ViewA 并不关心生活在其中的东西的类型。它只是提供自己的“潜在的”导航视图,仅此而已。因此,将要建立的“合同”是。高阶分量 擦除类型的构建器(受到 React 的启发,在 iOS: D 中呆了很多年后,React 告诉我) ,它将从组件中获得一个视图。这个构建器将提供一个视图。就是这样。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、坐标系等进一步抽象您的解决方案。但实际上问题的根源已经解决了。

我也决定试试这个问题。

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

这是我决定用的模式。

在框架方面

继续协调议定书

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

protocol Segueing {
associatedtype Destination: View
associatedtype Segue
    

func destination(for segue: Segue) -> Destination
}

它所做的是定义一个契约,任何附属于一个观点的接续协调员必须能够提供另一个观点,作为一个目的地,以响应一个具体的接续。

请注意,segue 不需要是枚举,但是使用由关联类型增强的有限枚举来携带必要的上下文是切实可行的。

继续枚举

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

这里有一个例子,它定义了一个单独的“细节”,并以类型安全的方式使用一个任意类型 Value 来传递用户选择的上下文。 是对紧密协作的一组视图使用单个继承枚举,还是让每个视图定义自己的视图,这是一个设计选择。如果每个视图都带有自己的泛型类型,后者是更可取的选择。

观景

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和继承枚举 Destinations之间的关系。因此,该视图接受一个 segue 协调器,该协调器根据 Destinations中的可用 segue 来响应目标查询,并将用户选择的值传递给协调器以进行决策。

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

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

这些都是在框架或快速包中定义的。

在客户那边

继续协调员

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

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

在通话现场

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. 视图可以重用,并分解成单独的模块 如框架或快速一揽子方案。
  2. 导航目的地可以在客户端自定义,不需要预先配置。
  3. 强(上下文)类型信息可在视图施工现场获得。
  4. 深视图层次结构不会导致嵌套闭包。

我在一篇文章中提出了我的解决方案—— SwiftUI 中的路由

以下是一个概述:

1.带触发器视图的路由器。路由器将返回所有可能的导航路由的触发子视图,并将它们插入到呈现视图中。这样的子视图代码片段将包含内部的 < strong > NavigationLink < strong > . 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))
}
}

< strong > 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.带有类型擦除修饰符的路由器。呈现视图将配置通用修饰符,用于呈现任何其他视图: 导航(路由器)工作表(路由器)。通过路由器初始化,这些修改器将通过绑定跟踪存储在路由器中的导航状态,并在路由器改变该状态时执行导航。该路由器还将为所有可能的导航功能。这些函数将因此改变状态和触发器导航。

一个展示视图的例子:

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

定制的 < strong > . 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

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

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

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

路由器:

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

RootView

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

尽管已经过去一年了,这仍然是一个有趣而实际的问题。恕我直言,我们仍然需要为常见问题找到良好的解决方案和最佳实践。

但是我不认为 UIKIt 中的协调器模式是一个很好的解决问题的方案,而且一个正确的应用程序会引起很多麻烦,并且留下很多问题没有得到解答,比如如何将它与架构的其他部分集成起来。

在 SwiftUI 中,一切似乎都是静态的和“预定义的”,我们努力寻找一种方法让它充满活力。因此,同样的问题仍然存在于 SwiftUI 中。

下面的方法将 与导航的 方面(创建、转换和配置)解耦,并将 过渡期方面保留在它应该保留的位置(IMHO) : 在源视图中。

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

注意: SwiftUI 视图不是 UIKit 中的视图。它仅仅是一种创建和修改“视图”的手段,这种“视图”隐藏在后台,将由 SwiftUI 管理。因此,无论如何,使用仅执行 设计配置的视图是一种完全有效和有用的方法。正确的命名和约定将有助于识别这些视图。

这种解决方案重量很轻。如果需要进一步解耦某些方面——比如让目标视图的类型不仅依赖于元素,而且还依赖于某些 环境中的某些属性,我就不会像为 UIKit 发明的那样使用协调器模式。在 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
}
}

主协调器视图负责为导航设置机制,并且还将 ViewModel 从视图中解耦。

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

在这里,DetailConceratorView 负责选择详细信息视图。

最后,视图模型:

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

我是 领航员库的作者,该库将 ViewNavigationLink解耦。可以在运行时调用所有导航目的地。没有硬编码的静态目标视图

它基本上创建了一个从 View中提取出来的委托对象 navigator,它可以调用所有基本的导航操作

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

使用基础 SwiftUI 导航范例(NavigationLink) ,不使用自定义导航或包装器视图

它还跟踪导航堆栈并允许自定义导航逻辑

struct DetailScreen: ScreenView {
@EnvironmentObject var navigator: Navigator<ScreenID, MyViewFactory>
@State var showNextScreen: Bool = false
var screenId: ScreenID
    

var body: some View {
VStack(spacing: 32) {
Button("Next") {
navigator.navigate(to: calculateNextScreen())
}
.tint(.blue)




Button("Dismiss") {
navigator.pop()
}
.tint(.red)


}
.navigationTitle("Detail Screen")
.bindNavigation(self, binding: $showNextScreen)
}
}

IOS16 +

在 iOS16中,我们最终可以访问 导航栈导航路径

下面是一个非常简单的演示:

  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. 然后我们创建一个包含 NavigationStackRootView。我们还需要提供 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")
}
}
}
}