If you are an iOS Developer looking to master navigation architecture in modern Apple applications, you have come to the right place. Since Apple introduced SwiftUI, the way we structure our apps has evolved drastically. One of the biggest evolutionary leaps was the introduction of NavigationStack, replacing the older and sometimes problematic NavigationView.
In this deep and detailed tutorial, we will thoroughly explore what a NavigationStack is, what a TabView is: how to create a robust architecture using a NavigationStack with TabView in SwiftUI. All of this using Swift programming in Xcode, and ensuring our code is scalable for iOS, macOS, and watchOS.
What is a NavigationStack in SwiftUI?
In the Swift programming ecosystem, navigating between screens is one of the most common tasks. Up until iOS 16, we used NavigationView, which based its routing primarily on view state. However, this presented limitations when trying to perform deep linking or managing the navigation history programmatically.
This is where NavigationStack comes in.
A NavigationStack is a view that displays a root and allows you to append additional views over it. The big difference is that it is Data-Driven. Instead of linking a button directly to a destination view, you bind the navigation to a data value.
Key features of NavigationStack:
- Value-based: You use the
.navigationDestination(for:destination:)modifier to associate a data type with a view. - History management (NavigationPath): You can extract the navigation state into a
NavigationPathobject, which allows you to push multiple views at once, or pop to root with a single line of code. - Performance: It only renders views when they are actually needed in the stack.
What is a TabView in SwiftUI?
A TabView is an interactive container that allows the user to switch between multiple child views, usually presenting a tab bar at the bottom of the screen (on iOS).
As an iOS Developer, you will recognize this pattern in apps like Instagram, X (Twitter), or Apple’s own Clock app. Each tab represents a completely independent context or workflow within the application.
Key features of TabView:
- Context isolation: Each tab works as a silo. What you do in the “Profile” tab shouldn’t visually affect the “Home” tab.
- Customization: You can define icons (using SF Symbols) and text for each tab using the
.tabItemmodifier. - Cross-platform adaptability: A
TabViewin SwiftUI automatically adapts to the platform (iOS, macOS, watchOS).
The Perfect Architecture: NavigationStack with TabView in SwiftUI
This is where many developers who are just starting with SwiftUI make a critical mistake. The question is: Do I put the TabView inside the NavigationStack, or the NavigationStack inside the TabView?
The correct answer (for 99% of standard apps) is: You must place multiple NavigationStack views inside a single TabView.
If you place the TabView inside the NavigationStack, when you navigate to a new screen, the new view will cover the entire screen, hiding the tab bar! By placing an independent NavigationStack in each tab of the TabView, each section of your app maintains its own navigation history, and the bottom tab bar remains always visible, drastically improving the user experience.
Step-by-Step Tutorial: Building our App in Xcode
Let’s create a base application using Swift and SwiftUI in Xcode that demonstrates this architecture.
Step 1: Project Setup in Xcode
- Open Xcode and select Create a new Xcode project.
- Select App under the iOS tab (or Multiplatform if you want to test macOS).
- Name your project (e.g.,
AdvancedNavigationApp), make sure the Interface is SwiftUI and the Language is Swift.
Step 2: Defining our data models
To leverage the power of value-based routing of the NavigationStack, we will create some simple data models.
import SwiftUI
// Model for the Home tab
struct Car: Identifiable, Hashable {
let id = UUID()
let brand: String
let model: String
}
// Model for the Settings tab
struct SettingOption: Identifiable, Hashable {
let id = UUID()
let title: String
let icon: String
}
Step 3: Creating the Destination Views
Before assembling the NavigationStack, we need the views we are going to navigate to.
// Detail View for the Car
struct CarDetailView: View {
let car: Car
var body: some View {
VStack(spacing: 20) {
Image(systemName: "car.fill")
.font(.system(size: 80))
.foregroundColor(.blue)
Text(car.brand)
.font(.largeTitle)
.bold()
Text(car.model)
.font(.title2)
.foregroundColor(.secondary)
}
.navigationTitle(car.brand)
.navigationBarTitleDisplayMode(.inline)
}
}
// Detail View for Settings
struct SettingDetailView: View {
let setting: SettingOption
var body: some View {
VStack {
Image(systemName: setting.icon)
.font(.system(size: 100))
Text("Configuring \(setting.title)")
.font(.title)
.padding()
}
.navigationTitle(setting.title)
}
}
Step 4: Building the Individual Tabs (The NavigationStacks)
This is where the magic of Swift programming meets SwiftUI. Let’s create a view for each tab, each wrapping its content in a NavigationStack.
The Home Tab:
struct HomeTabView: View {
// Sample data
let cars = [
Car(brand: "Tesla", model: "Model S"),
Car(brand: "Porsche", model: "Taycan"),
Car(brand: "Audi", model: "e-tron")
]
var body: some View {
// Here we declare the NavigationStack for this specific tab
NavigationStack {
List(cars) { car in
// Value-based NavigationLink
NavigationLink(value: car) {
VStack(alignment: .leading) {
Text(car.brand).font(.headline)
Text(car.model).font(.subheadline).foregroundColor(.gray)
}
}
}
.navigationTitle("Garage")
// Navigation destination handled centrally
.navigationDestination(for: Car.self) { selectedCar in
CarDetailView(car: selectedCar)
}
}
}
}
The Settings Tab:
struct SettingsTabView: View {
let options = [
SettingOption(title: "Account", icon: "person.circle"),
SettingOption(title: "Privacy", icon: "hand.raised.fill"),
SettingOption(title: "Notifications", icon: "bell.fill")
]
var body: some View {
// A completely independent NavigationStack
NavigationStack {
List(options) { option in
NavigationLink(value: option) {
Label(option.title, systemImage: option.icon)
}
}
.navigationTitle("Settings")
.navigationDestination(for: SettingOption.self) { selectedOption in
SettingDetailView(setting: selectedOption)
}
}
}
}
Step 5: Tying it all together with the Main TabView
Finally, let’s go to our main file (usually ContentView.swift) and put our views together using the TabView.
struct ContentView: View {
var body: some View {
// The main container
TabView {
// Tab 1
HomeTabView()
.tabItem {
Label("Home", systemImage: "house.fill")
}
// Tab 2
SettingsTabView()
.tabItem {
Label("Settings", systemImage: "gearshape.fill")
}
}
// Optional: Change the color of the selected icons
.tint(.blue)
}
}
Understanding Cross-Platform Behavior
As an iOS Developer, your work often transcends the iPhone. One of the great advantages of SwiftUI is its declarative nature that adapts to the underlying operating system. How does this combination of NavigationStack with TabView in SwiftUI behave on other platforms using Xcode?
1. Implementation on iOS and iPadOS
- iPhone: The
TabViewrenders as the classic bottom tab bar. TheNavigationStackanimates views by pushing them from the right. - iPad: Although it defaults to a bottom bar, on iPadOS it’s an excellent practice to use
.tabViewStyle(.sidebar)or wrap it in aNavigationSplitViewto take advantage of the large screen size.
2. Implementation on macOS
- On macOS, the
TabViewusually compiles into a segmented control at the top of the window (Toolbar style). NavigationStacktransitions do not slide in from the right side like on iOS; instead, they use a subtle fade effect or direct view replacement, adhering to the Mac Human Interface Guidelines (HIG).
3. Implementation on watchOS
- On the Apple Watch, the
TabViewwas traditionally implemented using horizontal swipe gestures (.tabViewStyle(.page)). - In recent versions of watchOS (watchOS 10+), Apple redesigned the interface by introducing a vertical
TabView. - The
NavigationStackworks perfectly on the watch, stacking views that the user can dismiss by tapping the back button in the top left corner.
Best Practices when using this Architecture
- State Management (
@Statevs@StateObject): Keep the state that dictates navigation (like theNavigationPathif you are doing programmatic navigation) at the same level or above theNavigationStack, not inside child views, to avoid erratic behaviors. - Keep initializers lightweight: When you use
.navigationDestination, SwiftUI loads destination views lazily. However, make sure not to make heavy network or database calls directly in theinit()of the destination view; instead, do it in.taskor.onAppear(). - Code Modularization: As we did in the tutorial, separate each tab into its own view structure. Do not try to stuff all the code for all tabs inside the same
ContentViewfile. This is vital for code maintenance in Swift programming.
Conclusion
Implementing a NavigationStack with TabView in SwiftUI is the current gold standard for complex applications within the Apple ecosystem. It provides you with the reliability and programmatic control of the NavigationStack (value-based), while maintaining a clean and structured interface through the TabView.
If you have any questions about this article, please contact me and I will be happy to help you . You can contact me on my X profile or on my Instagram profile.