While developing our app Reflect, we encountered a limitation with SwiftUI’s TabView component when trying to implement a horizontally-scrollable tab bar. In this blog post, we’ll explore how we overcame this limitation by implementing custom state management and a custom tab bar, all while maintaining the native feel of SwiftUI.
The Problem with TabView and More Than Five Tabs
Recently we were inspired by this beautiful tab design we found on Reddit. To accommodate our design and allow as many tabs as the user might want, we decided to adapt the tab bar by making it horizontally scrollable. However, we encountered an issue. While SwiftUI’s TabView is a powerful component that simplifies tab-based navigation in iOS apps—handling state preservation, view hierarchy management, and providing a familiar user experience—it comes with a significant constraint: when you have more than five tabs, iOS automatically adds a “More” tab. This behavior is inherited from UIKit’s UITabBarController.
Challenges with the Automatic “More” Tab
The automatic “More” tab presents several challenges:
- Design Limitations: The automatic inclusion of the “More” tab interferes with custom tab bar designs, making it difficult to maintain a consistent appearance without hidden tabs. Even when you overlay a custom tab view, the underlying “More” tab can inadvertently get triggered, causing unexpected behavior and disrupting the custom interface.
- Limited Customization: There are no straightforward APIs to disable or modify this default behavior within
TabView. This lack of flexibility prevents developers from fully customizing the tab navigation experience to suit their app’s specific needs. - User Experience: Although users might not find the “More” tab inherently confusing, it does introduce additional steps in navigation. They have to tap on the “More” tab and then select their desired destination from a new list, which requires extra taps and time. This added layer forces users to visually parse another screen to find the tab they want, potentially disrupting the flow and efficiency of the app experience.
Common Workarounds and Their Drawbacks
Some developers attempt to work around this limitation by using PageTabViewStyle:
TabView {
// Your tabs here
}
.tabViewStyle(PageTabViewStyle())
While this removes the tab bar and uses a swipe-based navigation style, it doesn’t suit all apps, especially those that require a traditional tab bar interface and do not want to allow the user to swipe to change tabs, which can interfere with other swipe action functionality.
Our Solution: Custom State Management and Tab Bar
To overcome these limitations, we devised a solution that involves:
- Implementing a Custom Tab Bar with Horizontal Scrolling
- Limiting the Number of Tabs in
TabViewto Five or Fewer - Dynamically Managing Which Tabs Are Cached
Overview of the Approach
Our approach revolves around managing the tabs whose view state is cached in the TabView dynamically. By ensuring that the TabView never has more than five tabs at any given time, we prevent the automatic “More” tab from appearing. We achieve this by:
- Using a custom tab bar to display all available tabs, with scrolling if necessary.
- Maintaining a set of default tabs whose view state is always cached in the
TabView. - Allowing additional tabs to be rotated into the
TabViewas needed.
Implementing the Solution Step by Step
Let’s dive into the implementation details, starting with defining our tabs.
Defining Tab Options
We start by defining an enum that represents all possible tabs in our app:
enum TabOption: String, CaseIterable, Identifiable, Codable {
case reflect = "Reflect"
case plots = "Plots"
case experiments = "Experiments"
case insights = "Insights"
case history = "History"
case goals = "Goals"
case metrics = "Metrics"
case events = "Events"
case reports = "Reports"
case timers = "Timers"
case tabSelection = "Edit"
var id: String { rawValue }
var icon: String {
switch self {
case .reflect: return "menucard.fill"
case .plots: return "chart.xyaxis.line"
case .experiments: return "testtube.2"
case .insights: return "lightbulb.fill"
case .history: return "clock"
case .goals: return "target"
case .metrics: return "compass.drawing"
case .events: return "calendar.badge.plus"
case .reports: return "chart.line.uptrend.xyaxis"
case .timers: return "stopwatch"
case .tabSelection: return "rectangle.stack.badge.plus"
}
}
}
Each case in the TabOption enum represents a unique tab within our app, identified by a raw string value for display purposes. The icon computed property provides an SF Symbol string for each tab, which will be used to display the appropriate icon in the tab bar.
Creating a Tab Manager
Next, we create a TabManager class that conforms to ObservableObject. The TabManager class is designed to dynamically manage which tabs are cached with the TabView. Our approach implements the logic needed to overcome the TabView limitations by controlling the number and arrangement of tabs, ensuring we stay within the five-tab limit to prevent the unwanted “More” tab from appearing.
class TabManager: ObservableObject {
@Published var tabsToCache: [TabOption] = []
@Published var selectedTab: TabOption = .reflect
let defaultTabs: [TabOption] = [
.reflect,
.plots,
.experiments,
.insights
]
private var lastNonDefaultTab: TabOption?
init() {
// Initialize tabs to show with default tabs
tabsToCache = Array(
defaultTabs.prefix(4)
)
}
func updateTabsToCache(
for newTab: TabOption
) {
// Check if newTab is one of the default tabs
if defaultTabs.contains(newTab) {
// Update with default tabs, keeping
// lastNonDefaultTab if available
tabsToCache = defaultTabs
if let lastTab = lastNonDefaultTab,
tabsToCache.count < 5 {
tabsToCache.append(lastTab)
}
} else {
// If not a default tab, add it as
// the 5th tab and store as lastNonDefaultTab
if !tabsToCache.contains(newTab) {
if tabsToCache.count >= 5 {
tabsToCache.removeLast()
}
tabsToCache.append(newTab)
}
lastNonDefaultTab = newTab
}
}
}
Understanding the Tab Manager
Let’s delve deeper into the TabManager‘s properties and methods:
tabsToCache: An array ofTabOptionthat represents the tabs currently cached in theTabView. By limiting this array to five or fewer items, we avoid triggering the automatic “More” tab.selectedTab: Keeps track of the currently selected tab, allowing the UI to highlight the active tab and display the corresponding content.defaultTabs: An array containing the default set of tabs that are always cached. These are the primary tabs essential to the app’s functionality.lastNonDefaultTab: Stores the last non-default tab selected by the user. This ensures that if a user frequently accesses a particular non-default tab, its view state remains cached until another non-default tab is tapped.updateTabsToCache(for newTab: TabOption): A method that updates thetabsToCachearray whenever a new tab is selected. If the new tab isn’t a default tab, it includes it intabsToCache, removing the last tab if necessary to keep the total count within five.
Building the Custom Tab Bar
To create a user-friendly interface that accommodates more than five tabs without invoking the default “More” tab, we designed a custom tab bar. This custom tab bar allows horizontal scrolling when the number of tabs exceeds the available screen width, ensuring all tabs remain accessible to the user. It mimics the native iOS tab bar’s aesthetics while providing the flexibility we want for Reflect’s design.
Creating the Tab Item View
The TabItem view encapsulates the appearance and behavior of each individual tab in the custom tab bar. It displays the tab’s icon and title, adjusts its styling based on whether it’s selected, and includes an indicator to highlight the active tab.
struct TabItem: View {
let tab: TabOption
let isSelected: Bool
let iconSize: CGFloat
let indicatorWidth: CGFloat
let indicatorHeight: CGFloat
var body: some View {
ZStack(alignment: .bottom) {
VStack(spacing: 0) {
Spacer()
.frame(height: 14)
Image(systemName: tab.icon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: iconSize)
.symbolVariant(
isSelected ? .fill : .none
)
.opacity(
isSelected ? 1.0 : 0.5
)
Spacer()
Text(tab.rawValue)
.font(.caption)
.foregroundColor(
isSelected ? .primary : .secondary
)
.lineLimit(1)
Spacer()
.frame(height: 12)
}
// Indicator
Rectangle()
.frame(
width: indicatorWidth,
height: indicatorHeight
)
.foregroundStyle(
isSelected ? .blue : .clear
)
.shadow(
color: isSelected ?
.blue.opacity(0.5) :
.clear,
radius: 5,
x: 0,
y: -1
)
.padding(.bottom, 3)
}
.contentShape(Rectangle())
.onTapGesture {
// Handle tab selection
}
}
}
Designing the Custom Tab Bar
Next, we create the CustomTabBar view:
struct CustomTabBar: View {
var tabs: [TabOption]
var selectedTab: TabOption
var onTabTapped: (TabOption) -> Void
// Fixed measurements
private let tabItemWidth: CGFloat = 70
private let contentHeight: CGFloat = 64
private let indicatorWidth: CGFloat = 32
private let indicatorHeight: CGFloat = 3
private let iconSize: CGFloat = 20
private let capsulePadding: CGFloat = 16
private func totalRequiredWidth(tabCount: Int) -> CGFloat {
let tabsWidth = CGFloat(tabCount) * tabItemWidth
let spacingWidth = CGFloat(tabCount - 1) * 8.0
return tabsWidth + spacingWidth + (capsulePadding * 2)
}
var body: some View {
GeometryReader { geometry in
let requiredWidth = totalRequiredWidth(tabCount: tabs.count)
let shouldScroll = requiredWidth > geometry.size.width
ZStack(alignment: .top) {
// Background extending to bottom with rounded corners
VStack(spacing: 0) {
Color(UIColor.systemGray5)
.frame(height: 0.5)
Rectangle()
.fill(.ultraThinMaterial)
}
.ignoresSafeArea(edges: .bottom)
// Tab content area
VStack(spacing: 0) {
let content = makeTabContent(shouldScroll: shouldScroll)
.padding(.horizontal, capsulePadding)
if shouldScroll {
makeScrollableContent(content: content)
} else {
content
.frame(height: contentHeight)
}
}
.frame(height: contentHeight)
}
}
.frame(height: contentHeight + 0.5)
}
// MARK: - Helper Views
private func makeTabContent(shouldScroll: Bool) -> some View {
HStack(spacing: shouldScroll ? 8 : nil) {
ForEach(Array(zip(tabs.indices, tabs)), id: \.1) { index, tab in
if !shouldScroll && index > 0 {
Spacer()
}
TabItem(
tab: tab,
isSelected: selectedTab == tab,
iconSize: iconSize,
indicatorWidth: indicatorWidth,
indicatorHeight: indicatorHeight
)
.frame(width: tabItemWidth)
.id(tab)
.onTapGesture {
withAnimation(.spring(response: 0.3)) {
onTabTapped(tab)
}
}
}
}
}
private func makeScrollableContent(content: some View) -> some View {
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
content
}
.frame(height: contentHeight)
.onChange(of: selectedTab) { newTab in
withAnimation {
proxy.scrollTo(newTab, anchor: .center)
}
}
.mask(
HStack(spacing: 0) {
makeGradientMask(
colors: [.clear, .white],
width: capsulePadding * 2
)
Rectangle()
makeGradientMask(
colors: [.white, .clear],
width: capsulePadding * 2
)
}
)
}
}
private func makeGradientMask(
colors: [Color],
width: CGFloat
) -> some View {
LinearGradient(
gradient: Gradient(stops: [
.init(color: colors[0], location: 0),
.init(color: colors[1], location: 1)
]),
startPoint: .leading,
endPoint: .trailing
)
.frame(width: width)
}
}
Our CustomTabBar view is responsible for displaying all the tabs and handling user interactions:
- Dynamic Layout: It calculates the total required width for all tabs and decides whether to enable horizontal scrolling. If the tabs fit within the screen width, they are displayed in a fixed layout. Otherwise, they are placed within a scrollable
ScrollView. - Aesthetic Styling: We use
ultraThinMaterialfor the background to achieve a translucent effect that blends with the app’s content. A top border line provides a subtle separation from the content above. - User Interaction: Each
TabItemresponds to tap gestures, updating the selected tab with an animation. When scrolling is enabled, the tab bar automatically scrolls to bring the selected tab close to center, revealing the other available tabs when possible. - Visual Indicators: The selected tab is visually distinguished with a filled icon and a colored indicator bar beneath it.
- Edge Masking: When the tabs are scrollable, we apply gradient masks to the edges of the tab bar to subtly indicate that more tabs are available off-screen.
Integrating the Tab Manager and Custom Tab Bar
Now, we bring everything together in the ContentView:
struct ContentView: View {
@StateObject private var tabManager = TabManager()
var body: some View {
NavigationView {
ZStack(alignment: .bottom) {
TabView(
selection: $tabManager.selectedTab
) {
ForEach(
tabManager.tabsToCache,
id: \.self
) { tab in
TabContentView(
tab: tab,
tabManager: tabManager
)
.tag(tab)
}
}
.edgesIgnoringSafeArea(.bottom)
.onChange(
of: tabManager.selectedTab
) { newTab in
tabManager.updateTabsToCache(
for: newTab
)
}
makeCustomTabBar()
}
.ignoresSafeArea(.keyboard)
}
}
private func makeCustomTabBar() -> some View {
CustomTabBar(
tabs: TabOption.allCases,
selectedTab: tabManager.selectedTab
) { tab in
withAnimation {
tabManager.selectedTab = tab
}
}
}
}
Handling Tab Selection
When a user taps on a tab in the CustomTabBar, the following occurs:
- Tab Selection Update: The
selectedTabproperty inTabManageris updated to the newly selected tab. - Tabs Display Update: The
updateTabsToCache(for:)method is called, which adjusts thetabsToCachearray to include the new tab while ensuring the total number of tabs in theTabViewdoes not exceed five.
This process ensures a smooth and responsive user experience, with the tab bar and content views always reflecting the current state.
Displaying Tab Content
Finally, we define the content for each tab:
struct TabContentView: View {
let tab: TabOption
@ObservedObject var tabManager: TabManager
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Spacer()
Text(tab.rawValue)
.font(.largeTitle)
VStack(alignment: .leading, spacing: 10) {
Text("Current TabView Tabs:")
.font(.headline)
ForEach(tabManager.tabsToCache, id: \.self) { tab in
Text("• \(tab.rawValue)")
.foregroundColor(.secondary)
}
}
.font(.system(.body, design: .monospaced))
Spacer()
}
.padding()
}
}
}
This view displays the name of the selected tab and lists the tabs currently cached by the TabView.
Benefits and Considerations
Advantages of This Approach
- Customizable Tab Bar: We have full control over the appearance and behavior of the tab bar.
- No “More” Tab: By limiting the number of tabs in the
TabView, we avoid the automatic “More” tab. - State Management: The
TabManagerensures that tab state is preserved as needed for the views we care about most.
Trade-Offs and Potential Issues
- State Loss for Rotated Tabs: Tabs rotated out of the
TabViewmay lose their state. If maintaining state is critical, additional state management strategies may be necessary. - Increased Complexity: This solution is more complex than using the standard
TabViewdirectly.
End Result
We now have a beautiful tab bar.
We successfully overcame the limitations imposed by SwiftUI’s TabView when dealing with more than five tabs.
Feedback and Contributions
We welcome your feedback! If you notice any mistakes or have suggestions for improvements, please let us know. Pull requests on our GitHub repository are greatly appreciated.

Leave a comment