Sitemap

Tab-based Navigation in SwiftUI: Multi-Tab Apps

8 min readFeb 25, 2026

--

Part 49 of 100 in the SwiftUI Zero to Expert Series

Press enter or click to view image in full size

The bug report read: “Switching tabs loses my scroll position.” Users would scroll through a long list on the Home tab, switch to Profile to check something, and return to find Home reset to the top.

Every time.

I’d built the tabs correctly — or so I thought.

The problem was subtle.

Each tab contained a NavigationStack, but I'd put the TabView inside a parent NavigationStack.

When you switch tabs in that configuration, SwiftUI recreates the tab content.

The fix was simple: flip the hierarchy. NavigationStack inside TabView, not the other way around.

But that bug taught me something important about tab navigation: each tab is its own world.

It has its own navigation stack, its own state, its own lifecycle.

Get that architecture wrong and everything feels broken.

Get it right and users navigate fluidly without ever losing context.

Here’s what I’ve learned about building tab-based navigation that actually works.

Basic TabView

The foundation is straightforward:

import SwiftUI

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

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

SearchView()
.tabItem {
Label("Search", systemImage: "magnifyingglass")
}
.tag(1)

FavoritesView()
.tabItem {
Label("Favorites", systemImage: "heart")
}
.tag(2)

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

The selection binding is optional but essential for programmatic tab switching.

Without it, you can’t switch tabs from code — like when a deep link arrives pointing to a profile screen.

Tabs with Independent Navigation Stacks

This is the architecture that fixed my scroll position bug:

import SwiftUI

struct TabWithNavigationView: View {
@State private var selectedTab = 0
@State private var homePath = NavigationPath()
@State private var searchPath = NavigationPath()
@State private var profilePath = NavigationPath()

var body: some View {
TabView(selection: $selectedTab) {
NavigationStack(path: $homePath) {
HomeTab()
.navigationDestination(for: Product.self) { product in
ProductDetailView(product: product)
}
}
.tabItem {
Label("Home", systemImage: "house")
}
.tag(0)

NavigationStack(path: $searchPath) {
SearchTab()
.navigationDestination(for: Product.self) { product in
ProductDetailView(product: product)
}
}
.tabItem {
Label("Search", systemImage: "magnifyingglass")
}
.tag(1)

NavigationStack(path: $profilePath) {
ProfileTab()
.navigationDestination(for: Order.self) { order in
OrderDetailView(order: order)
}
}
.tabItem {
Label("Profile", systemImage: "person")
}
.tag(2)
}
}
}

Each tab has its own NavigationPath.

User pushes three screens on Home, switches to Profile, pushes two screens there, switches back to Home — all three screens are still there.

Independent stacks preserve navigation context perfectly.

Tab Router for Programmatic Control

For any real app, you need centralized control:

import SwiftUI

@Observable
class TabRouter {
var selectedTab: Tab = .home

// Separate navigation paths for each tab
var homePath = NavigationPath()
var searchPath = NavigationPath()
var cartPath = NavigationPath()
var profilePath = NavigationPath()

enum Tab: Int, CaseIterable {
case home = 0
case search = 1
case cart = 2
case profile = 3

var title: String {
switch self {
case .home: return "Home"
case .search: return "Search"
case .cart: return "Cart"
case .profile: return "Profile"
}
}

var icon: String {
switch self {
case .home: return "house"
case .search: return "magnifyingglass"
case .cart: return "cart"
case .profile: return "person"
}
}
}

func selectTab(_ tab: Tab) {
if selectedTab == tab {
// Already on this tab - pop to root
popToRoot(for: tab)
} else {
selectedTab = tab
}
}

func popToRoot(for tab: Tab) {
switch tab {
case .home: homePath.removeLast(homePath.count)
case .search: searchPath.removeLast(searchPath.count)
case .cart: cartPath.removeLast(cartPath.count)
case .profile: profilePath.removeLast(profilePath.count)
}
}

func navigateToProduct(_ product: Product) {
selectedTab = .home
homePath.append(product)
}

func navigateToOrder(_ order: Order) {
selectedTab = .profile
profilePath.append(order)
}

func navigateToCart() {
selectedTab = .cart
}
}

The tap-to-pop-to-root behavior is a native iOS pattern users expect.

Tapping the current tab’s icon should take you back to the root of that tab.

With selectTab, if you're already on the tab, it pops to root instead of doing nothing.

Using the Router

import SwiftUI

struct TabRouterView: View {
@State private var router = TabRouter()

var body: some View {
TabView(selection: $router.selectedTab) {
homeTab
searchTab
cartTab
profileTab
}
.environment(router)
}

var homeTab: some View {
NavigationStack(path: $router.homePath) {
HomeScreen()
.navigationDestination(for: Product.self) { product in
ProductDetailScreen(product: product)
}
}
.tabItem { Label("Home", systemImage: "house") }
.tag(TabRouter.Tab.home)
}

var searchTab: some View {
NavigationStack(path: $router.searchPath) {
SearchScreen()
.navigationDestination(for: Product.self) { product in
ProductDetailScreen(product: product)
}
}
.tabItem { Label("Search", systemImage: "magnifyingglass") }
.tag(TabRouter.Tab.search)
}

var cartTab: some View {
NavigationStack(path: $router.cartPath) {
CartScreen()
}
.tabItem { Label("Cart", systemImage: "cart") }
.tag(TabRouter.Tab.cart)
}

var profileTab: some View {
NavigationStack(path: $router.profilePath) {
ProfileScreen()
.navigationDestination(for: Order.self) { order in
OrderDetailScreen(order: order)
}
}
.tabItem { Label("Profile", systemImage: "person") }
.tag(TabRouter.Tab.profile)
}
}

Any child view can access the router through the environment and trigger cross-tab navigation.

Badge Support

Badges communicate status at a glance:

import SwiftUI

struct BadgedTabView: View {
@State private var selectedTab = 0
@State private var cartCount = 3
@State private var notificationCount = 5

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

CartView()
.tabItem {
Label("Cart", systemImage: "cart")
}
.badge(cartCount)
.tag(1)

NotificationsView()
.tabItem {
Label("Alerts", systemImage: "bell")
}
.badge(notificationCount > 0 ? "\(notificationCount)" : nil)
.tag(2)

ProfileView()
.tabItem {
Label("Profile", systemImage: "person")
}
.badge("New") // Text badge
.tag(3)
}
}
}

The cart badge showing “3” tells users they have items waiting.

The notification badge shows unread count.

The “New” text badge highlights new features.

All without the user tapping anything.

Custom Tab Bar

When the default tab bar doesn’t match your design:

import SwiftUI

struct CustomTabBar: View {
@Binding var selectedTab: Int
let tabs: [TabItem]

struct TabItem {
let icon: String
let title: String
let badge: Int?
}

var body: some View {
HStack {
ForEach(tabs.indices, id: \.self) { index in
Spacer()

TabButton(
item: tabs[index],
isSelected: selectedTab == index,
action: { selectedTab = index }
)

Spacer()
}
}
.padding(.vertical, 10)
.background(
Rectangle()
.fill(.ultraThinMaterial)
.shadow(color: .black.opacity(0.1), radius: 5, y: -5)
)
}
}

struct TabButton: View {
let item: CustomTabBar.TabItem
let isSelected: Bool
let action: () -> Void

var body: some View {
Button(action: action) {
VStack(spacing: 4) {
ZStack(alignment: .topTrailing) {
Image(systemName: item.icon)
.font(.system(size: 24))

if let badge = item.badge, badge > 0 {
Text("\(badge)")
.font(.caption2)
.foregroundColor(.white)
.padding(4)
.background(Color.red)
.clipShape(Circle())
.offset(x: 10, y: -5)
}
}

Text(item.title)
.font(.caption)
}
.foregroundColor(isSelected ? .blue : .gray)
}
}
}

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

let tabs = [
CustomTabBar.TabItem(icon: "house.fill", title: "Home", badge: nil),
CustomTabBar.TabItem(icon: "magnifyingglass", title: "Search", badge: nil),
CustomTabBar.TabItem(icon: "cart.fill", title: "Cart", badge: 3),
CustomTabBar.TabItem(icon: "person.fill", title: "Profile", badge: nil)
]

var body: some View {
VStack(spacing: 0) {
// Content
ZStack {
switch selectedTab {
case 0: HomeView()
case 1: SearchView()
case 2: CartView()
case 3: ProfileView()
default: EmptyView()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)

// Custom tab bar
CustomTabBar(selectedTab: $selectedTab, tabs: tabs)
}
.ignoresSafeArea(.keyboard)
}
}

Custom tab bars give you complete control over appearance and animation, but you lose built-in features like the long-press haptic and accessibility traits.

Usually worth the tradeoff for apps with distinctive branding.

Floating Tab Bar

For a modern, minimalist look:

import SwiftUI

struct FloatingTabBar: View {
@Binding var selectedTab: Int

var body: some View {
HStack(spacing: 30) {
ForEach(0..<4) { index in
Button {
withAnimation(.spring(response: 0.3)) {
selectedTab = index
}
} label: {
VStack(spacing: 4) {
Image(systemName: tabIcon(index))
.font(.system(size: 20))
.scaleEffect(selectedTab == index ? 1.2 : 1)

if selectedTab == index {
Circle()
.fill(Color.blue)
.frame(width: 5, height: 5)
}
}
.foregroundColor(selectedTab == index ? .blue : .gray)
}
}
}
.padding(.horizontal, 30)
.padding(.vertical, 15)
.background(
Capsule()
.fill(.ultraThinMaterial)
.shadow(color: .black.opacity(0.2), radius: 10, y: 5)
)
}

func tabIcon(_ index: Int) -> String {
switch index {
case 0: return "house.fill"
case 1: return "magnifyingglass"
case 2: return "heart.fill"
case 3: return "person.fill"
default: return ""
}
}
}

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

var body: some View {
ZStack(alignment: .bottom) {
// Content
TabView(selection: $selectedTab) {
HomeView().tag(0)
SearchView().tag(1)
FavoritesView().tag(2)
ProfileView().tag(3)
}
.tabViewStyle(.page(indexDisplayMode: .never))

// Floating tab bar
FloatingTabBar(selectedTab: $selectedTab)
.padding(.bottom, 20)
}
}
}

The capsule shape and shadow make it feel like a floating element.

The spring animation adds polish when switching tabs.

Hiding the Tab Bar

Sometimes you need to hide the tab bar — for immersive content or full-screen flows:

import SwiftUI

struct HideableTabView: View {
@State private var selectedTab = 0
@State private var hideTabBar = false

var body: some View {
TabView(selection: $selectedTab) {
NavigationStack {
ScrollView {
VStack {
ForEach(0..<50) { i in
Text("Item \(i)")
.padding()
}
}
}
.navigationTitle("Home")
.toolbar(hideTabBar ? .hidden : .visible, for: .tabBar)
}
.tabItem { Label("Home", systemImage: "house") }
.tag(0)

Text("Search")
.tabItem { Label("Search", systemImage: "magnifyingglass") }
.tag(1)
}
}
}

The .toolbar(_:for:) modifier controls tab bar visibility.

You can tie it to scroll position, navigation depth, or any other state.

Tab Bar with Center Action Button

A popular pattern for primary actions:

import SwiftUI

struct CenterButtonTabBar: View {
@Binding var selectedTab: Int
let onCenterTap: () -> Void

var body: some View {
ZStack {
// Background with regular tabs
HStack {
tabButton(0, icon: "house.fill")
tabButton(1, icon: "magnifyingglass")

Spacer().frame(width: 60) // Space for center button

tabButton(2, icon: "heart.fill")
tabButton(3, icon: "person.fill")
}
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(Color(.systemBackground))

// Center action button
Button(action: onCenterTap) {
Image(systemName: "plus")
.font(.title2.bold())
.foregroundColor(.white)
.frame(width: 56, height: 56)
.background(Color.blue)
.clipShape(Circle())
.shadow(color: .blue.opacity(0.3), radius: 10, y: 5)
}
.offset(y: -20)
}
}

func tabButton(_ index: Int, icon: String) -> some View {
Button {
selectedTab = index
} label: {
Image(systemName: icon)
.font(.system(size: 22))
.foregroundColor(selectedTab == index ? .blue : .gray)
.frame(maxWidth: .infinity)
}
}
}

The center button often triggers a modal for creating content — new post, new item, new message.

It’s not a tab destination; it’s an action.

What I Know Now That I Wish I’d Known Then

NavigationStack inside TabView, not outside.

This is the most important rule. Each tab needs its own navigation stack to preserve state independently. Get this hierarchy wrong and you’ll chase bugs for hours.

Tap-to-pop-to-root is expected.

Users expect tapping the current tab’s icon to scroll to top or pop to root. Build this into your tab router from the start.

Badge counts should update reactively. Don’t manually update badge counts. Derive them from your data model. Cart count should reflect actual cart items, not a separate counter that might get out of sync.

Test tab switching with deep navigation. Push several screens on one tab, switch tabs, push more screens, switch back. Make sure state persists correctly. This is where tab navigation bugs hide.

The Gotchas That Caught Me

TabView selection type must match tag type.

If your tags are Tab enum cases, your selection must be @State private var selectedTab: Tab. Mismatched types fail silently—the tab doesn't switch.

Tab items are declared on the view, not TabView.

Every view inside TabView needs .tabItem attached to it directly. If you wrap views in containers, the tab items disappear.

Custom tab bars lose accessibility features.

The native tab bar has built-in VoiceOver support, haptics, and long-press actions. Custom implementations need to add these manually.

Hiding the tab bar affects safe area. When you hide the tab bar, views resize. This can cause jarring layout shifts. Use animation: nil or plan for the geometry change.

Tab views are lazy but persistent. SwiftUI doesn’t load tab content until the tab is selected, but once loaded, it stays in memory. Large apps might need to manually reset state on inactive tabs.

Tab-based navigation structures your entire app:

  • Independent NavigationStacks per tab preserve context
  • Tab routers enable programmatic control
  • Badges communicate status without interaction
  • Custom tab bars match your brand

That scroll position bug taught me to respect the architecture.

Each tab is a complete navigation context.

The tab bar is just a switcher between those contexts.

Keep them independent, expose programmatic control, and users navigate fluidly.

In the next article, we’ll explore Modal Presentations — sheets, full-screen covers, and the patterns that make them work reliably.

Found this helpful? Share it!

Thank you for reading! If you enjoyed it, please consider clapping 👏 and following for more updates! 🚀

--

--