Effortless SwiftUI tab pager with dynamic customization.
TabPagerX is a SwiftUI-based library designed to help iOS developers create customizable tab pagers with ease.
It offers flexible layouts, per-tab state preservation, and extensive styling options for tab labels and indicators, making it a perfect choice for building tab-based navigation in your SwiftUI applications.
- Features
- Anatomy
- Requirements
- Installation
- Usage
- Configuration
- Migrating from 2.x
- Contributing
- About
- License
- ID-based Selection: Bind selection to your item's
id— it survives reorders and removals, and deep links can select a tab without knowing its position. - Generic Data API: Work with any
Identifiable & Equatabledata model. - Type-safe Builders: Closure-based
contentandlabelper item. - Static & Dynamic Tabs: Supports both fixed arrays and API-driven dynamic lists — safe with empty or async-loaded items.
- State Preservation: Each tab's page (scroll position, internal state) is cached by item id — appending or reordering tabs keeps existing state.
- Real-time Label & Indicator Tracking:
TabState.selectionProgressinterpolates 0→1 as your finger swipes, for the label and the indicator alike. - Configurable Layouts: Fixed/Scrollable tab bar with spacing and padding controls.
- Indicator Customization: Height, color, corner radius, horizontal inset, animation.
- Optional Separator: Built-in separator between TabBar and content via modifier.
- Gesture Navigation: Enable/disable swipe between pages — togglable at runtime. Disabling swipe also removes tab transition animation.
- VoiceOver Support: Tabs are announced as buttons with selection state and position.
A TabPagerX is a vertical stack of three parts. Each part maps to the API that controls it:
┌───────────────────────────────────────────────┐
│ Home Search Profile │ ← tab labels label: { item, state in }
│ ▔▔▔▔▔▔ │ ← indicator .tabIndicatorStyle(…)
│ ───────────────────────────────────────────── │ ← separator .tabBarSeparator(…)
│ │
│ page content │ ← content content: { item in }
│ ◀ swipe to change tabs ▶ │ paging .contentSwipeEnabled(…)
│ │
└───────────────────────────────────────────────┘
▲
└ tab bar layout .tabBarLayoutStyle(.fixed / .scrollable)
.tabBarLayoutConfig(buttonSpacing:sidePadding:)
- iOS 15.0+
- Swift 5.5+
- Xcode 15.0+
In Xcode, go to File > Add Packages
https://github.com/camosss/TabPagerX.git
Add to your Podfile
pod 'TabPagerX'Run
pod install
- Bind a
@Stateoptional id toselectionto track the current tab.niluntil items arrive — the first tab is then selected automatically.- Preset an id (e.g.
= "profile") to start on a specific tab.
- Provide
items(anyIdentifiable & Equatabletype). - Define each tab's content using SwiftUI views via
contentclosure. - Use the
labelclosure to build each tab's label from the item and itsTabState.
@State private var selection: MyItem.ID? = nil
private let items = [..]
TabPagerX(
selection: $selection,
items: items
) { item in
/* content */
} label: { item, state in
/* label — state.isSelected / state.selectionProgress */
}Use stable ids. Give items a stable identity (
let id: Stringfrom your data), notUUID()created on the fly — stable ids are what make selection and per-tab state survive item updates.
- Ideal for simple static lists or repeating the same layout.
- All tabs use the same view structure with different data.
|
|
struct TabItem: Identifiable, Equatable {
let id: String
let title: String
let content: String
let color: Color
}
@State private var selection: String? = nil
private let items = [
TabItem(id: "home", title: "Home", content: "Welcome to Home", color: .blue),
TabItem(id: "search", title: "Search", content: "Search content", color: .green),
TabItem(id: "profile", title: "Profile", content: "Profile content", color: .orange)
]
TabPagerX(
selection: $selection,
items: items
) { item in
VStack {
Text(item.content)
.font(.title2)
.foregroundColor(item.color)
Rectangle()
.fill(item.color)
.frame(height: 200)
.cornerRadius(12)
}
.padding()
} label: { item, state in
Text(item.title)
.font(state.isSelected ? .headline : .body)
.foregroundColor(state.isSelected ? item.color : .secondary)
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
.tabBarLayoutStyle(.fixed)
.tabIndicatorStyle(height: 3, color: .blue, horizontalInset: 16)- Renders different views based on each item's
type. - Useful when each tab needs heterogeneous UI.
|
|
struct MixedTabItem: Identifiable, Equatable {
let id: String
let type: TabItemType
let title: String
enum TabItemType: Equatable {
case text(String)
case image(String)
case custom
}
}
@State private var selection: String? = nil
private let items = [
MixedTabItem(id: "text", type: .text("Hello World"), title: "Text"),
MixedTabItem(id: "image", type: .image("star.fill"), title: "Image"),
MixedTabItem(id: "custom", type: .custom, title: "Custom")
]
TabPagerX(
selection: $selection,
items: items
) { item in
switch item.type {
case .text(let text):
Text(text)
.font(.largeTitle)
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .image(let name):
Image(systemName: name)
.font(.system(size: 60))
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .custom:
Circle()
.fill(LinearGradient(colors: [.blue, .purple], startPoint: .topLeading, endPoint: .bottomTrailing))
.frame(width: 100, height: 100)
}
} label: { item, state in
HStack {
if case .image = item.type {
Image(systemName: "photo")
} else if case .custom = item.type {
Image(systemName: "star.circle")
}
Text(item.title)
}
.font(state.isSelected ? .headline : .body)
.foregroundColor(state.isSelected ? .blue : .secondary)
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
.tabIndicatorStyle(height: 4, color: .purple)- Scrollable layout for many tabs with button spacing and side padding.
state.selectionProgress(0...1) follows your finger — interpolate color, opacity, or scale for a continuous transition.
@State private var selection: String? = nil
private let items = [
CategoryItem(id: "all", title: "All", emoji: "🌐", color: .blue),
CategoryItem(id: "music", title: "Music", emoji: "🎵", color: .pink),
CategoryItem(id: "sports", title: "Sports", emoji: "⚽", color: .green),
// ...
]
TabPagerX(
selection: $selection,
items: items
) { item in
VStack(spacing: 16) {
Text(item.emoji).font(.system(size: 80))
Text(item.title).font(.title).foregroundColor(item.color)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} label: { item, state in
Text("\(item.emoji) \(item.title)")
.font(.subheadline)
.foregroundColor(item.color.opacity(0.35 + 0.65 * state.selectionProgress))
.scaleEffect(1 + 0.08 * state.selectionProgress)
.padding(.horizontal, 14)
.padding(.vertical, 8)
}
.tabBarLayoutStyle(.scrollable)
.tabBarLayoutConfig(buttonSpacing: 4, sidePadding: 12)
.tabIndicatorStyle(height: 3, color: .blue, cornerRadius: 1.5)- Safe with empty or async-loaded items — no
isLoadingguard needed. - Tabs render automatically when data arrives; the first tab (or a preset id) is selected.
@State private var selection: Item.ID? = nil
@State private var items: [Item] = [] // starts empty
var body: some View {
VStack {
Button("Reload") { loadData() }
// No isLoading guard needed — safe with empty items
TabPagerX(
selection: $selection,
items: items
) { item in
Text(item.content)
.font(.title2)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} label: { item, state in
HStack {
Image(systemName: item.icon)
Text(item.title)
}
.font(state.isSelected ? .headline : .body)
.foregroundColor(state.isSelected ? item.color : .secondary)
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
.tabBarLayoutStyle(.scrollable)
.tabIndicatorStyle(height: 3, color: .green, horizontalInset: 8)
}
.onAppear { loadData() }
}
func loadData() {
items = []
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
items = [/* fetched data */]
}
}- Pages are cached by item id, so each tab keeps its scroll position and internal state across item updates.
- Appending a tab preserves the existing tabs; reordering moves each page with its item; removing drops only that page.
@State private var selection: String? = nil
@State private var items: [TabItem] = [/* ... */]
TabPagerX(selection: $selection, items: items) { item in
/* a scrollable page */
} label: { item, state in
/* label */
}
// Later — existing tabs keep their state because their ids are unchanged
items.append(TabItem(id: "new", title: "New"))
items.shuffle()- Preset the binding to start on a specific tab — no index math, works regardless of server-driven tab order.
- Assigning the id from anywhere (a deep link, a button) moves the pager to that tab.
// Start on the "profile" tab once items load
@State private var selection: String? = "profile"
// Deep link later — just assign the id
selection = "event"- When swipe is disabled, tapping a tab switches content instantly with no slide animation.
- Can be toggled at runtime.
TabPagerX(
selection: $selection,
items: items
) { item in
Text(item.title)
.font(.largeTitle)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} label: { item, state in
Text(item.title)
.font(state.isSelected ? .headline : .body)
.foregroundColor(state.isSelected ? item.color : .secondary)
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
.tabBarLayoutStyle(.fixed)
.tabIndicatorStyle(height: 3, color: .red)
.contentSwipeEnabled(false) // no swipe, no slide animation on tapFor more examples, browse the sample app. Each usage case is a focused, heavily commented file grouped by topic:
| Topic | Cases |
|---|---|
| Basics | Same Content, Different Views by Type |
| Layout & Style | Fixed vs Scrollable, Real-time Label (selectionProgress), Indicator Customization, Separator, Safe Area |
| Interaction | Swipe Disabled, Runtime Swipe Toggle |
| Selection | Preset Selection (by id), Deep Link / Programmatic, Observe Tab Changes |
| Dynamic Data | Dynamic / Async Tabs, State Preservation (append / reorder) |
| Accessibility | VoiceOver |
- Set Tab Bar Layout Style.
- Choose between fixed or scrollable layouts.
- Custom tab views are fully supported in both layouts.
|
|
// Fixed: tabs share equal width across the screen (default)
.tabBarLayoutStyle(.fixed)
// Scrollable: tabs size to content, horizontally scrollable
.tabBarLayoutStyle(.scrollable)- Configure Tab Bar Layout.
- Adjust
buttonSpacingandsidePadding. (defaults to 0)buttonSpacing: spacing between each tab buttonsidePadding: horizontal padding applied to the whole tab bar (left & right)
// No spacing (default)
.tabBarLayoutConfig(buttonSpacing: 0, sidePadding: 0)
// With spacing and padding
.tabBarLayoutConfig(buttonSpacing: 8, sidePadding: 12)- Customize Tab underline (indicator) with
.tabIndicatorStyle(...). - You can set
height,color,horizontalInset,cornerRadius, andanimationDuration. - The indicator tracks your finger in real-time during swipe gestures.
// Thin blue underline
.tabIndicatorStyle(height: 2, color: .blue)
// Rounded pill with inset
.tabIndicatorStyle(
height: 4,
color: .orange,
horizontalInset: 20,
cornerRadius: 2,
animationDuration: 0.25
)
// No indicator (default — height: 0, color: .clear)- Enable or Disable Content Swipe.
- Allow or disable swipe gesture to switch between tabs.
- Default is
true. Use.contentSwipeEnabled(false)to disable swipe navigation. - Can be toggled at runtime (e.g. while editing).
- When disabled, tab tap transitions are also instant (no slide animation).
// Swipe enabled (default) — swipe between pages with slide animation
.contentSwipeEnabled(true)
// Swipe disabled — tap only, instant content switch
.contentSwipeEnabled(false)- By default the pager respects the safe area, so it can sit above tab bars or toolbars without layout issues.
- For full-screen content, opt in to extend into safe area edges.
// Full-screen content — extend into the bottom safe area (v2 default behavior)
.contentIgnoresSafeArea()
// Or specify edges explicitly
.contentIgnoresSafeArea(edges: [.bottom, .horizontal])- Adds a separator line between the TabBar and the content area.
- Use to visually distinguish the tab bar from page content.
// Add separator
.tabBarSeparator(
color: .gray.opacity(0.3),
height: 1
)
// Full customization
.tabBarSeparator(
color: .gray.opacity(0.2),
height: 1,
horizontalPadding: 16,
isHidden: false
)
|
|
- Observe tab index changes via callback.
- For the selected item itself, observe your
selectionbinding withonChange.
.onTabChanged { index in
print("Selected tab: \(index)")
}The 2.x index-based initializer still compiles (deprecated) — migrate at your own pace:
| 2.x | 3.0 |
|---|---|
selectedIndex: Binding<Int> |
selection: Binding<Item.ID?> |
initialIndex: 2 |
preset the binding: @State var selection: ID? = "someId" |
tabTitle: { item, isSelected in } |
label: { item, state in } — use state.isSelected |
| bottom safe area always ignored | respected by default — add .contentIgnoresSafeArea() to keep the old behavior |
// 2.x
TabPagerX(selectedIndex: $index, initialIndex: 1, items: items) { item in
...
} tabTitle: { item, isSelected in
Text(item.title).foregroundColor(isSelected ? .blue : .gray)
}
// 3.0
TabPagerX(selection: $selection, items: items) { item in
...
} label: { item, state in
Text(item.title).foregroundColor(state.isSelected ? .blue : .gray)
}Also make sure your items use stable ids — replace let id = UUID() with an identity from your data (e.g. a server id or a constant string).
Issues and pull requests are welcome. If you find a bug or want a feature, please open an issue. For pull requests, keep changes focused and make sure the test suite passes (⌘U, or xcodebuild test on an iOS Simulator) — CI runs the same suite on every PR.
Created and maintained by camosss.
TabPagerX is released under an MIT license. See License for more information.







