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, tab scroll preservation, and extensive styling options for tab buttons and indicators, making it a perfect choice for building tab-based navigation in your SwiftUI applications.
- Generic Data API: Work with any
Identifiable & Equatabledata model. - Type-safe Builders: Closure-based
contentandtabTitleper item. - Static & Dynamic Tabs: Supports both fixed arrays and API-driven dynamic lists โ safe with empty or async-loaded items.
- Configurable Layouts: Fixed/Scrollable tab bar with spacing and padding controls.
- Indicator Customization: Height, color, corner radius, horizontal inset, animation.
- Real-time Indicator Tracking: Indicator follows your finger in real-time during swipe.
- Optional Separator: Built-in separator between TabBar and content via modifier.
- Gesture Navigation: Enable/disable swipe between pages. Disabling swipe also removes tab transition animation.
- iOS 15.0+
- Swift 5.5+
- Xcode 15.0+
- Bind a
@Statevariable toselectedIndexto track the current tab. - Optionally set
initialIndex(default is 0) to define which tab is shown first (applied once on first appearance). - Provide
items(anyIdentifiable & Equatabletype). - Define each tab's content using SwiftUI views via
contentclosure. - Use
tabTitleclosure to provide a custom tab label per item (@ViewBuilder).
@State private var selectedIndex = 0
private let items = [..]
TabPagerX(
selectedIndex: $selectedIndex,
items: items
) { item in
/* content */
} tabTitle: { item, isSelected in
/* title */
}- 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 = UUID()
let title: String
let content: String
let color: Color
}
@State private var selectedIndex = 0
private let items = [
TabItem(title: "Home", content: "Welcome to Home", color: .blue),
TabItem(title: "Search", content: "Search content", color: .green),
TabItem(title: "Profile", content: "Profile content", color: .orange)
]
TabPagerX(
selectedIndex: $selectedIndex,
items: items
) { item in
VStack {
Text(item.content)
.font(.title2)
.foregroundColor(item.color)
Rectangle()
.fill(item.color)
.frame(height: 200)
.cornerRadius(12)
}
.padding()
} tabTitle: { item, isSelected in
Text(item.title)
.font(isSelected ? .headline : .body)
.foregroundColor(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 = UUID()
let type: TabItemType
let title: String
enum TabItemType: Equatable {
case text(String)
case image(String)
case custom
}
}
@State private var selectedIndex = 0
private let items = [
MixedTabItem(type: .text("Hello World"), title: "Text"),
MixedTabItem(type: .image("star.fill"), title: "Image"),
MixedTabItem(type: .custom, title: "Custom")
]
TabPagerX(
selectedIndex: $selectedIndex,
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)
}
} tabTitle: { item, isSelected in
HStack {
if case .image = item.type {
Image(systemName: "photo")
} else if case .custom = item.type {
Image(systemName: "star.circle")
}
Text(item.title)
}
.font(isSelected ? .headline : .body)
.foregroundColor(isSelected ? .blue : .secondary)
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
.tabIndicatorStyle(height: 4, color: .purple)- Safe with empty or async-loaded items โ no
isLoadingguard needed. - Tabs render automatically when data arrives.
@State private var selectedIndex = 0
@State private var items: [Item] = [] // starts empty
var body: some View {
VStack {
Button("Reload") { loadData() }
// No isLoading guard needed โ safe with empty items
TabPagerX(
selectedIndex: $selectedIndex,
initialIndex: 1, // applied once when items load
items: items
) { item in
Text(item.content)
.font(.title2)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} tabTitle: { item, isSelected in
HStack {
Image(systemName: item.icon)
Text(item.title)
}
.font(isSelected ? .headline : .body)
.foregroundColor(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 */]
}
}- Scrollable layout for many tabs with button spacing and side padding.
- Indicator and tab title selection follow your finger in real-time during swipe.
@State private var selectedIndex = 0
private let items = [
CategoryItem(title: "All", emoji: "๐", color: .blue),
CategoryItem(title: "Music", emoji: "๐ต", color: .pink),
CategoryItem(title: "Sports", emoji: "โฝ", color: .green),
CategoryItem(title: "Gaming", emoji: "๐ฎ", color: .purple),
CategoryItem(title: "Food", emoji: "๐", color: .orange),
CategoryItem(title: "Travel", emoji: "โ๏ธ", color: .cyan),
// ...
]
TabPagerX(
selectedIndex: $selectedIndex,
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)
} tabTitle: { item, isSelected in
Text("\(item.emoji) \(item.title)")
.font(isSelected ? .headline : .subheadline)
.foregroundColor(isSelected ? item.color : .secondary)
.padding(.horizontal, 14)
.padding(.vertical, 8)
}
.tabBarLayoutStyle(.scrollable)
.tabBarLayoutConfig(buttonSpacing: 4, sidePadding: 12)
.tabIndicatorStyle(height: 3, color: .blue, cornerRadius: 1.5)- When swipe is disabled, tapping a tab switches content instantly with no slide animation.
TabPagerX(
selectedIndex: $selectedIndex,
items: items
) { item in
Text(item.title)
.font(.largeTitle)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} tabTitle: { item, isSelected in
Text(item.title)
.font(isSelected ? .headline : .body)
.foregroundColor(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, see TabPagerXSample in the sample app. (link: TabPagerXSample)
- 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. - 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)- 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.
.onTabChanged { index in
print("Selected tab: \(index)")
}In Xcode, go to File > Add Packages
https://github.com/camosss/TabPagerX.git
Add to your Podfile
pod 'TabPagerX'Run
pod install
TabPagerX is released under an MIT license. See License for more information.







