What You Will Build
A swipeable onboarding screen using a paged TabView with a custom circle page indicator. Each page displays an SF Symbol icon, a title, and a subtitle. The active circle indicator grows larger than the inactive dots, and a "Next" / "Get Started" button advances through the pages programmatically.
Why This Pattern Matters
TabView with .page style is the standard way to build swipeable onboarding in SwiftUI. However, the default page indicator is limited in styling. This tutorial shows how to hide the default indicator and replace it with a custom animated circle indicator that gives you full control over size, color, and animation.
Key SwiftUI Concepts
- TabView with .page(indexDisplayMode: .never) hides the default dots.
- Custom HStack of circles replaces the built-in indicator.
- Programmatic page navigation by binding the TabView selection to @State.
Step 1: Define Page Data
@State private var currentPage = 0
let pages = [
("Welcome", "hand.wave.fill", "Get started with our app"),
("Discover", "magnifyingglass", "Find what you need"),
("Create", "paintbrush.fill", "Make something beautiful"),
("Share", "square.and.arrow.up.fill", "Share with the world"),
]
Step 2: Build the Paged TabView
TabView(selection: $currentPage) {
ForEach(pages.indices, id: \.self) { index in
VStack(spacing: 20) {
Image(systemName: pages[index].1)
.font(.system(size: 80))
.foregroundStyle(.blue.gradient)
Text(pages[index].0)
.font(.title.bold())
Text(pages[index].2)
.foregroundStyle(.secondary)
}
.tag(index)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
Step 3: Build the Custom Circle Indicators
The active dot is 12pt and filled with blue, while inactive dots are 8pt and gray. Spring animation makes the size change feel natural:
HStack(spacing: 8) {
ForEach(pages.indices, id: \.self) { index in
Circle()
.fill(index == currentPage ? .blue : .gray.opacity(0.3))
.frame(
width: index == currentPage ? 12 : 8,
height: index == currentPage ? 12 : 8
)
.animation(.spring, value: currentPage)
}
}
Step 4: Add the Navigation Button
Button(currentPage < pages.count - 1 ? "Next" : "Get Started") {
withAnimation {
if currentPage < pages.count - 1 {
currentPage += 1
} else {
currentPage = 0
}
}
}
.buttonStyle(.borderedProminent)
.padding(.bottom, 30)
Tips and Pitfalls
- indexDisplayMode: .never is essential. Without it, the default dots show alongside your custom indicator, resulting in duplicated navigation dots.
- Tag must match the selection type. Since
currentPageis an Int, each page needs.tag(index)where index is also an Int. Mismatched types silently break the binding. - Swipe still works. Even with a custom indicator and button, the user can swipe between pages. The TabView binding keeps everything synchronized.
- Button text changes dynamically: The ternary expression shows "Next" on intermediate pages and "Get Started" on the final page, guiding the user through the flow.
- Add skip functionality: Add a "Skip" button in the top trailing corner that jumps directly to the last page or dismisses onboarding entirely.
iOS Version: iOS 15+