-
Notifications
You must be signed in to change notification settings - Fork 2
Navigation
A comprehensive demonstration of modern SwiftUI navigation patterns using TabView, multiple NavigationStacks, programmatic navigation, and type-safe routing. This project showcases an opinionated approach to building scalable navigation in SwiftUI applications.
This sample project demonstrates advanced SwiftUI navigation techniques that solve common navigation challenges in complex applications:
- TabView with Multiple NavigationStacks - Each tab has its own independent NavigationStack and navigation state
- Type-safe Navigation with enum-based destinations
- Programmatic Navigation using a centralized router
- Protocol-based Routing for feature isolation and testability
- Single Source of Truth: The Router manages all navigation state for each tab
- Type Safety: Compile-time checking of navigation destinations
- Dependency Inversion: Features depend on routing protocols, not concrete implementations
- Testability: Easy mocking and unit testing of navigation logic
- Scalability: Clean separation of concerns for large applications
- State Retention: Each tab retains its navigation stack when switching tabs
SwiftUI navigation with multiple NavigationLink
s scattered throughout the view hierarchy can become difficult to manage as applications grow. This approach provides:
- Centralized Control: All navigation logic in one place
- Predictable State: Clear navigation state management
- Better Testing: Isolated navigation logic for unit tests
- Feature Isolation: Each feature defines its own routing needs
- Independent Tab Navigation: Each tab maintains its own navigation history, improving user experience
enum Destination: Hashable {
case home
case contactList
case conversation(Contact)
case contactDetail(Contact)
case profile_settings
case privacy_settings
}
The Destination
enum defines all possible navigation states in a type-safe manner. Each case can carry associated data (like Contact
objects) and conforms to Hashable
for use with NavigationStack
.
Key Benefits:
- Compile-time safety for navigation destinations
- Associated values for passing data through navigation
- Hashable conformance enables use with NavigationStack's path binding
@Observable
final class Router: ContactRouter, ChatRouter, SettingsRouter {
var selectedTab: Tabs = .chats
var chatTabPath: [Destination] = []
var settingsTabPath: [Destination] = []
// ... navigation methods for each tab ...
}
The Router
class serves as the single source of truth for navigation state. It manages a separate navigation path for each tab (e.g., chatTabPath
, settingsTabPath
) and provides methods for programmatic navigation. While the implementation of routing logic is centralized, the interfaces for routing are defined by features, improving feature isolation.
protocol ContactRouter {
func gotoConversation(recipient: Contact)
func gotoContactDetail(_ contact: Contact)
}
Each feature defines its own routing protocol, allowing for:
- Feature Isolation: Features don't depend on concrete router implementation
- Testability: Easy to mock routers for unit tests
- Dependency Inversion: Features depend on abstractions, not concretions
Key Features:
- Single source of truth for navigation state
- Programmatic control over navigation stack
- Protocol conformance for feature-specific routing
struct ContentView: View {
@Environment(Router.self) var router
var body: some View {
@Bindable var router = router
TabView(selection: $router.selectedTab) {
Tab(
Tabs.chats.name,
systemImage: Tabs.chats.systemImageName,
value: Tabs.chats
) {
NavigationStack(path: $router.chatTabPath) {
HomeScreen(router: router)
.navigationDestination(for: Destination.self) { dest in
RouterView(router: router, destination: dest)
}
}
}
Tab(
Tabs.settings.name,
systemImage: Tabs.settings.systemImageName,
value: Tabs.settings
) {
NavigationStack(path: $router.settingsTabPath) {
SettingsHomeView(router: router)
.navigationDestination(for: Destination.self) { dest in
RouterView(router: router, destination: dest)
}
}
}
}
}
}
The main navigation container uses a TabView
with a separate NavigationStack
for each tab, each bound to its own navigation path in the router. This allows each tab to maintain its own navigation history independently.
Key Benefits:
- Independent navigation stacks for each tab
- Type-safe navigation with
navigationDestination
- Automatic state synchronization and retention when switching tabs
struct RouterView: View {
let router: Router
let destination: Destination
var body: some View {
switch destination {
case .home:
HomeScreen(router: router)
case .conversation(let recipient):
ConversationView(router: router, contact: recipient)
case .contactDetail(let contact):
ContactDetailView(router: router, contact: contact)
case .contactList:
ContactFeatureRootView(router: router)
case .profile_settings:
ProfileSettingsView()
case .privacy_settings:
PrivacySettingsView()
}
}
}
The RouterView
acts as a switch statement that maps destinations to their corresponding views, passing the router and any associated data.
Key Benefits:
- Clean separation of navigation logic from view logic
- Centralized view routing
- Easy to maintain and extend
Views can trigger navigation programmatically by calling router methods:
// Navigate to contact detail
Button("View Contact") {
router.gotoContactDetail(contact)
}
// Navigate to conversation
Button("Start Chat") {
router.gotoConversation(recipient: contact)
}
// Navigate to contacts list
Button("Add Contact") {
router.gotoContactsList()
}
The sample app demonstrates a chat application with the following navigation flows:
- Home Screen → Shows chat list with navigation to contacts
- Contact List → Displays contacts in table or list format
- Contact Detail → Shows detailed contact information
- Conversation → Chat interface with the selected contact
- Settings → Profile and privacy settings, each with their own navigation stack
Flow 1: Enter Existing Conversation
Chats Tab → Home → Conversation → Contact Detail
Flow 2: Start New Conversation
Chats Tab → Home → Contact List → Contact Detail → Conversation
Flow 3: Settings Navigation
Settings Tab → Settings Home → Profile Settings / Privacy Settings
Each tab maintains its own navigation stack, allowing users to switch tabs and return to their previous navigation state within each tab.
- TabView with Multiple NavigationStacks: Use a TabView at the root, with a separate NavigationStack for each tab
- Type Safety: Always use enum-based destinations
- Protocol Dependencies: Features should depend on protocols, not concrete types
- Centralized Router: Keep all navigation logic in one place
- Testability: Design for easy testing with mock implementations
- State Retention: Let each tab maintain its own navigation history for a better user experience
- Clone the repository
- Open
SwiftUINavigationSample.xcodeproj
in Xcode - Build and run on your target platform
- Explore the navigation patterns by tapping through the app
- Examine the code to understand the implementation
// Navigate to contacts list
router.gotoContactsList()
// Navigate to specific contact detail
router.gotoContactDetail(contact)
// Navigate to conversation with contact
router.gotoConversation(recipient: contact)
// Reset navigation to home
router.chatTabPath = []
- Add a new case to the
Destination
enum - Add navigation methods to the
Router
class - Update the
RouterView
switch statement - Create the corresponding view
This sample demonstrates modern SwiftUI navigation best practices that can be adapted for your own applications. Feel free to use this as a reference for implementing scalable navigation in your SwiftUI projects.
This sample demonstrates an opinionated approach to SwiftUI navigation that prioritizes type safety, testability, and scalability. For more details on the design philosophy, see the accompanying blog post: Building Scalable SwiftUI Navigation.