Customized TabBar in SwiftUI

Building a tab bar in SwiftUI often starts with the simplicity of TabView, but real-world apps quickly demand more flexibility than the default component provides. In this post, we’ll take a practical, step-by-step journey from the standard implementation to a fully customized, animated tab bar—exploring how to progressively enhance styling, restructure layout, and ultimately replace built-in behavior with a state-driven solution. Along the way, we’ll touch on core SwiftUI concepts like data flow, view composition, and animation, using a familiar UI pattern to bridge the gap between basic usage and building polished, production-ready interfaces.

Basic SwiftUI TabBar

The minimum code for implementening a TabBar is just following:

struct ContentView: View {
    var body: some View {
        TabView {
            MainScrollStoryView()
                .tabItem {
                    Label("Home", systemImage: "doc.text.fill")
                }
            
            ProfileView()
                .tabItem {
                    Label("Profile", systemImage: "person.circle.fill")
                }

            SettingsView()
                .tabItem {
                    Label("Settings", systemImage: "gearshape.fill")
                }
        }
        .accentColor(.blue)
    }
}

It defines a tab-based interface in SwiftUI where a TabView presents three sections—Home, Profile, and Settings—each linked to its own view (MainScrollStoryView, ProfileView, and SettingsView); every tab is configured with a Label combining a title and an SF Symbol icon, and the .accentColor(.blue) modifier sets the active tab’s tint color to blue, so when users tap a tab, the corresponding view is displayed while the selected tab is visually highlighted.

This is one of the views attached to one of the tabs:

 

struct MainScrollStoryView: View {
    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(alignment: .leading, spacing: 25) {
                    Text("The Future of SwiftUI")
                        .font(.system(size: 34, weight: .bold, design: .rounded))
                    ForEach(1...25, id: \.self) { index in
                        VStack(alignment: .leading, spacing: 10) {
                            Text("Chapter \(index)")
                                .font(.headline)
                                .foregroundColor(.secondary)
                            
                            Text("This is a demonstration of a ScrollView inside a TabView. In SwiftUI, the TabBar remains anchored at the bottom while the content flows behind it. This specific block of text is part of iteration number \(index), ensuring that we have enough height to trigger the scrolling physics of the device.")
                                .font(.body)
                                .lineSpacing(6)
                        }
                        Divider()
                    }
                }
                .padding()
            }
            .navigationTitle("Main Feed")
        }
    }
}

As in UIKit, there’s no issue embedding one container controller (e.g., a NavigationStack) inside another container controller (such as a TabBar). And finally the result:

 

Adapt TabBar to your System Dessign

It’s quite common, especially on projects of a certain scale, to work with a UX/UI team that defines and governs the application’s visual design and user experience. In most of the cases they respect HIG (Human Interface Guidelines), so is just customize basically icons, fonts and colors.

struct CustomStyledTabView: View {
    init() {
        let appearance = UITabBarAppearance()
        appearance.configureWithOpaqueBackground()
        appearance.backgroundColor = .systemBackground
        
        let selectedAttributes: [NSAttributedString.Key: Any] = [
            .foregroundColor: UIColor.systemPurple,
            .font: UIFont.boldSystemFont(ofSize: 12)
        ]
        
        let normalAttributes: [NSAttributedString.Key: Any] = [
            .foregroundColor: UIColor.gray
        ]
        
        appearance.stackedLayoutAppearance.selected.titleTextAttributes = selectedAttributes
        appearance.stackedLayoutAppearance.selected.iconColor = .systemPurple
        
        appearance.stackedLayoutAppearance.normal.titleTextAttributes = normalAttributes
        appearance.stackedLayoutAppearance.normal.iconColor = .gray

        UITabBar.appearance().standardAppearance = appearance
        UITabBar.appearance().scrollEdgeAppearance = appearance
    }

    var body: some View {
        TabView {
            MainScrollStoryView()
                .tabItem {
                    Label("Home", systemImage: "doc.text.fill")
                }
            ProfileView()
                .tabItem {
                    Label("Profile", systemImage: "person.circle.fill")
                }
            
            SettingsView()
                .tabItem {
                    Label("Settings", systemImage: "gearshape.fill")
                }
        }
    }
} 

This SwiftUI View struct customizes the global appearance of all tab bars in the app by configuring a UITabBarAppearance instance during initialization. It sets an opaque background using the system background color, then defines distinct text and icon styles for selected and unselected tab items—selected items appear in purple with a bold font, while unselected ones are gray. These styling attributes are applied specifically to the standard stacked layout used in bottom tab bars. Finally, the configured appearance is assigned to both the standardAppearance and scrollEdgeAppearance of UITabBar via the UIAppearance proxy, ensuring consistent styling across the entire app.

Fully customization

Sometimes the default customization options provided by the SDK aren’t sufficient, and you need to push beyond them—for example, when you want to introduce additional animations.

struct FloatingTabBarView: View {
    @State private var selectedTab = 0
    @State private var isTabBarVisible = true
    
    var body: some View {
        ZStack(alignment: .bottom) {
            NavigationStack {
                MainScrollStoryView()
                .navigationTitle("Auto-hide Bar")
                .onScrollGeometryChange(for: CGFloat.self) { geo in
                    geo.contentOffset.y
                } action: { oldValue, newValue in
                    let scrollingDown = newValue > oldValue
                    let isAtTop = newValue <= 0
                    
                    withAnimation(.easeInOut(duration: 0.3)) {
                        if isAtTop {
                            isTabBarVisible = true
                        } else if scrollingDown {
                            isTabBarVisible = false
                        } else {
                            isTabBarVisible = true
                        }
                    }
                }
            }
            if isTabBarVisible {
                CustomBar(selectedTab: $selectedTab)
                    .transition(.move(edge: .bottom).combined(with: .opacity))
                    .zIndex(1)
            }
        }
    }
}

struct CustomBar: View {
    @Binding var selectedTab: Int
    
    var body: some View {
        HStack(spacing: 40) {
            TabBarButton(index: 0, icon: "house.fill", selectedTab: $selectedTab)
            TabBarButton(index: 1, icon: "magnifyingglass", selectedTab: $selectedTab)
            TabBarButton(index: 2, icon: "person.fill", selectedTab: $selectedTab)
        }
        .padding(.horizontal, 30)
        .padding(.vertical, 15)
        .background(.ultraThinMaterial)
        .clipShape(Capsule())
        .shadow(color: .black.opacity(0.15), radius: 10, y: 5)
        .padding(.bottom, 20)
    }
}

struct TabBarButton: View {
    let index: Int
    let icon: String
    @Binding var selectedTab: Int
    
    var body: some View {
        Button(action: { selectedTab = index }) {
            Image(systemName: icon)
                .font(.system(size: 22))
                .foregroundColor(selectedTab == index ? .blue : .gray)
        }
    }
}

This code defines a custom SwiftUI tab bar system that overlays a floating, animated tab bar on top of a scrollable view. The main FloatingTabBarView uses a ZStack to position content and the tab bar, embedding a NavigationStack with a scrollable MainScrollStoryView. It listens to scroll position changes via onScrollGeometryChange, comparing the previous and current vertical offsets to determine scroll direction. Based on this, it toggles the isTabBarVisible state with a smooth animation: the tab bar hides when the user scrolls down, reappears when scrolling up, and stays visible when the content is at the top.

The CustomBar and TabBarButton components define the visual and interactive behavior of the floating tab bar. CustomBar lays out three tab buttons horizontally inside a capsule-shaped container with a blurred background (ultraThinMaterial) and shadow, giving it a modern floating appearance. Each TabBarButton updates the shared selectedTab binding when tapped and visually reflects its active state by switching icon color between blue (selected) and gray (unselected). The tab bar itself animates in and out using a combined slide-from-bottom and fade transition, creating a polished, dynamic UI effect.

func combineUsage() {
    NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
        .sink { _ in
            print("App active")
        }
        .store(in: &cancellables)
}

Conclusions

Across the three examples, we’ve explored how to implement a TabBar, apply basic customization, and extend its behavior beyond the standard capabilities.

You can find the source code for this example in the following GitHub repository.

References

Copyright © 2024-2025 JaviOS. All rights reserved