Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 023 / 100

TabView in SwiftUI: Swift & iOS best practices explained

Top-level app navigation.

Expona Admin9 min read

TabView in SwiftUI: Swift & iOS best practices explained. Top-level app navigation.

Tab-based navigation sits at the heart of most modern iOS apps

Whether you're building an e-commerce platform, a ride-hailing app, or a SaaS dashboard on iOS, you'll likely need a clean way to let users switch between major sections of your app. TabView in SwiftUI provides exactly that—a declarative, composable approach to top-level app navigation. In this guide, we'll walk through TabView architecture, show you how to structure each tab with its own NavigationStack, implement badge counts for notifications, and adapt your tabs to work beautifully on both iPhone and iPad.

TabView with tab items

The basic TabView is straightforward: it displays a horizontal row of tabs at the bottom (on iPhone) and switches between views when the user taps. Each tab item is marked with a label and optional icon. This forms the backbone of top-level app navigation in SwiftUI.

Let's say you're building a ride-hailing app with Home, Bookings, and Profile sections. Here's how you'd structure it:

swift
import SwiftUI

struct MainApp: View {
    @State private var selectedTab = 0

    var body: some View {
        TabView(selection: $selectedTab) {
            HomeView()
                .tabItem {
                    Label("Home", systemImage: "house.fill")
                }
                .tag(0)

            BookingsView()
                .tabItem {
                    Label("Bookings", systemImage: "calendar")
                }
                .tag(1)

            ProfileView()
                .tabItem {
                    Label("Profile", systemImage: "person.fill")
                }
                .tag(2)
        }
    }
}

struct HomeView: View {
    var body: some View {
        Text("Home Tab Content")
    }
}

struct BookingsView: View {
    var body: some View {
        Text("Bookings Tab Content")
    }
}

struct ProfileView: View {
    var body: some View {
        Text("Profile Tab Content")
    }
}

The @State variable tracks which tab is currently selected. The .tabItem modifier defines what appears in the tab bar, and .tag() binds each view to a unique identifier. When a user taps a tab, SwiftUI updates selectedTab, triggering a re-render of the active view.

In production, you'll often extract tabs into separate files and use an enum to manage state more cleanly:

swift
enum MainTab: Int, CaseIterable {
    case home = 0
    case bookings = 1
    case profile = 2

    var label: String {
        switch self {
        case .home: return "Home"
        case .bookings: return "Bookings"
        case .profile: return "Profile"
        }
    }

    var icon: String {
        switch self {
        case .home: return "house.fill"
        case .bookings: return "calendar"
        case .profile: return "person.fill"
        }
    }
}

struct MainApp: View {
    @State private var selectedTab: MainTab = .home

    var body: some View {
        TabView(selection: $selectedTab) {
            ForEach(MainTab.allCases, id: \.self) { tab in
                Group {
                    switch tab {
                    case .home:
                        HomeView()
                    case .bookings:
                        BookingsView()
                    case .profile:
                        ProfileView()
                    }
                }
                .tabItem {
                    Label(tab.label, systemImage: tab.icon)
                }
                .tag(tab)
            }
        }
    }
}

This enum-based approach scales better as your app grows and makes it easier to manage tab configuration in one place.

Each tab with its own NavigationStack

In real applications, each tab often has its own navigation hierarchy. Your Home tab might drill down into ride details; your Bookings tab might show booking history then a specific booking; your Profile tab might have settings and account screens. To preserve each tab's navigation state independently, wrap each tab's root view in its own NavigationStack.

Here's how this works in practice:

swift
struct MainApp: View {
    @State private var selectedTab: MainTab = .home

    var body: some View {
        TabView(selection: $selectedTab) {
            NavigationStack {
                HomeView()
            }
            .tabItem {
                Label("Home", systemImage: "house.fill")
            }
            .tag(MainTab.home)

            NavigationStack {
                BookingsView()
            }
            .tabItem {
                Label("Bookings", systemImage: "calendar")
            }
            .tag(MainTab.bookings)

            NavigationStack {
                ProfileView()
            }
            .tabItem {
                Label("Profile", systemImage: "person.fill")
            }
            .tag(MainTab.profile)
        }
    }
}

struct HomeView: View {
    @State private var path: [HomeDestination] = []

    var body: some View {
        NavigationStack(path: $path) {
            List {
                NavigationLink(value: HomeDestination.rideDetails(id: "123")) {
                    Text("Available Ride")
                }
            }
            .navigationTitle("Home")
            .navigationDestination(for: HomeDestination.self) { destination in
                switch destination {
                case .rideDetails(let id):
                    RideDetailView(id: id)
                }
            }
        }
    }
}

enum HomeDestination: Hashable {
    case rideDetails(id: String)
}

The key insight: each TabView child has its own NavigationStack with its own path state. When you switch tabs, your navigation stack is preserved. If you navigate three levels deep in the Home tab and then switch to Bookings, the Home tab's navigation state remains in memory. When you return to Home, you're back where you left off.

Without separate NavigationStacks per tab, users would lose their place every time they switch tabs, which destroys the user experience.

Badges for counts

Notification counts and unread message badges are essential for guiding user attention. SwiftUI's TabView supports the .badge() modifier to display badge values directly on tab items.

For an e-commerce or SaaS app, you might show the number of pending orders or unread notifications:

swift
struct MainApp: View {
    @State private var selectedTab: MainTab = .home
    @StateObject private var notificationManager = NotificationManager()

    var body: some View {
        TabView(selection: $selectedTab) {
            NavigationStack {
                HomeView()
            }
            .tabItem {
                Label("Home", systemImage: "house.fill")
            }
            .badge(notificationManager.unreadCount > 0 ? notificationManager.unreadCount : 0)
            .tag(MainTab.home)

            NavigationStack {
                BookingsView()
            }
            .tabItem {
                Label("Bookings", systemImage: "calendar")
            }
            .badge(notificationManager.pendingBookings)
            .tag(MainTab.bookings)

            NavigationStack {
                ProfileView()
            }
            .tabItem {
                Label("Profile", systemImage: "person.fill")
            }
            .tag(MainTab.profile)
        }
    }
}

class NotificationManager: ObservableObject {
    @Published var unreadCount = 0
    @Published var pendingBookings = 0

    func startListening() {
        // Connect to real-time service (WebSocket, Firebase, etc.)
        // Update @Published properties as data arrives
    }
}

The badge appears as a red circle with white text in the tab bar. It's visible even when the user is on a different tab, making it an effective way to surface activity. If the count is zero, pass 0 to hide the badge entirely.

For production apps dealing with WebSocket streams or Kafka message consumers, bind your badge counts to a published property and update it as real-time events arrive. This ensures users always see current data without manual refresh.

iPad users expect a different layout. Rather than cramped tabs at the bottom, a sidebar on the left gives more breathing room and better visibility of tab labels. SwiftUI's TabView adapts automatically on larger screens, but you can take finer control with the .tabViewStyle() modifier.

Here's how to make your tabs work beautifully on both iPhone and iPad:

swift
struct MainApp: View {
    @State private var selectedTab: MainTab = .home
    @Environment(\.horizontalSizeClass) var sizeClass

    var body: some View {
        TabView(selection: $selectedTab) {
            NavigationStack {
                HomeView()
            }
            .tabItem {
                Label("Home", systemImage: "house.fill")
            }
            .tag(MainTab.home)

            NavigationStack {
                BookingsView()
            }
            .tabItem {
                Label("Bookings", systemImage: "calendar")
            }
            .tag(MainTab.bookings)

            NavigationStack {
                ProfileView()
            }
            .tabItem {
                Label("Profile", systemImage: "person.fill")
            }
            .tag(MainTab.profile)
        }
        .tabViewStyle(sizeClass == .compact ? .automatic : .sidebarAdaptable)
    }
}

The sidebarAdaptable style moves tabs to a sidebar on iPad in landscape, while keeping them at the bottom on iPhone. On iPad in portrait, it may use a centered tab bar or sidebar depending on iOS version.

If you need even more control, you can build a custom sidebar yourself using a NavigationSplitView:

swift
struct MainApp: View {
    @State private var selectedTab: MainTab = .home
    @Environment(\.horizontalSizeClass) var sizeClass

    var body: some View {
        if sizeClass == .compact {
            // iPhone layout
            TabView(selection: $selectedTab) {
                // ... tabs as before ...
            }
        } else {
            // iPad layout
            NavigationSplitView {
                List(MainTab.allCases, id: \.self, selection: $selectedTab) { tab in
                    NavigationLink(value: tab) {
                        Label(tab.label, systemImage: tab.icon)
                    }
                }
                .navigationTitle("Menu")
            } detail: {
                NavigationStack {
                    switch selectedTab {
                    case .home:
                        HomeView()
                    case .bookings:
                        BookingsView()
                    case .profile:
                        ProfileView()
                    }
                }
            }
        }
    }
}

This approach gives you full flexibility. On compact devices (iPhone), use a TabView. On regular devices (iPad), use a NavigationSplitView with a sidebar. Each branch preserves state independently, and the layout feels native to each device class.

Common mistakes

Forgetting to wrap tabs in NavigationStack: Without separate NavigationStacks, your app loses the navigation state when switching tabs. Users navigate two levels deep, switch tabs, then return to find themselves back at the root. Always use NavigationStack per tab.

Recreating views on every render: If you construct your tab content inline without extracting to separate structs, SwiftUI may recreate them unnecessarily, losing state. Extract each tab to its own View struct and reference it by name in the TabView.

Not managing badge state centrally: If you scatter notification count logic across multiple views, you'll end up with out-of-sync badge numbers. Use a single ObservableObject (like NotificationManager above) that all tabs observe via @StateObject or @EnvironmentObject.

Ignoring iPad layouts: Testing only on iPhone means your iPad app looks cramped and awkward. Always check your layout on both regular and compact size classes. Use SwiftUI previews with #Preview and device previews to catch these issues early.

Not testing navigation preservation: Navigate deeply in one tab, switch to another, then return. If your navigation state didn't persist, your architecture needs adjustment. Write a quick test: navigate to three levels deep, switch tabs twice, return, and verify you're still at level three.

Key takeaways

  • TabView provides declarative top-level app navigation with selection state bound via @State.
  • Wrap each tab in its own NavigationStack to preserve independent navigation hierarchies.
  • Use enums to manage tab configuration and routing logic cleanly.
  • Bind badge counts to @Published properties in a shared ObservableObject for real-time updates.
  • Leverage .tabViewStyle(sidebarAdaptable) for iPhone, and consider NavigationSplitView for iPad's expanded layout.
  • Test thoroughly on both regular (iPad) and compact (iPhone) size classes to ensure native-feeling layouts.
  • Extract each tab's view into its own struct to avoid unnecessary recreations and state loss.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles