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…
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
ContentViewthat gives each tab its own independentNavigationStack - SVG icon support for tab bar icons, both inline-defined and file-based
- An
AppRoutetyped 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.primaryandcolors.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.
ContentViewowns aNavigationStackper tab and switches the active one via aswitchonselectedTab. 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:
**.regularMaterialbackground** 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 ofTabView.- 0.5-pt divider overlay.
colors.dividerfrom 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. **TabBarButtonis private. **It is an implementation detail ofAppTabBar.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 nofill→ color resolves tonil, which inheritstintColorat render timefill=”#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:
- Resolution-independent.
The path scales to any
sizewithout rasterising. - Color-correct.
Each layer’s fill color is applied at draw time, so
tintColoroverrides work without re-encoding the asset. - No UIKit.
Canvasis pureSwiftUI, noUIImage/CGImagebridging.
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()
}
}
}
}
}
}
- One
NavigationStackper tab. TheswitchonselectedTabrenders 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. **.safeAreaInset(edge: .bottom, spacing: 0)** AttachesAppTabBarat the bottom of the safe area. SwiftUI increases the bottom safe area inset by the tab bar’s height, so content in eachNavigationStack, including scroll views, and automatically stops at the top of the bar with no manual padding.**notificationsPath: [AppRoute]** Is an explicitNavigationStackpath for the Notifications tab. This allows programmatic navigation. When a push notification tap arrives (Part 10), the app can setnotificationsPathto[.orderDetail(id: orderId)]to land directly on the correct order detail screen.- The other three tabs use the simplified
NavigationStack { Root() }initialiser, which manages its own internal path state. - Session expired sheet.
.onReceive(tokenManager.sessionExpiredPublisher)presents a non-dismissible re-login sheet whenever the token refresh fails. We buildLoginSheetand 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.
- 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. - 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.
메타데이터
- 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