Create a tab bar with TabView and PageTabViewStyle in SwiftUI | SwiftUI Bootcamp #43
TabView is the backbone of most multi-screen iOS apps, and PageTabViewStyle transforms it into a horizontally swipeable carousel — the same pattern used by App Store feature banners, photo viewers, and onboarding flows. After this lesson you'll know how to build both styles, programmatically switch tabs from any view, and pass data between tab siblings using @Binding.
What You'll Learn
- How to build a standard tab bar with icons and labels using
.tabItem - How to switch
TabViewto a swipeable page carousel withPageTabViewStyle() - How to programmatically navigate to a tab by binding
TabView'sselectionto a@Statevariable - How to pass a
@Bindingto a child tab view so it can change the active tab
Mental Model
Think of TabView as a TV remote with channel buttons. In the standard configuration each .tabItem is a button on the remote that jumps the TV to that channel. The selectedTab state is the TV's current channel memory. When you wire TabView(selection: $selectedTab) and tag each tab with .tag(0), .tag(1), etc., you are labeling the channel buttons so the TV knows which channel to display when a number is pressed — even from code.
PageTabViewStyle switches the TV to a swipe-to-browse mode, like swiping through Netflix thumbnails. The page dots at the bottom are the visual indicator of which "channel" is active. The same @State variable drives both modes — the mental model is identical.
Detailed Explanation
TabView without a selection binding is read-only from the user's perspective: they can tap tabs but your code cannot jump to a specific tab. Binding it to @State var selectedTab: Int makes navigation bidirectional — user taps change the state, and your code can change the state to switch tabs programmatically. Each tab must have a .tag(n) that matches the type of the selection variable so SwiftUI knows which tab corresponds to which value.
PageTabViewStyle completely replaces the bottom tab bar with a horizontal swipe gesture and a page-indicator dot row. It works on any collection of views inside ForEach or as individually added views. This makes it ideal for onboarding screens, feature carousels, and image galleries where a row of tabs would be out of place.
The HomeView struct in the code demonstrates how a child tab can navigate to a sibling tab using @Binding. Rather than sharing a global object, HomeView receives a @Binding var selectedTab: Int from its parent TabViewBootcamp. When the button inside HomeView sets selectedTab = 2, SwiftUI propagates the change up to the parent and the TabView switches to the Profile tab. This is a clean, contained pattern for cross-tab navigation without @EnvironmentObject.
Do not use TabView when you only have two views — a segmented control or custom toggle is more appropriate. For more than five tabs, consider a sidebar navigation approach (on iPad) or lazy-loading to avoid memory pressure from all tabs being instantiated at launch.
Code Structure
TabViewBootcamp.swift contains three declarations. TabViewBootcamp is the root view that demonstrates the PageTabViewStyle carousel with a ForEach over SF Symbol names. The commented-out block shows the full standard tab bar implementation with a selection binding. HomeView demonstrates programmatic cross-tab navigation using @Binding.
Complete Code
TabViewBootcamp.swift
import SwiftUI
struct TabViewBootcamp: View {
@State var selectedTab: Int = 0 // tracks which tab is currently visible; 0 = first tab
let icons: [String] = [
"heart.fill", "globe", "house.fill", "person.fill" // SF Symbol names used as page content
]
var body: some View {
TabView {
ForEach(icons, id: \.self) { icon in
Image(systemName: icon)
.resizable()
.scaledToFit()
.padding(30)
}
}
.background(
RadialGradient(gradient: Gradient(colors: [Color.red, Color.blue]), center: .center, startRadius: 5, endRadius: 300) // full-bleed gradient behind the carousel pages
)
.frame(height: 300) // constrains the carousel to 300pt so it doesn't fill the whole screen
.tabViewStyle(PageTabViewStyle()) // switches from tab bar to swipeable page carousel with dot indicators
// TabView(selection: $selectedTab) {
// HomeView(selectedTab: $selectedTab)
// .tabItem {
// Image(systemName: "house.fill")
// Text("Home")
// }
// .tag(0) // must match the type and value of selectedTab
//
// Text("BROWSE TAB")
// .tabItem {
// Image(systemName: "globe")
// Text("Browse")
// }
// .tag(1)
// Text("PROFILE TAB")
// .tabItem {
// Image(systemName: "person.fill")
// Text("Profile")
// }
// .tag(2)
// }
// .accentColor(.red) // tints the active tab icon and label
}
}
struct TabViewBootcamp_Previews: PreviewProvider {
static var previews: some View {
TabViewBootcamp()
}
}
struct HomeView: View {
@Binding var selectedTab: Int // receives a two-way reference to the parent's selectedTab
var body: some View {
ZStack {
Color.red.ignoresSafeArea() // full-bleed background for the Home tab
VStack {
Text("Home Tab")
.font(.largeTitle)
.foregroundColor(.white)
Button(action: {
selectedTab = 2 // programmatically jumps to the Profile tab (tag 2)
}, label: {
Text("Go to profile")
.font(.headline)
.padding()
.padding(.horizontal)
.background(Color.white)
.cornerRadius(10)
})
}
}
}
}Code Walkthrough
@State var selectedTab: Int = 0— The single source of truth for which tab is visible. By making this@State, any code that changesselectedTab— whether from a button, a deeplink, or a notification — will automatically update the visible tab.ForEach(icons, id: \.self)— Iterates over the SF Symbol name strings, creating one page per icon.id: \.selfworks becauseStringisHashable, so each symbol name uniquely identifies its page..tabViewStyle(PageTabViewStyle())— This single modifier transforms the standard tab bar into a full swipe-based carousel. The page indicator dots appear automatically at the bottom. Removing this line reverts to a standard tab bar..frame(height: 300)— Without an explicit height, aTabViewin page style will expand to fill the available space. Constraining it to 300pt makes it behave as a card-style carousel embedded in a larger layout.Commented-out standard tab bar — Shows the canonical pattern:
TabView(selection: $selectedTab)+ individual views tagged with.tag(0),.tag(1), etc. The tag integer must exactly match the type ofselectedTab— mixingInttags with aStringselection binding causes a silent mismatch.HomeView(selectedTab: $selectedTab)— Passes the binding down, not a copy.$selectedTab(with the dollar sign) creates aBindingto theIntthat points back to the parent's state. This is the clean, explicit way to allow child views to navigate to other tabs.selectedTab = 2insideHomeView's button — BecauseselectedTabis a@Binding, this assignment propagates up toTabViewBootcamp, which reads the new value and switches the displayed tab to the one tagged2(Profile).
Common Mistakes
Mistake: Forgetting .tag() on tab items when using a selection binding
Without .tag(), SwiftUI cannot map the tab to the selection value, so programmatic navigation silently does nothing. Every tab must have a .tag(n) where n matches both the type and a valid value of the selection variable.
Mistake: Using PageTabViewStyle without constraining the height, causing it to fill the entire screen
In page style, TabView expands greedily. If it is inside a VStack without .frame(height:), it will push all sibling views off screen. Always set an explicit height when embedding a page-style TabView inside another layout.
Mistake: Initializing all tab content eagerly, causing performance issues on launch
SwiftUI creates all tab views at launch, even the ones not initially visible. If a tab contains expensive views (complex lists, heavy data processing), consider using lazy initialization patterns or loading data only in onAppear.
Key Takeaways
- Add
TabView(selection: $selectedTab)and.tag(n)to each tab to enable programmatic navigation — without a selection binding, tabs cannot be switched from code. PageTabViewStyle()convertsTabViewinto a swipeable carousel in one line; use it for onboarding, feature highlights, or image galleries.- Pass
$selectedTabas a@Bindingto child views that need to navigate to sibling tabs — this keeps data flow explicit without needing a global@EnvironmentObject.
Last updated: June 27, 2026