SwiftUI - how to avoid navigation hardcoded into the view?
Asked Answered
J

14

143

I try to do the architecture for a bigger, production ready SwiftUI App. I am running all the time into the same problem which points to a major design flaw in SwiftUI.

Still nobody could give me a full working, production ready answer.

How to do reusable Views in SwiftUI which contain navigation?

As the SwiftUI NavigationLink is strongly bound to the view this is simply not possible in such a way that it scales also in bigger Apps. NavigationLink in those small sample Apps works, yes - but not as soon as you want to reuse many Views in one App. And maybe also reuse over module boundaries. (like: reusing View in iOS, WatchOS, etc...)

The design problem: NavigationLinks are hardcoded into the View.

NavigationLink(destination: MyCustomView(item: item))

But if the view containing this NavigationLink should be reusable I can not hardcode the destination. There has to be a mechanism which provides the destination. I asked this here and got quite a good answer, but still not the full answer:

SwiftUI MVVM Coordinator/Router/NavigationLink

The idea was to inject the Destination Links into the reusable view. Generally the idea works but unfortunately this does not scale to real Production Apps. As soon as I have multiple reusable screens I run into the logical problem that one reusable view (ViewA) needs a preconfigured view-destination (ViewB). But what if ViewB also needs a preconfigured view-destination ViewC? I would need to create ViewB already in such a way that ViewC is injected already in ViewB before I inject ViewB into ViewA. And so on.... but as the data which at that time has to be passed is not available the whole construct fails.

Another idea I had was to use the Environment as dependency injection mechanism to inject destinations for NavigationLink. But I think this should be considered more or less as a hack and not a scalable solution for large Apps. We would end up using the Environment basically for everything. But because Environment also can be used only inside View's (not in separate Coordinators or ViewModels) this would again create strange constructs in my opinion.

Like business logic (e.g. view model code) and view have to be separated also navigation and view have to be separated (e.g. the Coordinator pattern) In UIKit it's possible because we access to UIViewController and UINavigationController behind the view. UIKit's MVC already had the problem that it mashed up so many concepts that it become the fun-name "Massive-View-Controller" instead of "Model-View-Controller". Now a similar problem continues in SwiftUI but even worse in my opinion. Navigation and Views are strongly coupled and can not be decoupled. Therefore it's not possible to do reusable views if they contain navigation. It was possible to solve this in UIKit but now I can't see a sane solution in SwiftUI. Unfortunately Apple did not provide us an explanation how to solve architectural issues like that. We got just some small sample Apps.

I would love to be proven wrong. Please show me a clean App design pattern which solves this for big production ready Apps.


Update: this bounty will end in a few minutes and unfortunately still nobody was able to provide a working example. But I will start a new bounty to solve this problem if I can't find any other solution and link it here. Thanks to all for their great Contribution!


Update 18th June 2020: I got an answer from Apple regarding this issue, proposing something like this to decouple views and models:

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

My response was:

Thanks for the feedback. But as you see you still have the strong coupling in the View. Now "ContentView" needs to know all the views (ViewA, ViewB, ViewC) it can navigate too. As I said, this works in small sample Apps, but it does not scale to big production ready Apps.

Imagine that I create a custom View in a Project in GitHub. And then import this view in my App. This custom View does not know anything about the other views it can navigate too, because they are specific to my App.

I hope I explained the problem better.

The only clean solution I see to this problem is to separate Navigation and Views like in UIKit. (e.g. UINavigationController)

Thanks, Darko

So still no clean & working solution for this problem. Looking forward to WWDC 2020.


Update September 2021: Using AnyView is not a good general solution for this problem. In big Apps basically all views have to be designed in a reusable way. This would mean that AnyView get's used everywhere. I had a session with two Apple developers and they clearly explained to me the AnyView creates a way worse performance then View and it should be only used in exceptional cases. The underlying reason for this is that the type of AnyView can't be resolved during compile time so it has to be allocated on the heap.


Update June 2022:

Apple introduced today at WWDC the new SwiftUI NavigationStack.

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

NavigationStack allows separating the destination view from the current visible view, by using the .navigationDestination modifier. It's finally a way of doing a clean coordinator.

Thanks for listening @Apple!

Judaic answered 19/4, 2020 at 12:55 Comment(10)
Agreed! I created a request for this in ”Feedback Assistant” many months ago, no response yet: gist.github.com/Sajjon/b7edb4cc11bcb6462f4e28dc170be245Compo
@Compo Thanks! I intend to write Apple as well, let's see if I get a response.Judaic
A wrote a letter to Apple regarding this. Let's see if we get a repsonse.Judaic
Nice! It would the best present during WWDC by far!Compo
If you have a long chain of dependencies, break dependencies. Nothing can generate it for you. You could probably elaborate on "real production app". It sounds like a design problem to me than language limitations, i.e.; don't design views that require long chain of dependencies.Tarsier
If you are willing to use a 3rd party library here is a great solution I have recently used in one of my projects: Link. It solves all current problems with navigation in SwiftUI.Glamorize
I'm a bit late in this topic, but anyway found approach looks promising for me - consider in proposed answer.Deane
This is an issue none of the "SwiftUI is so great and you should switch all your development to it!" articles tackle. I had exactly the same problem. Also sometimes transitions do weird things. SwiftUI is great to declare the interface, but not to manage navigation. I encapsulate each view in a UIViewController and handle all navigation there (using a protocol). I tried 100% SwiftUI and it was a nightmare. The below answers also show the complexity of the "better" way of doing things has become. I just use what has always worked, and let SwiftUI handle the presentation / editing of data.Avebury
Maybe this year, WWDC 2021, I keep my fingers crossed.Bloodthirsty
Hey @Darko, thanks for raising this question. Would you share a clean coordinator way with WWDC 22 update? thanksSeptennial
D
20

The closure is all you need!

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

I wrote a post about replacing the delegate pattern in SwiftUI with closures. https://swiftwithmajid.com/2019/11/06/the-power-of-closures-in-swiftui/

Deliver answered 23/4, 2020 at 13:2 Comment(3)
The closure is a good idea, thanks! But how would that look like in a deep view hierarchy? Imagine I have a NavigationView which goes 10 levels deeper, detail, into detail, into detail, etc...Judaic
I would like to invite you to show some simple example code of only three levels deep.Judaic
Can use coordinator + closure to solve deep hierarchyFuzee
F
13

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

Adapt the SceneDelegate to use the 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()
        }
    }

Inside of ContentView, we have this:


struct ContentView: View {
    var delegate: ContentViewDelegate?

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

We can define the ContenViewDelegate protocol like this:

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

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:

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

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

Forfeiture answered 23/4, 2020 at 13:14 Comment(14)
Thanks for the sample code. I would like to invite you to change Text("Returned Destination1") to something like MyCustomView(item: ItemType, destinationView: View). So that MyCustomView also needs some data and destination injected. How would you solve that?Judaic
You run into the nesting problem which I describe in my post. Please correct me if I am wrong. Basically this approach works if you have one reusable view and that reusable view does not contain another reusable view with NavigationLink. Which is quite a simple use-case but does not scale to big Apps. (where almost every view is reusable)Judaic
This highly depends on how you manage your app dependencies and their flow. If you are having dependencies in a single place, as you should IMO (also known as composition root), you shouldn't run into this problem.Forfeiture
What works for me is defining all your dependencies for a view as a protocol. Add conformance to the protocol in the composition root. Pass dependencies to the coordinator. Inject them from the coordinator. In theory, you should end up with more than three parameters, if done properly never more than dependencies and destination.Forfeiture
And after trying it, I see that with a properly implemented delegation, it doesn't matter how deep you go. It always gets instantiated from Coordinator, passed into a view with a simple two-parameter initialization. @JudaicForfeiture
I would love to see a concrete example. As I already mentioned, let's start at Text("Returned Destination1"). What if this needs to be a MyCustomView(item: ItemType, destinationView: View). What are you going to inject there? I understand dependency injection, loose coupling thru protocols, and shared dependencies with coordinators. All of that is not the problem - it's the needed nesting. Thanks.Judaic
Hmm, cool. I will make a sample project on GitHub and link it here so everyone can see. @JudaicForfeiture
Would be great if you put the reusable view in another module/framework. It’s the best test to really see that it’s decoupled from the main target. Thanks!Judaic
My current impression is that a working & clean solution needs a dependency injection container (e.g. Environment, Swinject, etc...). Additionally @Mecids idea with the generic closure return will be most likely part of it. If you start a GitHub project I will participate with PR's if you accept or need my help. Thanks.Judaic
Sure, would you like to talk more in detail about the creation of possible SwiftUI navigation ? @JudaicForfeiture
It would be great if you can just start a GitHub project. I will start than a new bounty as the current one is running out. Thanks in advance.Judaic
@NikolaMatijevic has there been an update to this? Have you been able to create a sample project? I'm currently going into this and would really appreciate if you have a solution that I could have a look at. Thank you!Giorgio
Maybe this article will help, Coordinator Pattern in SwiftUI: quickbirdstudios.com/blog/coordinator-pattern-in-swiftuiJacquelynejacquelynn
I have built a full app using this approach, you just need to break it down into multiple coordinators based on the complexity of navigation. The composition worked really well for me. I am aware that this might not be the ideal solution but I am working full-time in SwiftUI. Another interesting approach to the whole SwiftUI paradigm is The composable architecture by point free. It's similar to Redux :)Forfeiture
A
7

I will try to answer your points one by one. I will follow a little example where our View that should be reusable is a simple View that shows a Text and a NavigationLink that will go to some Destination. I created a Gist: SwiftUI - Flexible Navigation with Coordinators if you want to have a look at my full example.

The design problem: NavigationLinks are hardcoded into the View.

In your example it is bound to the View but as other answers have already shown, you can inject the destination to your View type struct MyView<Destination: View>: View. You can use any Type conforming to View as your destination now.

But if the view containing this NavigationLink should be reusable I can not hardcode the destination. There has to be a mechanism which provides the destination.

With the change above, there are mechanisms to provide the type. One example is:

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

will change to

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

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

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

and you can pass in your destination like this:

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

As soon as I have multiple reusable screens I run into the logical problem that one reusable view (ViewA) needs a preconfigured view-destination (ViewB). But what if ViewB also needs a preconfigured view-destination ViewC? I would need to create ViewB already in such a way that ViewC is injected already in ViewB before I inject ViewB into ViewA. And so on....

Well, obviously you need some kind of logic that will determine your Destination. At some point you need to tell the view what view comes next. I guess what you're trying to avoid is this:

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

I put together a simple example that uses Coordinators to pass around dependencies and to create the views. There is a protocol for the Coordinator and you can implement specific use cases based on that.

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

    func createView() -> ReusableNavigationLinkTextView<Destination>
}

Now we can create a specific Coordinator that will show the BoldTextView when clicking on the 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)
    }
}

If you want, you can also use the Coordinator to implement custom logic that determines the destination of your view. The following Coordinator shows the ItalicTextView after four clicks on the link.

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

If you have data that needs to be passed around, create another Coordinator around the other coordinator to hold the value. In this example I have a TextField -> EmptyView -> Text where the value from the TextField should be passed to the Text. The EmptyView must 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())
    }
}

This is the coordinator that creates views by calling other coordinators (or creates the views itself). It passes the value from TextField to Text and the EmptyView doesn't know about this.

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

To wrap it all up, you can also create a MainView that has some logic that decides what View / Coordinator should be used.

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

I know that I could also create a Coordinator protocol and some base methods, but I wanted to show a simple example on how to work with them.

By the way, this is very similar to the way that I used Coordinator in Swift UIKit apps.

If you have any questions, feedback or things to improve it, let me know.

Airfield answered 7/5, 2020 at 13:28 Comment(2)
struct ShowNavigationLinkUntilNumberGreaterFourThenItalicViewCoordinator: ReusableNavigationLinkTextViewCoordinator. You probably should use comments instead of super long name.Tarsier
au contraire, code should be self documenting. Long names are the way to go (and indeed what Apple do)Dehaven
R
7

iOS 16+

In iOS 16 we can finally access the NavigationStack and NavigationPath.

Here is a very simple demo:

  1. We can create an object that contains a NavigationPath and manipulates it:
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. Then we create a RootView that will contain the NavigationStack. We also need to provide navigationDestination, so we can route as desired:
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. All subsequent views only need a Coordinator object and do not have hardcoded routing controls.
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")
            }
        }
    }
}
Reward answered 7/6, 2022 at 11:2 Comment(2)
How to pass value ?Deimos
How come calling coordinator.show(ViewA.self) for the first time isn't enough to show ViewA and you need to call navigationDestination, but in subsequent calls to coordinator.show(SomeView.self), you don't need to call navigationDestination?Asphyxia
Y
6

Here is a fun example of drilling down infinitely and changing your data for the next detail view programmatically

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())
    }
}
Yonatan answered 23/4, 2020 at 15:43 Comment(26)
-> some View forces you to always return just one type of View.Judaic
The dependency injection with EnvironmentObject solves one part of the problem. But: should something crucial and important in an UI framework should be so complex... ?Judaic
I mean - if dependency injection is the only solution for this then I would reluctantly accept it. But this would really smell...Judaic
You could also make this return AnyView and wrap it in that to type erase. Not sure about performance. You could also simply pass in a navigationManager as a parameter, not as EnvironmentObject, but EnvironmentObject is a pretty standard use at this point. Heck, if there is not state associated with navigationManager, you could construct one per view and pass what you need through the destinationForTitle method. Or listen for notifications, etc. A lot of ways to go for this.Yonatan
It may seem easy just thinking about it but please try to actually write the code down and compile it. I did it. It's not that easy. What would be the return type of destinationForTitle? As you see View does not work. And AnyView has serious performance problems. (I mention this also in my linked post) The solution which comes nearest until now is the Generic Solution with Closures. But still not there. Nevertheless - thanks for the suggestion.Judaic
Hi, I added a more complex detail view. Basically you make it so that the detail view is completely dynamic based on what your needs are, according to the data you feed it. You might wonder about the complexity of having everything inside one view, but of course each SwiftUI app is only one view, the initial ContentView. Of course, the best result would be a performance-keeping type erasure and such, as you've noted. That would be a BIG improvement. But for now, you can keep things pretty dynamic as such.Yonatan
Thank you for your efforts Scott, I appreciate that. But maybe I didn’t explain my intentions good enough. See also my original post: #61188631Judaic
There is no dynamism in your example. Pretty everything is hardcoded and not replaceable. Only DetailView is possible as destination. Imagine you are developing a 3rd party Chart UI Component in SwiftUI and want to sell it. (that’s not what I want but it explains it quite good) The NavigationLink has to be completely decoupled.Judaic
Alright, I refactored to make the dynamism more clear. Basically, don't be thrown off by the "one" return type. The example I have effectively returns 4 different views depending on the view model. Dynamically make the view model, and you dynamically make the view. If you want the possibility of the DynamicView transforming into a different view, add the enum and if statement! The example I have is simple, but you could add more properties to the view model, like a ProductInformation struct to pull data from, and thus customize your ProductView even moreYonatan
Please put the DynamicView into an own module. E.g. a Swift Package or CocoaPod. You will see that it does not compile as it has implicit knowledge and is hardwired with the rest of the system. It's strongly coupled. I am impressed by your motivation but I'm afraid I can't explain you the problem good enough or you don't understand it. Really - the Swift Package would be a good test for you if you are still motivated to solve this. Imagine you are doing a reusable view which can be used on iOS, macOS, watchOS, etc.. then you have to put it into a separate, decoupled module.Judaic
Hmmm, I didn't see a request to make this a framework in your original question. Yes, unfortunately, you would need to explain your business requirements more clearly. Your initial question was about architectural patterns. Abstracting into a framework is a different game and I would need to understand how your framework is supposed to interact with clients, etc. For now, if someone wanted to make an app that dynamically drills down into 10 or 30 or 10,000 different views their app has defined, this is a way to do that.Yonatan
I don't request putting in into a framework. But putting it into a framework would be a good test if it's decoupled.Judaic
Yeah, it's a tightly coupled pattern, but effective for the app you were wanting to build in your original post. Meanwhile, on a different note, if you want SwiftUI charts :) github.com/AppPear/ChartViewYonatan
That's great, thanks for the link! And as you see - no Navigation on that chart. :)Judaic
Sure thing! And use the pattern, and you get infinite dynamic chart navigation 😜Yonatan
I don’t see why you couldn’t use this with your framework example. If you’re talking about a framework that vends an unknown view I would imagine it could just return some View. I also wouldn’t be surprised if an AnyView inside of a NavigationLink isn’t actually that big of a pref hit since the parent view is completely separated from the actual layout of the child. I’m no expert though, it would have to be tested. Instead of asking everyone for sample code where they can’t fully understand your requirements why don’t you write a UIKit sample and ask for translations?Circumsolar
This design is basically how the (UIKit) app I work on works. Models are generated which link to other models. A central system determines what vc should be loaded for that model and then the parent vc pushes it onto the stack.Circumsolar
@Circumsolar There are many different architecture which works without any issues with UIKit. MVC, MVP, MVVM, Viper, Redux, etc.. but there is this one difference between SwiftUI and UIKit which creates lot of headache. Navigation and View are strongly coupled together. You can see here a MVP example I created which shows how to decouple routing, building, presenting and the models. The result is: 100% of business logic is testable and views are reusable: github.com/DarkoDamjanovic/SolidRock.AppTemplate.iOS But this is just one example. But I do not ask for this in particular.Judaic
" I also wouldn’t be surprised if an AnyView inside of a NavigationLink isn’t actually that big of a pref hit since the parent view is completely separated from the actual layout of the child. " This comment seems important. @Judaic what measurements are you seeing if you use this method vs. loosely coupling with AnyView? Have you done any testing to see whether the performance hit would be significant, or whether it would be negligible, and thus scalable?Yonatan
Sometimes the AnyView performance difference doesn't have an impact. This could be one of those times. See here medium.com/swlh/…Yonatan
Also using AnyView would solve just one part of the problem. However - I would ask you to step back and see the whole picture: should something that foundational in an UI framework be so cumbersome? I talked already to dozen of experts with no clear answer. But still hope I am wrong.Judaic
Hmmm, it seems like you would have a framework with 1 class and two structs in it, maybe a protocol, and you get infinite navigation. It's just MVVM with a central component. Certainly less than VIPER. What part do you find cumbersome?Yonatan
Also, if you can use AnyView, what is the other part of the problem you are trying to solve? It looks like with that, you have a SwiftUI navigation architecture that you can extract into framework in a performative, scalable way that is ready for production enterprise apps. Is there another thing you're trying to solve?Yonatan
@Yonatan The other problem is one reusable view which links to another reusable view which links to another reusable view (etc...). Please try it out in code. For now it seems that only a Dependency Injection Container (e.g. SwiftUI Environment) would be the solution for that. If you look at Macids solution - this closure pattern solves how to provide needed items on demand. Using AnyView would replace the need for the Generics in Macid Solution. But Generic or AnyView is not that important for me. Now I would like a code example which shows all of this to hand over the bounty. Thanks!Judaic
MVC, MVVM and most patterns use "dependency injection." In MVVM the viewModel is a dependency injection that is not a UIView. A UINavigationController is a dependency injection that is not a UIView. You don't only use UIView in UIKit (you also use UIViewController), and you don't only use View in SwiftUI. It is an expected design pattern in SwiftUI to use EnvironmentObject. And this solution is MORE dynamic because you don't have to make all your closures ahead of time. You wanted a "clean App design pattern which solves this for big production ready Apps." I've given you two.Yonatan
@Yonatan I am sorry If I made you upset, this was not my intention. But I still can't see the solution you are proposing. The code you posted above clearly does not work. I know by myself all the concepts by as of today I can't write down a working solution which compiles and works. The edge points - multiple nested reusable Views with Navigation. Those Views in separated modules which are decoupled from the main App target and therefore can be reused among multiple targets. I hope you understand what I mean. Thanks for your contribution Scott.Judaic
S
4

Something that occurs to me is that when you say:

But what if ViewB also needs a preconfigured view-destination ViewC? I would need to create ViewB already in such a way that ViewC is injected already in ViewB before I inject ViewB into ViewA. And so on.... but as the data which at that time has to be passed is not available the whole construct fails.

it's not quite true. Rather than supplying views, you can design your re-usable components so that you supply closures which supply views on demand.

That way the closure which produces ViewB on demand can supply it with a closure which produces ViewC on demand, but the actual construction of the views can happen at a time when the contextual information that you need is available.

Subjective answered 23/4, 2020 at 13:2 Comment(1)
But how does the creation of such „closure-tree“ differ from actual views? The item providing problem would be solved, but not the needed nesting. I create a closure which creates a view - ok. But in that closure I would already need to provide the creation of the next closure. And in the last one the next. Etc... but maybe I misunderstand you. Some code example would help. Thanks.Judaic
S
3

This is a completely off-the-top-of-my-head answer, so will probably turn out to be nonsense, but I'd be tempted to use a hybrid approach.

Use the environment to pass through a single coordinator object - lets call it NavigationCoordinator.

Give your re-usable views some sort of identifier which is set dynamically. This identifier gives semantic information corresponding to the client application's actual use case and navigation hierarchy.

Have the re-usable views query the NavigationCoordinator for the destination view, passing their identifier and the identifier of the view type they are navigating to.

This leaves the NavigationCoordinator as a single injection point, and it's a non-view object which can be accessed outside the view hierarchy.

During setup you can register the right view classes for it to return, using some sort of matching with the identifiers it's passed at runtime. Something as simple as matching with the destination identifier might work in some cases. Or matching against a pair of host and destination identifiers.

In more complex cases you can write a custom controller which takes account of other app-specific information.

Since it is injected via the environment, any view can override the default NavigationCoordinator at any point and supply a different one to its subviews.

Subjective answered 23/4, 2020 at 12:22 Comment(0)
D
3

The problem is in static type checking, ie. to construct NavigationLink we need to provide some specific views for it. So if we need to break this dependencies we need type erasure, ie. AnyView

Here is a working demo of idea, based on Router/ViewModel concepts using type-erased views to avoid tight dependencies. Tested with Xcode 11.4 / iOS 13.4.

Let's start for the end of what we get and analyse it (in comments):

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

Thus, we have pure UI w/o any navigation specifics and separated knowledge of where this UI can route to. And here is how it works:

demo

Building blocks:

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

Testing code shown in demo:

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)") })
    }
}
Deane answered 15/7, 2020 at 7:39 Comment(0)
R
3

I've posted my solutions in an article - Routing in SwiftUI. Two solutions for routing in SwiftUI.

Here is an overview:

1. Router with trigger views. A router will return trigger subviews for all possible navigation routes to insert them into a presenting view. Such a subview code snippet will contain NavigationLink or .sheet modifier inside, as well as a destination view specified, and will use a state property, stored in the router, via binding. This way, the presenting view won’t depend on the navigation code and destination, only on a router protocol.

A presenting view example:

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

A router example:

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

The SheetButton trigger view:

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

Source code: https://github.com/ihorvovk/Routing-in-SwiftUI-with-trigger-views

2. Router with type erased modifiers. A presenting view will be configured with general modifiers for presenting any other views: .navigation(router), .sheet(router). Being initialised with a router, those modifiers will track navigation state stored in the router via bindings and perform navigation when the router changes that state. The router also will have functions for all possible navigations. Those functions will change the state and trigger navigation as a result.

A presenting view example:

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

The custome .sheet modifier takes a router as a parameter:

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

The base Router class:

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

Subclasses only need to implement functions for available routes:

class PresentingRouter: Router, PresentingRouterProtocol {

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

Source code: https://github.com/ihorvovk/Routing-in-SwiftUI-with-type-erased-modifiers

Both solutions separate navigation logic from the view layer. Both store navigation state in a router. It allows us to perform navigation and implement deep linking simply by changing routers’ state.

Rogerson answered 7/10, 2020 at 12:28 Comment(2)
Ho can I use "NavigationButton" in List view's did select method?Tertial
@ErkamKUCET You would need to use a simple Button instead and call router's function from its action. The purpose of a router is to decouple navigation logic from UI.Rogerson
C
0

Really interesting topic the one you're discussing here guys. To put my penny here, I will share my thoughts. I did try to mainly focus on the problem without opinionating it too much.

Let's say you're building a UI Components framework that you would need to ship within your company worldwide. Then the requirement you have it's building "dummy" components that will now how to present themselves and some extra minimal knowledge, like if they likely will have navigation or not.

Assumptions:

  • ViewA component will live in a UI isolated Framework.
  • ViewA component will likely know that somehow from there it would be able to navigate. But ViewA doesn't care to much about the type of what's living within it. It will just provide it's own "potentially" navigatable view and that's it. Therefore the "contract" that will be establish is. A higher-order-component erased typed builder (inspired by React, who would tell me after many years in iOS :D) that will receive a view from the component. And this builder would provide a View. That's it. ViewA does not need to know anything else.

ViewA

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

Then;

  • We have a HostA, which will consume that component, and actually wants to provide a navigatable link on that 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
                    })
                }
            }
        }
    }
}

But actually another consumer B. Doesn't want to provide a navigatable link, it will provide just the inner component as it is given the requirement in Consumer B is to not be navigable.

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

By checking the code above, we can have isolated components with the bare minimum contract established. I went to type erasure because actually here, the type erasure is implicitly required by the context. ViewA actually doesn't care about what to be placed within there. Will be responsibility of the consumer.

Then based on this, you can abstract further your solution with FactoryBuilders, Coordinators and so on. But actually the root of the problem it's solved.

Carpous answered 20/7, 2020 at 9:27 Comment(0)
B
0

I decided to have a go on the problem as well.

One could easily argue that dependency injection via environment would be a cleaner approach, and indeed in many ways it can be, but I have decided against it as it does not allow using generic data type as context information at the site of destination determination. In other words, you cannot inject generics into environment without specializing them beforehand.

Here’s the pattern I’ve decided to use instead…

On the framework side

Protocol for Segue Coordination

At the core of the solution is one protocol Segueing.

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

What it does is define a contract that any segue coordinator attached to a view must be able to provide another view, as a destination, in response to a concrete segue.

Note that segue does not need to be an enumeration, but it is practical to use a finite enumeration augmented by associated types to carry necessary context for the purpose.

Segue Enumeration

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

Here’s an example that defines a single segue “details” and takes an arbitrary type Value to carry context of the user choice, and in a type safe manner. It’s a design choice whether to use a single segue enumeration for a group of views tightly working together or have each view define its own. The latter being a more preferable option if each view brings along its own generic types.

View

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

Here’s an example of a list view for generic Value type. We also establish a relationship between segue coordinator N: Segueing and segue enumeration Destinations. So this view accepts a segue coordinator that responds to destination queries based on available segues in Destinations and passes on the user selected value to the coordinator for decision making.

It is possible to define a default segue coordinator by conditionally extending the view and introducing a new convenience initializer as below.

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

This is all defined inside the framework or a swift package.

On the client side

Segue Coordinators

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

On the client side we need to create a segue coordinator. Above we can see an example of responding to a single segue choice by instantiating another view from the framework DetailView. We provide another segue coordinator and pass on the value (of user choosing) to the detail view.

At call site

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

Benefits

  1. Views can be made reusable and factored out into a separate module such as framework or swift package.
  2. Navigation destinations can be customized on client side and do not need to be pre-configured.
  3. Strong (context) type information is available at view construction site.
  4. Deep view hierarchies do not result in nested closures.
Borneol answered 26/8, 2020 at 12:27 Comment(0)
S
0

Here's another suggested solution decoupling Views and destination Views using Routers. As you can see the presented View type and presentation styles are abstracted away from the presenting View.

If you think the solution or sample code attached below has any architectural drawbacks please let me know.

Router:

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

The entire project is here https://github.com/nikolsky2/FeatureRoutingSwiftUI

Subliminal answered 22/5, 2021 at 15:30 Comment(4)
You shouldn't use AnyView as it hides away details that allow SwiftUI to optimize for transitions, comparisons, and animations.Sheeting
Hi @KyleBrowning do you mind sharing a bit more details how exactly using AnyView is degrading performance please? What would be your solution to this?Subliminal
I use the .background modifier to solve this problem.Sheeting
swiftbysundell.com/articles/avoiding-anyview-in-swiftuiSheeting
C
0

Despite its a year ago, this is an interesting and still actual question. IMHO, we still need to discover good solutions and best practices for common problems.

I don't think though, the Coordinator pattern in UIKIt is a good solution for the problem it strives to solve and a correct application rises a lot of headaches and left many questions unanswered how to integrate it with the rest of the architecture.

In SwiftUI everything seems that static and "predefined" that we struggle so find a way to get some dynamism into it. So, the same problem still exists, too in SwiftUI.

The following approach decouples two of the three aspects for Navigation (creation, transition and configuration), and leaves the transition aspect where it should stay (IMHO): in the source view.

The two other aspects creation (of the destination view and configuration) is performed in a dedicated "Coordinator" View which is a parent view of the source view in the view hierarchy.

Note: a SwiftUI view is not a View as it was in UIKit. It is merely a means to create and modify a "View" which lives behind the scenes and will be managed by SwiftUI. So, using a view which performs solely setup and configuration is IMHO a total valid and useful approach anyway. A proper naming and a convention will help to identify these views.

The solution is pretty light weight. If there is the need to further decouple certain aspects - like making the kind of destination view not only dependent on the element but also from some property in some environment, I would not resort to anything like the Coordinator pattern like it was invented for UIKit. In SwiftUI we have better alternatives. I would use common techniques like the "Reader Monad" which decomposes application and configuration and makes it possible to have two "far away" locations where you implement the one and the other aspect - which is basically a form of Dependency Injection.

So, given this scenario:

  • we have a list view which shows elements
  • each element can be displayed in a detail view via a Navigation Link.
  • the kind of the detail view depends on certain properties of the element
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)")
                    }
            }
        }
    }
}

The Master View has no knowledge of the Detail View. It uses only one Navigation Link to effectively show different kind of detail views. It also does not know the mechanics which determines the kind of detail view. However it knows and determines the kind of transition.

struct DetailView: View {
    let item: DetailViewModel.Item

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

Just a detail view for demonstration.

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

The MasterCoordinatorView is responsible to setup the mechanics for the Navigation and also decouples the ViewModel from the 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)
        }
    }
}

Here, the DetailCoordinatorView is responsible to the select the detail view.

Finally, the View Models:

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

}

For playgrounds:

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)
    }
}
Crosby answered 7/9, 2021 at 14:4 Comment(3)
Using 'AnyView' is not a good general solution for this problem. In big Apps basically all views have to be designed in a reusable way. This would mean that 'AnyView' get's used everywhere. I had a session with two Apple developers and they clearly explained to me the AnyView creates a way worse performance then View and it should be only used in exceptional cases. The underlying reason for this is that the type of AnyView can't be resolved during compile time so it has to be allocated on the heap.Judaic
@Judaic Thanks for the comment. You are right that AnyView should not be used generally - and it doesn't need to be used to build a view hierarchy within one "scene" (page, screen). In this use case, an AnyView is returned where it starts a complete new flow by pushing the returned view on the navigation stack. There is no other way to use AnyView if you want to to completely decouple your destination view from the parent view. There is also no performance issue.Crosby
@Judaic See also here: AnyView Performance Issue: Busted, Swift Sundell: avoid AnyView as much as possible doesn’t mean that it should never be usedCrosby
W
0

I am the author of Navigator a library that decouples the View from the NavigationLink. All navigation destinations can be called at runtime. No hardcoded static destination views

It basically creates a delegation object navigator lifted out of the View that can call all basic navigation operations

  • navigate(to:) pushes a View onto the NavigationView
  • pop pops the current View off of the NavigationView
  • popToRoot() pops all views off the NavigationView to reveal the root View

Uses the underlying SwiftUI navigation paradigm (NavigationLink), no custom Navigation or wrapper views

It also keeps keeps track of the navigation stack and allows for custom navigation logic. Here is a snippet

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)
    }
}
Whiffen answered 28/12, 2021 at 16:54 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.