← Back to list

Building a Production iOS App with SwiftUI — Part 3: Navigation, Tab Bar & Typed Routes

In the previous article, we built the design system, the token palette, the environment propagation mechanism, and ThemeManager. Every…

Russel Rajitha · 2026-05-23 15:37 · 30 claps · 10.2 min read
#ios #swiftui #swinject #navigation #typed-navigation
Open on Medium ↗
Wiki topics: PRD · Product Design 📱 · Mobile Development

Building a Production iOS App with SwiftUI — Part 3: Navigation, Tab Bar & Typed Routes

In the previous article, we built the design system, the token palette, the environment propagation mechanism, and ThemeManager. Every component we build from here on reads colors from @Environment(\.appColors) and adapts automatically.

This part adds navigation. A production app has two distinct navigation axes. The first one is switching between top-level sections (tabs) and drilling deeper within a section (push navigation). We handle both with distinct, well-typed mechanisms.

By the end of this article, you will have:

  • A custom bottom tab bar that integrates with the safe area and token palette
  • A ContentView that gives each tab its own independent NavigationStack
  • SVG icon support for tab bar icons, both inline-defined and file-based
  • An AppRoute typed enum that replaces string-based destination names
  • The navigationDestination(for:) wiring that connects route values to screens

Why a custom tab bar instead of “TabView”?

SwiftUI’s built-in TabView is quick to add but limited in control. It renders its own tab bar with fixed styling, manages NavigationStack paths internally, and exposes little surface area for customization. You cannot use arbitrary icons, override the active/inactive tint per token, or control the bar’s background material.

The custom approach costs about 60 lines of code and gives complete control:

  • Token colors. Active and inactive tints come from colors.primary and colors.textSecondary.
  • SVG icons. Any icon from the design system (or UI designer), not limited to SF Symbols. This is a frustrating situation every developer faces in industrial apps, and fully customizable SVG icons will take away your pain.
  • Independent stack state. ContentView owns a NavigationStack per tab and switches the active one via a switch on selectedTab. History in the cart tab survives while you browse the shop tab.
  • Predictable layout. .safeAreaInset(edge: .bottom) attaches the bar to the safe area edge without obscuring content, the system adjusts the safe area inset so scroll views automatically stop above the bar.

The only meaningful tradeoff is the accessibility badge API (tabItem + .badge) that TabView provides for free. We do not use it in this app, so the trade is clean.

Step 1: The AppTab enum

AppTab is the single source of truth for what tabs exist, in what order, and what each one looks like. Create Navigation/AppTab.swift:

import SwiftUI

enum AppTab: Int, CaseIterable {
    case shop
    case cart
    case notifications
    case profile

    var title: String {
        switch self {
        case .shop:          "Shop"
        case .cart:          "Cart"
        case .notifications: "Notifications"
        case .profile:       "Profile"
        }
    }

    var icon: SVGIconDefinition {
        switch self {
        case .shop:          SVGFileLoader.load(named: "tab-shop")
        case .cart:          SVGFileLoader.load(named: "tab-cart")
        case .notifications: SVGFileLoader.load(named: "tab-notifications")
        case .profile:       SVGFileLoader.load(named: "tab-person")
        }
    }
}

Int raw values give AppTab an inherent ordering that matches the on-screen position left-to-right. CaseIterable let’s AppTabBar iterate over all cases with ForEach(AppTab.allCases), adding a fifth tab means adding one case to this enum; everything else updates automatically.

The icon property returns an SVGIconDefinition loaded from an .svg file in the app bundle. We cover that loading mechanism in Step 3.

Step 2: The AppTabBar component

Create UI/Components/AppTabBar/AppTabBar.swift:

import SwiftUI

struct AppTabBar: View {
    @Binding var selectedTab: AppTab
    @Environment(\.appColors) private var colors

    var body: some View {
        HStack(spacing: 0) {
            ForEach(AppTab.allCases, id: \.self) { tab in
                TabBarButton(
                    tab: tab,
                    isSelected: selectedTab == tab,
                    colors: colors
                ) {
                    selectedTab = tab
                }
            }
        }
        .padding(.top, 10)
        .padding(.bottom, 6)
        .background {
            Rectangle()
                .fill(.regularMaterial)
                .ignoresSafeArea(edges: .bottom)
        }
        .overlay(alignment: .top) {
            Rectangle()
                .fill(colors.divider)
                .frame(height: 0.5)
        }
    }
}

private struct TabBarButton: View {
    let tab: AppTab
    let isSelected: Bool
    let colors: AppColorTokens
    let action: () -> Void

    var body: some View {
        Button(action: action) {
            VStack(spacing: 4) {
                SVGIcon(
                    tab.icon,
                    size: 24,
                    tintColor: isSelected ? colors.primary : colors.textSecondary
                )
                Text(tab.title)
                    .font(.system(size: 10, weight: isSelected ? .semibold : .regular))
                    .foregroundStyle(isSelected ? colors.primary : colors.textSecondary)
            }
            .frame(maxWidth: .infinity)
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
        .animation(.easeInOut(duration: 0.15), value: isSelected)
    }
}

A few implementation details worth understanding:

  • **.regularMaterial background** Using a system material gives the tab bar a frosted-glass look that adapts to light and dark mode without any custom code. .ignoresSafeArea(edges: .bottom) lets the material extend behind the home indicator on iPhone, matching the visual treatment of TabView.
  • 0.5-pt divider overlay. colors.divider from the token palette draws a fine line at the top of the tab bar. At 0.5 points, it sits below the threshold for hairline rendering on all retina densities without feeling too bold.
  • **TabBarButton is private. **It is an implementation detail of AppTabBar.No other file needs to construct one directly. Keeping it private prevents accidental coupling.
  • **.contentShape(Rectangle()) ** expands the tap target to the full cell width, not just the icon and label. Without this, tapping the space between the icon and the edge of the cell misses the button.
  • **.animation(.easeInOut(duration: 0.15), value: isSelected) **animates the active/inactive tint transition smoothly when the user switches tabs.

Step 3: SVG icons

The tab bar uses custom SVG icons rather than SF Symbols. There are two ways to define an SVG icon in this app.

An inline icon is defined in Swift code using SVGIconDefinition and one or more SVGIconLayer values. Each layer takes an SVG path d= string and an optional per-layer tint color.

SVGIconDefinition(layers: [
    SVGIconLayer(pathData: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z")
])

The SVGIcon view renders the definition with a Canvas, scaling the viewBox to the requested size and applying the tintColor to any layer whose color is nil:

SVGIcon(myIcon, size: 28, tintColor: colors.primary)

This approach is useful for icons defined programmatically or constructed from shape factories:

// Factory helpers for common SVG primitives
SVGIconLayer.rect(x: 2, y: 2, width: 20, height: 20, rx: 4)
SVGIconLayer.circle(cx: 12, cy: 12, r: 4)
SVGIconLayer.line(x1: 4, y1: 4, x2: 20, y2: 20, strokeWidth: 2)

File-based: SVGFileIcon

For standard .svg files from a design tool, drop the file into the Xcode project (as a plain file resource, not inside Assets.xcassets) and reference it by name:

SVGFileIcon("tab-shop", size: 24, tintColor: colors.primary)

SVGFileLoader parses the XML file using XMLParser, converts each shape element (path, rect, circle, ellipse, line, polyline, polygon) into an SVGIconLayer, and caches the result so subsequent uses of the same name are free.

Color resolution follows the SVG spec:

  • fill=”currentColor” or no fill → color resolves to nil, which inherits tintColor at render time
  • fill=”#RRGGBB” → The color is baked into the layer

This means an icon with fill=”currentColor” automatically responds to the active/inactive tint in the tab bar without any special handling.

The Canvas renderer

SVGIcon renders using Canvas, not Image. This matters for three reasons:

  1. Resolution-independent. The path scales to any size without rasterising.
  2. Color-correct. Each layer’s fill color is applied at draw time, so tintColor overrides work without re-encoding the asset.
  3. No UIKit. Canvas is pureSwiftUI, no UIImage/CGImage bridging.

The renderer scales the icon’s viewBox to fit the requested frame, translates to center it, and then fills each layer in order:

Canvas { ctx, canvasSize in
    let scale = min(
        canvasSize.width  / icon.viewBox.width,
        canvasSize.height / icon.viewBox.height
    )
    let tx = (canvasSize.width  - icon.viewBox.width  * scale) / 2
    let ty = (canvasSize.height - icon.viewBox.height * scale) / 2
    let transform = CGAffineTransform(translationX: tx, y: ty)
        .scaledBy(x: scale, y: scale)

    for layer in icon.layers {
        let scaledPath = layer.path.applying(transform)
        ctx.fill(scaledPath, with: .color(layer.color ?? tintColor), style: layer.fillStyle)
    }
}
.frame(width: size, height: size)

Step 4: “AppRoute” typed push navigation

Create Navigation/AppRoute.swift:

import Foundation

enum AppRoute: Hashable {
    case configurations
    case categoryDetail(id: String, name: String)
    case productDetail(id: String)
    case orderDetail(id: String)
    case orders
}

AppRoute is the typed vocabulary for within-tab push navigation. Every destination in the app is a case of this enum. Hashable conformance is required by NavigationStack . It uses the route value as a key to manage the stack.

Compare this to a string-based approach:

NavigationLink("product-detail?id=\(item.id)") { ... }

// Typed — compile-time safe, associated values carry the payload
NavigationLink(value: AppRoute.productDetail(id: item.id)) { ... }

Associated values replace query parameters. The compiler verifies that id: item.id is a String. If you rename the case or add a required parameter, every call site becomes a compile error, not a runtime crash.

Step 5: “ContentView” one stack per tab

Update ContentView.swift to give each tab its own NavigationStack:

import SwiftUI

struct ContentView: View {
    @StateObject private var themeManager = AppContainer.shared.container.resolve(ThemeManager.self)!
    @State private var selectedTab: AppTab = .shop
    @State private var showSessionExpiredLogin = false
    @State private var notificationsPath: [AppRoute] = []

    private let tokenManager = AppContainer.shared.container.resolve(TokenManager.self)!

    var body: some View {
        tabContent
            .safeAreaInset(edge: .bottom, spacing: 0) {
                AppTabBar(selectedTab: $selectedTab)
            }
            .environmentObject(themeManager)
            .preferredColorScheme(themeManager.colorScheme)
            .applyAppColors()
            .sheet(isPresented: $showSessionExpiredLogin) {
                LoginSheet(isPresented: $showSessionExpiredLogin, canDismiss: false)
                    .environmentObject(themeManager)
                    .preferredColorScheme(themeManager.colorScheme)
                    .applyAppColors()
            }
            .onReceive(tokenManager.sessionExpiredPublisher) { _ in
                showSessionExpiredLogin = true
            }
            .onAppear {
                if let url = DeepLinkManager.shared.drainPendingURL() {
                    handleDeepLink(url)
                }
            }
            .onOpenURL { url in
                handleDeepLink(url)
            }
            .onReceive(DeepLinkManager.shared.publisher) { url in
                _ = DeepLinkManager.shared.drainPendingURL()
                handleDeepLink(url)
            }
    }

    @ViewBuilder
    private var tabContent: some View {
        switch selectedTab {
        case .shop:
            NavigationStack { HomeScreen() }
        case .cart:
            NavigationStack { CartScreen() }
        case .notifications:
            NavigationStack(path: $notificationsPath) { NotificationsScreen() }
        case .profile:
            NavigationStack {
                ProfileScreen()
                    .navigationDestination(for: AppRoute.self) { route in
                        switch route {
                        case .orders:
                            OrdersScreen()
                        case .orderDetail(let id):
                            OrderDetailScreen(orderId: id)
                        default:
                            EmptyView()
                        }
                    }
            }
        }
    }
}
  1. One NavigationStack per tab. The switch on selectedTab renders only the active tab’s stack. Switching tabs does not pop the navigation history of inactive tabs, a user who drills into a product detail on the Shop tab can switch to Cart and switch back without losing their place.
  2. **.safeAreaInset(edge: .bottom, spacing: 0)** Attaches AppTabBar at the bottom of the safe area. SwiftUI increases the bottom safe area inset by the tab bar’s height, so content in each NavigationStack , including scroll views, and automatically stops at the top of the bar with no manual padding.
  3. **notificationsPath: [AppRoute]** Is an explicit NavigationStack path for the Notifications tab. This allows programmatic navigation. When a push notification tap arrives (Part 10), the app can set notificationsPath to [.orderDetail(id: orderId)] to land directly on the correct order detail screen.
  4. The other three tabs use the simplified NavigationStack { Root() } initialiser, which manages its own internal path state.
  5. Session expired sheet. .onReceive(tokenManager.sessionExpiredPublisher) presents a non-dismissible re-login sheet whenever the token refresh fails. We build LoginSheet and the auth flow in Part 5, and for now, the pattern is wired so it will work without changes once the auth layer exists.

Step 6: navigationDestination in screens

Declaring routes is only half the job. Each NavigationStack root also needs to declare what view to render for each route value. This is done with .navigationDestination(for:).

**HomeScreen handles three routes**

HomeScreen is the root of the Shop tab. It handles configurations, categoryDetail, and productDetail because all three can be reached from within the shop flow:

struct HomeScreen: View {
    var body: some View {
        ScrollView { /* ... */ }
            .navigationTitle("Shop")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    NavigationLink(value: AppRoute.configurations) {
                        Image(systemName: "gearshape")
                    }
                }
            }
            .navigationDestination(for: AppRoute.self) { route in
                switch route {
                case .configurations:
                    ConfigurationsScreen()
                case .categoryDetail(let id, let name):
                    CategoryProductsScreen(categoryId: id, categoryName: name)
                case .productDetail(let id):
                    ProductDetailScreen(productId: id)
                default:
                    EmptyView()
                }
            }
    }
}

The default: EmptyView() catch-all handles any route the Shop tab does not own, orders and orderDetail are Profile-tab routes and will never be pushed here in practice.

Pushing a route with NavigationLink

Inside the categories grid, each card is wrapped in a NavigationLink that carries an AppRoute value:

NavigationLink(value: AppRoute.categoryDetail(id: category.id, name: category.name)) {
    AppCategoryCard(title: category.name, imageURL: category.icon)
}
.buttonStyle(.plain)

When tapped, SwiftUI pushes the value onto the active NavigationStack path, which triggers the navigationDestination handler to return CategoryProductsScreen. The associated values id and name flow directly into the screen’s initialiser, no global state, no environment lookup, no string parsing.

.buttonStyle(.plain) prevents SwiftUI from adding the default list-row highlight style to the card. Without it, the card gets an unwanted grey press state.

**ProfileScreen orders and order detail**

The Profile tab’s navigationDestination is declared in ContentView (rather than ProfileScreen) because ContentView owns the NavigationStack for that tab:

case .profile:
    NavigationStack {
        ProfileScreen()
            .navigationDestination(for: AppRoute.self) { route in
                switch route {
                case .orders:
                    OrdersScreen()
                case .orderDetail(let id):
                    OrderDetailScreen(orderId: id)
                default:
                    EmptyView()
                }
            }
    }

Inside ProfileScreen, the link to orders needs no special treatment:

NavigationLink(value: AppRoute.orders) {
    Label("My Orders", systemImage: "bag")
}

The value AppRoute.orders has no associated data, and it is a destination, not a parameterised request. The navigationDestination switch matches it and returns OrdersScreen().

Step 7: Programmatic navigation and DeepLinkManager

Sometimes the app needs to navigate without user input, which means a push notification tap should land on a specific order detail screen without requiring the user to navigate there manually.

**DeepLinkManager: a Combine bus for URLs**

The app uses a custom DeepLinkManager singleton to route URLs from any source (push notification tap, URL-scheme open) into the active SwiftUI scene. Create Push/DeepLinkManager.swift

import Combine
import Foundation

final class DeepLinkManager {
    static let shared = DeepLinkManager()
    private init() {}

    private let subject = PassthroughSubject<URL, Never>()

    /// Buffered URL for killed-state launches; nil once consumed.
    private(set) var pendingURL: URL?

    var publisher: AnyPublisher<URL, Never> { subject.eraseToAnyPublisher() }

    func send(_ url: URL) {
        pendingURL = url
        subject.send(url)
    }

    func drainPendingURL() -> URL? {
        defer { pendingURL = nil }
        return pendingURL
    }
}

send(_:) does two things.

  1. Stores the URL as pendingURL,a buffer for the case where the app is launched from a killed state, so the URL isn’t lost before SwiftUI has subscribed.
  2. Broadcasts it to any live Combine subscribers, delivers the URL directly for background→foreground taps where the subscriber is already listening.

**handleDeepLink and the three entry points**

ContentView handles deep links from three entry points, which are killed-state launch, URL-scheme open, and the foreground/background publisher.

.onAppear {
    if let url = DeepLinkManager.shared.drainPendingURL() {
        handleDeepLink(url)
    }
}
.onOpenURL { url in
    handleDeepLink(url)
}
.onReceive(DeepLinkManager.shared.publisher) { url in
    _ = DeepLinkManager.shared.drainPendingURL()
    handleDeepLink(url)
}

.onAppear drains any URL that arrived before SwiftUI subscribed. .onOpenURL handles the system calling application(_:open:options:) for app:// scheme links. .onReceive(DeepLinkManager.shared.publisher) handles URLs delivered while the app is live, it also calls drainPendingURL() to clear the buffer and prevent double-handling if .onAppear and the publisher both fire for the same URL.

The handler itself maps host → tab and extracts any path arguments:

private func handleDeepLink(_ url: URL) {
    guard url.scheme == "app" else { return }
    switch url.host {
    case "home", "shop":
        selectedTab = .shop
    case "cart":
        selectedTab = .cart
    case "notifications":
        selectedTab = .notifications
        let orderId = url.pathComponents.dropFirst().first
        notificationsPath = orderId.map { [.orderDetail(id: $0)] } ?? []
    case "profile":
        selectedTab = .profile
    default:
        break
    }
}

Setting selectedTab switches the visible tab. For the notifications case, setting notificationsPath to [.orderDetail(id: orderId)] immediately pushes the order detail screen onto the Notifications stack’s history, the user lands directly on the correct order without any intermediate navigation.

This is why the path is [AppRoute] rather than a plain integer offset, it is a navigation history expressed as typed values, not a cursor position. The deep link format used by the server is app://notifications/{orderId}. In upcoming articles, I will show how AppDelegate calls DeepLinkManager.shared.send(url) when a notification is tapped.

The complete navigation structure

ContentView
├── tabContent (switches on selectedTab)
│   ├── .shop   → NavigationStack
│   │                └── HomeScreen
│   │                    └── navigationDestination:
│   │                        .configurations     → ConfigurationsScreen
│   │                        .categoryDetail     → CategoryProductsScreen
│   │                        .productDetail      → ProductDetailScreen
│   │
│   ├── .cart   → NavigationStack
│   │                └── CartScreen
│   │
│   ├── .notifications → NavigationStack(path: $notificationsPath)
│   │                        └── NotificationsScreen
│   │
│   └── .profile → NavigationStack
│                      └── ProfileScreen
│                          └── navigationDestination:
│                              .orders            → OrdersScreen
│                              .orderDetail(id:)  → OrderDetailScreen
│
└── AppTabBar (safeAreaInset bottom)
    └── selectedTab binding → switches tabContent

Each tab is fully isolated. Routes pushed in the Shop tab cannot bleed into the Profile tab. The type system enforces which routes are valid, pushing AppRoute.orders on the Shop tab reaches the default: EmptyView() catch-all, not OrdersScreen.

Github Repo


메타데이터
post_id
26aa8cec524b
slug
building-a-production-ios-app-with-swiftui-part-3-navigation-tab-bar-typed-routes-26aa8cec524b
url
https://medium.com/@russelrajitha/building-a-production-ios-app-with-swiftui-part-3-navigation-tab-bar-typed-routes-26aa8cec524b
canonical_url
https://medium.com/@russelrajitha/building-a-production-ios-app-with-swiftui-part-3-navigation-tab-bar-typed-routes-26aa8cec524b
author_url
https://medium.com/@russelrajitha
status
ok
fetched_at
2026-06-09 15:37:30