Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

SwiftUI - how to avoid navigation hardcoded into the view?

People also ask

How do I manage navigation in SwiftUI?

SwiftUI's NavigationLink has a second initializer that has an isActive parameter, allowing us to read or write whether the navigation link is currently active. In practical terms, this means we can programmatically trigger the activation of a navigation link by setting whatever state it's watching to true.

Why does SwiftUI use some view?

First, using some View is important for performance: SwiftUI needs to be able to look at the views we are showing and understand how they change, so it can correctly update the user interface.


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/


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.


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.


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.


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

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.


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