SwiftUI. Is there life without NavigationView or a few words about the coordinator

In the distant, distant times, when iOS was very small, developers, proudly called iOS developers, thought about customizing the navigation stack. Not that the nav stack was bad—it fit in perfectly with Apple’s worldview—but the nav bar was often a thorn in the side of users and designers. Therefore, the developers used a simple trick – they hid the panel in the application, and instead showed their own panel, with their own interface design, the controls of which were all tied to the same push and pop methods available to them out of the box.

Over time, even Apple realized that it was impossible to live like this anymore, having released iOS 7… How much negativity spilled over the heads of developers… But those who learned how to customize the navigation bar got out of those dark times very worthily.

…until SwiftUI flickered on the horizon.

This is where the hint ended.

Those developers who switched to SwiftUI sooner or later face the problem of unpredictable NavigationView behavior. This usually happens when you re-enter the displayed view in the navigation stack – for some reason, the navigation bar is shifted relative to the previously set position. And when using a container in conjunction with UIKit, it can lead to duplicate navigation. In addition, the navigation view causes a lot of problems when using other components. All this suggests that SwiftUI is too raw to be used in commercial projects. (For a moment, SwiftUI 4 has been announced for the moment and will be available in iOS16).

Developers have been waiting for years for Apple to fix widely known glaring navigation issues, and hope has been raised with the announcement that NavigationView will be deprecated and SwiftUI 4 will introduce a new user-friendly navigation experience. The study of this issue showed that it was possible to write a little less code, but this could easily be done without Apple, just using switch instead of if, but the navigation problems did not disappear anywhere – all the same old problems appeared in the new navigation components.

SwiftUI is too good to ignore because of its childhood illnesses. But, alas, many do it simply because they cannot get around the mistakes that have set their teeth on edge. Moreover, in professional communities there are often discussions about whether it is possible to do without NavigationView at all.

Surprisingly, this is not easy, but very simple. But, on the way to a solution, some pebbles arise, without stepping over which it is impossible to move on. Those who overcome them do not even remember the difficulties that have arisen, the rest are waiting for a saving miracle from Apple, which, of course, is ready to lend a helping hand to those in need by making some changes regarding AnyView in Swift 5.7. True, all this will be available in the new iOS, and anyone who wants to continue working with an earlier version of the target will have to upgrade.

In discussions about how to get around the “problem”, the concept of “Coordinator” often comes up. For those who have experience with UIKit, the coordinator pattern is familiar because it makes navigation easier and accessible from any part of the application. But those who are “born into the world” along with SwiftUI ask questions about what it is about, and do not understand how the discussed pattern can help solve the navigation problem in their application.

In fact, when it comes to the coordinator in SwiftUI, it all comes down to a much simpler solution than what was described in relation to UIKit.

Demo
Demo

Let’s say we want to navigate between the View chain. It can be any some View. But for simplicity, we will use colored views. The color of each view will be different from the other in the sequence of colors of the rainbow. The only feature of such a view will be the presence of the “Back” button.

In this case, the transition to the extreme elements of the view stack is possible using simple calls from anywhere in the code:

Coordinator.next(state: .root)
Coordinator.next(state: .end)

Navigating the stack is done using simple UIKit-style navigation commands:

Coordinator.push(view: ColoredView(index: index + 1))
Coordinator.pop()

Here index is the color number in the Colors array from red to magenta).

Looks simple? It seems that the task of implementing the ColoredView itself is a trainee-level task.

hidden text
import SwiftUI

struct ColoredView: View {
    var index: Int = 0

    private (set) var colors:[Color] = [.red, .orange, .yellow, .green, .teal, .blue, .purple]
    
    var body: some View {
        ZStack {
            TitlePlace()
            HelloPlace()
            NextPlace()
            ModalaPlace()
            CoordinatorPlace()
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(viewColor)
    }
    
    private var viewColor: Color {
        return colors[index]
    }
    
    private func HelloPlace() -> some View {
        VStack {
            Text("Hello, World!")
            Text("Index \(index)")
        }
        .foregroundColor(.white)
    }
    
    private func TitlePlace() -> some View {
        VStack {
            HStack {
                if index > 0 {
                    Button {
                        Coordinator.pop()
                    } label: {
                        Image(systemName: "arrow.left")
                            .foregroundColor(.white)

                    }
                    .padding()
                }
                Spacer()
            }
            .frame(height: 44)
            .frame(maxWidth: .infinity)
            Spacer()
        }
    }
    
    private func NextPlace() -> some View {
        VStack {
            if index < colors.count - 1 {
                Button {
                    Coordinator.push(view: ColoredView(index: index + 1))

                } label: {
                    Image(systemName: "arrow.right")
                        .foregroundColor(.white)
                }
            }
        }
        .offset(y: 44)
    }

    private func ModalaPlace() -> some View {
        VStack {
            Button {
                Coordinator.modal(view: ModalView())
            } label: {
                Text("SHOW MODAL")
                    .foregroundColor(.white)
                    .fontWeight(.heavy)
            }
        }
        .offset(y: 120)
    }

    private func CoordinatorPlace() -> some View {
        HStack {
            Button {
                Coordinator.next(state: .root)
            } label: {
                Text("ROOT")
                    .foregroundColor(.white)
                    .fontWeight(.heavy)
            }
            .padding()
            .border(.white, width: 1)

            Button {
                Coordinator.next(state: .end)
            } label: {
                Text("END")
                    .foregroundColor(.white)
                    .fontWeight(.heavy)
            }
            .padding()
            .border(.white, width: 1)
        }
        .offset(y: 200)
    }

}

Since the navigation stack is not the only way to navigate in the iOS world, in order not to go far, a modal window was also added to the implementation of the coordinator, which is displayed in full screen on top of any other window. In the proposed code, it is presented in its simplest form, but it will not be difficult to add shading, partial overlap, or blur to it.

The implementation of ColoredView and ModalView differ only in the buttons in the window title, in accordance with common practice – the “Back” button returns to the previous view, and the button with a cross closes the modal window. Well, logically, the modal window closes when the final action is performed.

A little more interesting, but not much more complicated, is the coordinator class.

First, there is a generic ContainerView structure here.

struct ContainerView : View {
    var view : AnyView

    init<V>(view: V) where V: View {
        self.view = AnyView(view)
    }

    var body: some View {
        view
       }
}

Secondly, the entire coordinator is made as a singleton. Singleton is not the best practice in mobile development, but in this case it allows you to simplify the code to show the really important parts. You can use Dependency Injection (DI), ServiceLocator or EnvirontmentObject to implement it. Any of these methods will work in a similar way.

Thirdly, since we are doing all the manipulations in relation to SwiftUI, it would be unfair not to use MVVM, which easily allows us to bring reactivity to our application through ObservableObject.

hidden text
import SwiftUI

let Coordinator = CoordinatorService.instance

struct ContainerView : View {
    var view : AnyView

    init<V>(view: V) where V: View {
        self.view = AnyView(view)
    }

    var body: some View {
        view
       }
}


final class CoordinatorService: ObservableObject {
    
    enum State {
        case root
        case end
    }
    
    static let instance = CoordinatorService()
    
    @Published var modalVisibled = false
    @Published var modalView : ContainerView!
    @Published var container : ContainerView!

    private var stack  = [ContainerView]()
    
    private init() {
        self.push(view: ColoredView(index: 0))
    }

    func pop() {
        guard self.stack.count > 1 else { return }
        self.stack.remove(at: self.stack.count - 1)
        guard let last = self.stack.last else { return }
        self.container = last
    }
    
    func push<V: View>(view: V) {
        let containered = ContainerView(view: view)
        self.stack += [containered]
        self.container = containered
    }
    
    
    func modal<V: View>(view: V) {
        self.modalView = ContainerView(view: view)
        withAnimation {
            self.modalVisibled.toggle()
        }
    }

    func close() {
        withAnimation {
            self.modalVisibled.toggle()
        }
    }
    
    func next(state: State) {
        switch state {
            case .root :
                self.stack.removeAll()
                self.push(view: ColoredView())
            case.end:
                self.push(view: ColoredView(index: 6))
        }
    }
}

When creating an instance of the class, a view is pushed to the navigation stack, with index 0, which corresponds to the red color in the well-known mnemonic: “Every hunter wants to know where the pheasant sits.” In principle, it does not matter which view to push there. It is only important that there is at least some twist. EmptyView is not very suitable for this role, as it does not have interactive controls.

The “push” and “modal” methods work with a generic view. Each of them takes a view, puts it in a convenient container, and then sets the container to the “observable” variables, which at the same time leads to a change in the navigation windows.

Additionally, the “push” method pushes the container onto the navigation stack. The implementation of the stack can be done in “one hundred to five hundred” ways – the one given is quite obvious. The complimentary method “pop” removes the last element from the stack, after which it updates the last element on the stack through the “container” variable.

Usually, in addition to the “pop” method, the “popToRoot” method is also added. But then it would be difficult to answer the following question: “And what does all this have to do with the coordinator pattern?” That’s just in order to allow you to move to any target view, the method was added next to which we pass our target. We describe the view itself as a choice of states using switch (Please do not confuse it with the state pattern – now it is not meant, although it could also be implemented and replaced with switch. But, within the framework of the article, switch is much easier to understand).

Finally, we need to implement the ContextView from which our application starts. In it, the value of the container variable of the coordinator is displayed as content, and when the modal window is launched, it is displayed on top of the current view.

import SwiftUI

struct ContentView: View {
    @ObservedObject private var coordinator = Coordinator
    
    var body: some View {
        ZStack {
            coordinator.container.view
            if coordinator.modalVisibled {
                coordinator.modalView
                    .transition(.move(edge: .bottom))
            }
        }
    }
}

In general, that’s all. The demystification of the Coordinator is complete. The cherry on top is the visual hierarchy that is available for display in the XCode Debug View Hierarchy. After a full pass through all seven views, the visual hierarchy contains only the last rendered view with a single view controller. Compare with what usually happens when using UIKit.

Xcode Debug View Hierarchy
Xcode Debug View Hierarchy

Implementation details can be discussed at telegram channel.

The source code can be found here on GitHub.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *