Overcoming TabView Limitations in SwiftUI

Reflect’s custom tab bar

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:

  1. 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.
  2. 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.
  3. 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:

  1. Implementing a Custom Tab Bar with Horizontal Scrolling
  2. Limiting the Number of Tabs in TabView to Five or Fewer
  3. 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 TabView as 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 of TabOption that represents the tabs currently cached in the TabView. 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 the tabsToCache array whenever a new tab is selected. If the new tab isn’t a default tab, it includes it in tabsToCache, 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 ultraThinMaterial for 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 TabItem responds 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:

  1. Tab Selection Update: The selectedTab property in TabManager is updated to the newly selected tab.
  2. Tabs Display Update: The updateTabsToCache(for:) method is called, which adjusts the tabsToCache array to include the new tab while ensuring the total number of tabs in the TabView does 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 TabManager ensures 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 TabView may 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 TabView directly.

End Result

We now have a beautiful tab bar.

Reflect’s custom 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.


Comments

Leave a comment