SwiftUI Navigation: Part 1 - Infrastructure

The complete guide for app navigation using routers architecture.

Itay Amzaleg
Fiverr Tech
8 min readMay 23, 2024

--

Developing apps with SwiftUI can make architecture both simpler and more complex at times, especially when it comes to managing user navigation.

SwiftUI, with its declarative syntax and state-driven approach, offers a unique way of handling navigation stacks, moving beyond traditional imperative methods and towards a more intuitive, modular, and scalable architecture.

In this comprehensive guide, we dive into the basics of app navigation within SwiftUI, exploring the concept of router architecture, an advanced pattern designed to scale with your application from the ground up.

From the foundational building blocks through more advanced concepts, we’ll uncover the tools and techniques essential to constructing a seamless navigational experience.

If you find the initial infrastructure discussions somewhat elementary, or you’re already well-versed in certain aspects of SwiftUI navigation, feel free to skip to the sections that interest you most or meet your specific needs:

Part 1:

Part 2:

Part 3:

Notice!

In the upcoming articles, we will use APIs that require a minimum deployment target of iOS 17.

App router

The AppRouter class organizes and manages navigation throughout the app, making it easy to switch between screens or states.

To kick things off, we will create an Observable class and name it AppRouter.

@Observable class AppRouter {
}

For the moment, we’ll keep AppRouter barebones.

The next step involves integrating this router into our application and making it accessible throughout the environment.

@main
struct RoutersDemoApp: App {
@Bindable private var appRouter = AppRouter()

var body: some Scene {
WindowGroup {
ContentView()
.environment(appRouter)
}
}
}

Tab bar application

In SwiftUI, constructing a tabbed interface is straightforward and elegant, thanks to the TabView and tabItem() modifier. This structure is common in many iOS applications requiring a navigational framework that separates content into distinct sections accessible via a tab bar.

To start, we’ll create an enum representation for our tabs in our ContentView.

struct ContentView: View {
enum Tab {
case a
case b
case c
case d
}
}

Next we’ll bind the selectedTab state directly to the AppRouter, ensuring that the selected tab state is globally managed.

@Observable class AppRouter {
//MARK: - App states
var selectedTab: ContentView.Tab = .a
}

To make the current tab available to any view in our environment, we will create custom EnvironmentValues and EnvironmentKey for ContentView.Tab.

struct CurrentTabKey: EnvironmentKey {
static var defaultValue: Binding<ContentView.Tab> = .constant(.a)
}
extension EnvironmentValues {
var currentTab: Binding<ContentView.Tab> {
get { self[CurrentTabKey.self] }
set { self[CurrentTabKey.self] = newValue }
}
}

And below is an example of our full ContentView:

struct ContentView: View {
enum Tab {
case a
case b
case c
case d
}

@Environment(AppRouter.self) private var appRouter

//MARK: - Views
var body: some View {
@Bindable var appRouter = appRouter

TabView(selection: $appRouter.selectedTab) {
TabA()
.tag(Tab.a)
.tabItem {
Image(systemName: "a.circle")
}

TabB()
.tag(Tab.b)
.tabItem {
Image(systemName: "b.circle")
}

TabC()
.tag(Tab.c)
.tabItem {
Image(systemName: "c.circle")
}

TabD()
.tag(Tab.d)
.tabItem {
Image(systemName: "d.circle")
}
}
.environment(\.currentTab, $appRouter.selectedTab)
}
}

The provided view demonstrates how to implement a tab bar application. It outlines a typical use case with four tabs, each represented by an enum case. TabView acts as the container for our tabbed interface, We bind its selection — which tracks the currently selected tab — to our global router selectedTab parameter. Then we publish it to the environment using the currentTab key.

Each tab view is tagged with its corresponding enum case and annotated with a tabItem modifier, which specifies the visual representation of the tab in the tab bar. It’s typically an icon, a text label, or both.

Tab bar application

The base router

Routers play a very important role in managing navigation within applications by abstracting the navigation logic from the view layer. This separation of concerns not only simplifies implementation but also enhances the scalability and maintainability of the application. Here are a few key reasons why routers are essential:

  • Centralized navigation logic: Routers centralize navigation decisions, making it easier to understand and modify how users navigate through the app. This is especially beneficial in complex applications with multiple navigation paths.
  • Decoupling views from navigation: By separating navigation logic from view components, routers allow for a more modular design. Views focus on presenting content, while routers handle the transitions between those views, leading to cleaner, more reusable code.
  • Simplified deep linking and state restoration: Routers make it easier to implement deep linking and state restoration by providing clear mapping between URLs or specific app states and their corresponding view states.

Overall, routers help create robust, easy-to-navigate apps by offering a structured approach to handling user flow and interactions.

Now let’s examine the foundational components of a router-based architecture in SwiftUI: the BaseRouter class and the RouterDestination protocol.

The BaseRouter class acts as the backbone of our navigation framework. It wraps SwiftUI’s NavigationPath while introducing essential, reusable navigation functionalities.

@Observable class BaseRouter {
var path = NavigationPath()
var isEmpty: Bool {
return path.isEmpty
}

//MARK: - Public
func navigateBack() {
guard !isEmpty else {
return
}

path.removeLast()
}

func popToRoot() {
path.removeLast(path.count)
}
}

Choosing a NavigationPath instead of relying on bound arrays for path management brings significant benefits. A NavigationPath uses type erasure, enabling you to handle a collection of heterogeneous elements. It also provides familiar collection operations such as adding, counting, and removing data elements.

The RouterDestination protocol complements the BaseRouter, defining the properties and behaviors expected of navigation destinations. It ensures that destinations are Hashable, Identifiable, and Codable, allowing for easy state restoration and deep linking. Through its id and description properties, each destination can be uniquely identified and described.

protocol RouterDestination: Hashable, Identifiable, Equatable, Codable, CustomStringConvertible {
var id: String { get }
var description: String { get }
}

extension RouterDestination {
var id: String {
return description
}
}

extension RouterDestination where Self: RawRepresentable, RawValue == String {
var description: String {
return rawValue
}
}

extension RouterDestination {
static func == (lhs: any RouterDestination, rhs: any RouterDestination) -> Bool {
return lhs.id == rhs.id
}
}

Bonus: backtrack and logging

Since RouterDestination conforms to the Codable protocol, we can use the path’s codableRepresentation property to create a serializable representation of the path. Then, we use that representation to restore its content.

With the remarkable insights provided by Point Free’s article Reverse Engineering SwiftUI’s NavigationPath Codability, we’ll create a RouterDestinationDecoder to help convert the content of our NavigationPath to a RouterDestination array.

extension CodingUserInfoKey {
static let routerDestinationTypes = CodingUserInfoKey(rawValue: "routerDestinationTypes")!
}
struct RouterDestinationDecoder: Decodable {
var path: [any RouterDestination]

init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()

guard let types = decoder.userInfo[.routerDestinationTypes] as? [any RouterDestination.Type] else {
throw DecodingError.dataCorruptedError(in: container,
debugDescription: "No router destination types defined")
}

var path = [any RouterDestination]()
while !container.isAtEnd {
let rawTypeName = try container.decode(String.self)

guard let typeName = rawTypeName.components(separatedBy: ".").last,
let type = types.first(where: { String(describing: $0) == typeName }) else {
throw DecodingError.dataCorruptedError(in: container,
debugDescription: "\(rawTypeName) is not decodable")
}

let encodedValue = try container.decode(String.self)
let value = try JSONDecoder().decode(type, from: Data(encodedValue.utf8))

path.append(value)
}

self.path = path.reversed()
}
}

With this decoder setup, we can update our BaseRouter to include decodedPath, a serialized version of the navigation path. Which in turn allows us to identify the current destination, check if the path includes a specific destination, and monitor changes in the path to log them for debugging.

@Observable class BaseRouter {
var path = NavigationPath()
var isEmpty: Bool {
return path.isEmpty
}

@ObservationIgnored var currentDestination: (any RouterDestination)? {
return decodedPath?.last
}

@ObservationIgnored open var routerDestinationTypes: [any RouterDestination.Type] {
fatalError("BaseRouter: must override routerDestinationTypes in subclass")
}

@ObservationIgnored private lazy var decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.userInfo = [.routerDestinationTypes: routerDestinationTypes]
return decoder
}()

@ObservationIgnored private var decodedPath: [any RouterDestination]? {
guard let codableRepresentation = path.codable,
let data = try? JSONEncoder().encode(codableRepresentation),
let decodedRepresentation = try? decoder.decode(RouterDestinationDecoder.self, from: data) else {
return nil
}

return decodedRepresentation.path
}

//MARK: - Lifecycle
init() {
observePathChanges()
}

//MARK: - Public
func navigateBack() {
guard !isEmpty else {
print("\(String(describing: self)) tried to navigate back on empty path")
return
}

path.removeLast()
}

func popToRoot() {
path.removeLast(path.count)
}

func contains(_ item: any RouterDestination) -> Bool {
guard let decodedPath else {
return false
}

return decodedPath.contains(where: { $0.id == item.id })
}

//MARK: - Private
private func observePathChanges() {
withObservationTracking {
let _ = path
} onChange: {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
guard let self,
let decodedPath else {
return
}

print("➡️ \(String(describing: self)) navigation path changed \(decodedPath)⬅️")
observePathChanges()
}
}
}
}

To accurately decode navigation paths, each subclass of BaseRouter is required to override the routerDestinationTypes method and explicitly list all supported destination types. This explicit listing assists the RouterDestinationDecoder in determining the concrete type behind any RouterDestination type erasure, enabling it to accurately decode destinations from the encoded navigation path data.

Considerations:

  • Implementing logging within BaseRouter offers immediate feedback on navigation changes, which is crucial for debugging and development. While simple print statements are used for this demonstration, integrating a more sophisticated logging framework is advisable.
  • Continuous observation of navigation path changes, especially in a production environment, may impact app performance. It’s recommended to conditionally activate detailed logging for path observation based on the build configuration or logging levels, ensuring that these debug aids are not utilized inefficiently.
Xcode console logs

View extensions

To improve reusability and simplify usage, we’ll introduce some useful view extensions.

These streamline the router navigation setup process on our views.

extension View {
func navigationBackButton(title: String? = nil, action: @escaping () -> Void) -> some View {
navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: action, label: {
HStack(spacing: 0) {
Image(systemName: "chevron.backward")
.fontWeight(.semibold)

if let title {
Text(title)
.foregroundStyle(Color.accentColor)
}
}

})
.frame(minWidth: .navigationBarHeight)
.offset(x: .navigationBackButtonXOffset)
}
}
}

func routerDestination<D, C>(router: BaseRouter,
navigationBackTitle: String? = nil,
@ViewBuilder destination: @escaping (D) -> C) -> some View where D : Hashable, C : View {
navigationDestination(for: D.self) { item in
destination(item)
.navigationBackButton(title: navigationBackTitle, action: router.navigateBack)
}
}
}

The routerDestination extension is responsible for linking the view’s navigation destination provider with the router’s path state. On the other hand, the navigationBackButton is designed to manage the back navigation process explicitly. It updates the navigation path to reflect the current navigation state accurately, addressing a common challenge where the navigation path fails to update automatically when using the native back button functionality.

However, it’s important to note that this solution has a drawback: it disables UINavigationControllers interactivePopGestureRecognizer, which may affect user experience by removing the ability to swipe back. This is a known limitation within our current implementation, and we’re open to better solutions. If you have ideas for improvements, please share them in the comments.

Conclusion

With the foundational components of our router setup now in place, we’ve laid the groundwork for our app navigation system. In the next parts, we’ll expand our exploration to cover various navigation types and learn how to manage global navigation flows. Stay tuned for more insights.

If you’re curious to see the source code to explore in detail before moving on to the next parts, feel free to check out the RoutersDemo project in our GitHub repository.

--

--