Unit Testing state of mind in Swift
October 13, 2020
Coordinator with SwiftUI
April 5, 2021

Navigate programmatically outside of a view in SwiftUI

When I started to play with SwiftUI it was very unclear to me how can I programmatically navigate without any hacks(like creating an empty view) and how can I let the view be just a view without any responsibility of the navigation logic and even without any information inside the view where the view is able to navigate. So today I found the way that a have not seen in any other place and want to share it with you.

As we know today in SwiftUI we can navigate with NavigationLink which need to receive a different set of parameters, I will focus on the set with the next parameters:

/// Creates an instance that presents `destination` when active.
public init(destination: Destination, isActive: Binding, @ViewBuilder label: () -> Label)

I don’t want to let the view know when he should navigate and where so I need some object inside the view that will be read-only for the view and mutable from the Coordinator.

I will call him Navigation and I need 2 observable properties inside:

    @Published private(set) var destination: AnyView? {
        didSet {
            if destination != nil {
                isActive = true
            }
        }
    }
    
    @Published var isActive: Bool = false

As you can see we have a read-only destination and when it is set we immediately activating the link but the problem is in the isActive, it is mutable and cannot be read-only because the system needs to set it to false when the user will navigate back. So we will allow only setting it to false:

    @Published var isActive: Bool = false {
        didSet {
            if isActive {
                if destination == nil {
                    assertionFailure("isActive can be set only from controller")
                    isActive = false
                }
            } else {
                destination = nil
            }
        }
    }

As you can see setting it to true when we don’t have a destination will crash in debug mode and will notify the developer that he does not have permission to do it outside the class and even if this code will be going to production by mistake so nothing will happen, isActive will reset himself to false.

Ok, now the question is how we can change the destination from the Coordinator? Who will be this controller?.

For this I will create for our Navigation a “Controller” and the owner of the controller will be able to change the destination to any view he wants and our class will be looks like this:

import Combine
import SwiftUI

class Navigation: ObservableObject {
    
    class Controller {
        let show: PassthroughSubject<AnyView, Never> = .init()
    }
    
    @Published private(set) var destination: AnyView? {
        didSet {
            if destination != nil {
                isActive = true
            }
        }
    }
    
    @Published var isActive: Bool = false {
        didSet {
            if isActive {
                if destination == nil {
                    assertionFailure("isActive can be set only from controller")
                    isActive = false
                }
            } else {
                destination = nil
            }
        }
    }
    
    private let controller: Controller
    private var cancelables = Set<AnyCancellable>()
    
    init(controller: Controller) {
        self.controller = controller
        self.setupObservers()
    }
}

private extension Navigation {
    
    func setupObservers() {
        controller.show.sink { [weak self] (destination) in
            self?.destination = destination
        }.store(in: &cancelables)
    }
}

The View will need to implement NavigationLink like this:

struct MainView: View {
    
    @StateObject var viewModel: ViewModel
    @StateObject var navigation: Navigation
    
    var body: some View {
        
        NavigationView {
            VStack {
                Text("Hello, Navigation!")

                Button("User Profile") {
                    viewModel.userProfileButtonTapped.send()
                }

                NavigationLink(
                    destination: navigation.destination,
                    isActive: $navigation.isActive,
                    label: {})
            }
        }.navigationViewStyle(StackNavigationViewStyle())
    }
}

And the Coordinator will use the Navigation’s API:

        let controller = Navigation.Controller()
        let navigation = Navigation(controller: controller)
        let viewModel = MainViewModel()
        let mainView = MainView(viewModel: viewModel, navigation: navigation)

        viewModel.userProfileButtonTapped
            .sink { [weak self] in
                guard let self = self else { return }
                let userProfileViewModel = UserProfileViewModel()
                let userProfileView = UserProfileView(viewModel: userProfileViewModel)

                controller.show.send(AnyView(userProfileView))
                
                navigation
                    .$isActive
                    .sink { (isShown) in
                        if isShown {
                            //user profile view is shown
                        } else {
                            //user profile view is dismissed
                        }
                    }.store(in: &amp;userProfileViewModel.cancelables)

            }.store(in: &amp;cancelables)

That’s it, very easy and very powerful, If you are not familiar with the Coordinator pattern you can find another article about it here.

You are welcome to clap if you like it.

11

Comments are closed.