Swift and SwiftUI tutorials for Swift Developers

Customize TabView in SwiftUI

In the mobile development ecosystem, navigation is the backbone of the user experience. For any iOS Developer, mastering the main navigation component is a must. We are talking, of course, about the TabView.

While SwiftUI has greatly facilitated the creation of declarative interfaces since its launch, customizing TabView in SwiftUI has historically been a challenge. The native appearance is functional and consistent with the system, but designers and brands often require a unique visual identity that breaks away from Apple’s standard.

In this comprehensive Swift programming tutorial, you won’t just learn how to change an icon’s color. We will deconstruct the TabView, explore the modern native APIs available in Xcode, and finally, build a fully custom navigation bar from scratch that works across the entire Apple ecosystem: iOS, macOS, and watchOS.


1. The Native TabView: Fundamentals and Limitations

Before breaking the rules, we must know them. The TabView in Swift is the equivalent of UIKit’s UITabBarController. Its function is to allow the user to switch between different sub-views or “screens” of the application.

The basic structure that every developer knows is as follows:

struct MainView: View {
    var body: some View {
        TabView {
            Text("Home Screen")
                .tabItem {
                    Label("Home", systemImage: "house")
                }
            
            Text("Settings")
                .tabItem {
                    Label("Settings", systemImage: "gear")
                }
        }
    }
}

Why customize it?

Apple’s standard design is excellent for accessibility. However, there are valid reasons to seek customization of the TabView in SwiftUI:

  • Branding: The need to use corporate color palettes or specific typography.
  • Advanced UX: Floating central action buttons (common in apps like Instagram or TikTok).
  • Animations: Enriched visual feedback when switching tabs beyond the simple blue tint change.

2. Native Customization (The “Apple” Way)

Since iOS 15 and 16, Apple has introduced modifiers in SwiftUI that allow us to alter the appearance without resorting to complex hacks. If your goal is simply to change colors or the background, you do not need to create a custom view.

Here is how to do it efficiently in Xcode using UITabBarAppearance to have full control over blur and colors:

struct NativeCustomTabView: View {
    
    init() {
        // Deep customization using underlying UIKit
        let appearance = UITabBarAppearance()
        appearance.configureWithOpaqueBackground()
        appearance.backgroundColor = UIColor(Color.black) // Solid black background
        
        // Customize unselected item colors
        appearance.stackedLayoutAppearance.normal.iconColor = UIColor.gray
        appearance.stackedLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: UIColor.gray]
        
        // Apply the appearance
        UITabBar.appearance().standardAppearance = appearance
        UITabBar.appearance().scrollEdgeAppearance = appearance
    }
    
    var body: some View {
        TabView {
            Color.blue.ignoresSafeArea()
                .tabItem { Label("Home", systemImage: "house") }
            
            Color.red.ignoresSafeArea()
                .tabItem { Label("Profile", systemImage: "person") }
        }
        .tint(.white) // Selected item color (Native SwiftUI)
    }
}

Note for the iOS Developer: Although we are in SwiftUI, using UITabBar.appearance() remains the most robust way to affect the base style of the component until Apple exposes more pure native APIs.


3. Creating a Fully Custom TabView

This is where we enter advanced Swift programming. If the design requires a floating bar, animated icons, or non-rectangular shapes, we must hide the native bar and create our own.

Step 1: Define Tabs with Enums

To keep our code clean and ensure Type Safety, we will use an Enum to manage the selection state.

enum Tab: String, CaseIterable {
    case home = "house"
    case search = "magnifyingglass"
    case notifications = "bell"
    case profile = "person"
    
    var title: String {
        switch self {
        case .home: return "Home"
        case .search: return "Search"
        case .notifications: return "Alerts"
        case .profile: return "Profile"
        }
    }
}

Step 2: The Layout Architecture (ZStack)

The trick to fully customize TabView in SwiftUI is to use a ZStack. The bottom layer will be the actual TabView (to maintain view state management), and the top layer will be our custom bar placed at the bottom.

Important: We will use .toolbar(.hidden, for: .tabBar) to hide Apple’s native bar.

struct CustomTabBarContainer: View {
    @State private var selectedTab: Tab = .home
    
    var body: some View {
        ZStack(alignment: .bottom) {
            // Layer 1: The main content
            TabView(selection: $selectedTab) {
                NavigationStack { Color.red.ignoresSafeArea() }
                    .tag(Tab.home)
                
                Color.blue.ignoresSafeArea()
                    .tag(Tab.search)
                
                Color.green.ignoresSafeArea()
                    .tag(Tab.notifications)
                
                Color.yellow.ignoresSafeArea()
                    .tag(Tab.profile)
            }
            .toolbar(.hidden, for: .tabBar) // Hides the native bar (iOS 16+)
            
            // Layer 2: Our Custom Floating Bar
            CustomTabBar(selectedTab: $selectedTab)
                .padding(.bottom, 0)
        }
        .ignoresSafeArea(.keyboard, edges: .bottom) 
    }
}

Step 3: Designing the Visual Component (CustomTabBar)

Here we will create a floating bar with a “Glassmorphism” effect, using Material and .spring animations.

struct CustomTabBar: View {
    @Binding var selectedTab: Tab
    
    var body: some View {
        HStack {
            ForEach(Tab.allCases, id: \.rawValue) { tab in
                Spacer()
                
                VStack(spacing: 4) {
                    Image(systemName: selectedTab == tab ? tab.rawValue + ".fill" : tab.rawValue)
                        .scaleEffect(selectedTab == tab ? 1.25 : 1.0)
                        .font(.system(size: 22))
                        // Bounce animation (iOS 17+)
                        .symbolEffect(.bounce, value: selectedTab) 
                    
                    if selectedTab == tab {
                        Text(tab.title)
                            .font(.caption2)
                            .transition(.move(edge: .bottom).combined(with: .opacity))
                    }
                }
                .foregroundStyle(selectedTab == tab ? .blue : .gray)
                .onTapGesture {
                    withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) {
                        selectedTab = tab
                    }
                }
                
                Spacer()
            }
        }
        .padding(.vertical, 12)
        .padding(.horizontal, 8)
        .background(.ultraThinMaterial) // Glass effect
        .clipShape(Capsule()) // Rounded shape
        .shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 5)
        .padding(.horizontal, 24)
    }
}

Technical Analysis:

  • @Binding: Allows the child component to modify the parent’s state.
  • .symbolEffect: A new feature in Xcode 15 that allows animating SF Symbols automatically.
  • Capsule + Material: Creates that modern aesthetic that separates your app from standard ones.

4. Multiplatform Adaptability: macOS and watchOS

A good iOS Developer knows that the ecosystem doesn’t end at the iPhone. One of the great advantages of SwiftUI is its multiplatform capability. How do we adapt our TabView?

macOS: The Sidebar

On macOS, a bottom bar is incorrect in terms of UX. The standard is a Sidebar. We will use conditional compilation.

struct UniversalNavigationView: View {
    @State private var selectedTab: Tab = .home

    var body: some View {
        #if os(macOS)
        NavigationSplitView {
            List(Tab.allCases, id: \.self, selection: $selectedTab) { tab in
                Label(tab.title, systemImage: tab.rawValue)
            }
            .navigationTitle("Menu")
        } detail: {
            // Detail view based on selection
            Text("View: \(selectedTab.title)")
        }
        #else
        // On iOS we use our custom bar
        CustomTabBarContainer()
        #endif
    }
}

watchOS: Paging Style

On the Apple Watch, space is limited. Trying to squeeze in a complex bar is a mistake. Use .tabViewStyle.

struct WatchMainView: View {
    var body: some View {
        TabView {
            // Your views here
        }
        .tabViewStyle(.verticalPage) // Modern vertical style for watchOS 10+
    }
}

5. Troubleshooting and Accessibility

When developing custom solutions in Xcode, you will face the Safe Area issue: your content may be hidden behind the floating bar. The modern solution is to use safeAreaInset.

<pre class="wp-block-syntaxhighlighter-code">ScrollView {
    // Long content
    ForEach(0..<20) { _ in 
        Text("List item") 
            .padding()
    }
}
.safeAreaInset(edge: .bottom) {
    // We create a "phantom" space of the same height as our bar
    Color.clear.frame(height: 80)
}</pre>

Accessibility (VoiceOver)

By not using the native control, you lose automatic accessibility. You must add it manually to be an inclusive developer:

VStack {
    // Icon and Text
}
.accessibilityElement(children: .combine)
.accessibilityLabel(tab.title)
.accessibilityAddTraits(selectedTab == tab ? .isSelected : .isButton)
.accessibilityHint("Double tap to go to \(tab.title)")

Conclusion

Customizing the TabView in SwiftUI has gone from being an almost impossible task to being a creative and flexible process. As we have seen, the key is not to fight the system, but to understand how to compose views using ZStack and apply solid Swift programming principles.

Whether you are targeting iOS, macOS, or watchOS, the modular architecture we have discussed will allow you to scale your application without technical debt. Now it’s your turn to open Xcode and take your app’s navigation to the next level.

If you have any questions about this article, please contact me and I will be happy to help you 🙂. You can contact me on my X profile or on my Instagram profile.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

Gemini CLI with Xcode

Next Article

How to use Camera in SwiftUI

Related Posts