macosswiftuitabviewnstabview

SwiftUI: Custom Tab View for macOS & iOS


Is there a simple way to get a more customizable tab bar view using SwiftUI? I'm mainly asking from the perspective of macOS (though one that works on any system would be ideal), because the macOS implementation of the standard one has various issues:

Standard macOS tab bar with SwiftUI

Current code:

import SwiftUI

struct SimpleTabView: View {

    @State private var selection = 0

    var body: some View {

        TabView(selection: $selection) {

            HStack {
                Spacer()
                VStack {
                    Spacer()
                    Text("First Tab!")
                    Spacer()
                }
                Spacer()
            }
                .background(Color.blue)
                .tabItem {
                    VStack {
                        Image("icons.general.home")
                        Text("Tab 1")
                    }
                }
                .tag(0)

            HStack {
                Spacer()
                VStack {
                    Spacer()
                    Text("Second Tab!")
                    Spacer()
                }
                Spacer()
            }
                .background(Color.red)
                .tabItem {
                    VStack {
                        Image("icons.general.list")
                        Text("Tab 2")
                    }
                }
                .tag(1)

            HStack {
                Spacer()
                VStack {
                    Spacer()
                    Text("Third Tab!")
                    Spacer()
                }
                Spacer()
            }
                .background(Color.yellow)
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .tabItem {
                    VStack {
                        Image("icons.general.cog")
                        Text("Tab 3")
                    }
                }
                .tag(2)
        }
    }
}

Solution

  • To address this, I've put together the following simple custom view which provides a more similar tab interface to iOS, even when running on Mac. It works just by taking an array of tuples, each one outlining the tab's title, icon name and content.

    It works in both Light & Dark mode, and can be run on either macOS or iOS / iPadOS / etc., but you might want to just use the standard TabView implementation when running on iOS; up to you.

    It also includes a parameter so you can position the bar at either the top or bottom, depending on preference (across the top fits better with macOS guidelines).

    Here's an example of the result (in Dark Mode):

    Custom Tab Bar, running on macOS

    Here's the code. Some notes:

    Swift v5.1:

    import SwiftUI
    
    public extension Color {
    
        #if os(macOS)
        static let backgroundColor = Color(NSColor.windowBackgroundColor)
        static let secondaryBackgroundColor = Color(NSColor.controlBackgroundColor)
        #else
        static let backgroundColor = Color(UIColor.systemBackground)
        static let secondaryBackgroundColor = Color(UIColor.secondarySystemBackground)
        #endif
    }
    
    public struct CustomTabView: View {
        
        public enum TabBarPosition { // Where the tab bar will be located within the view
            case top
            case bottom
        }
        
        private let tabBarPosition: TabBarPosition
        private let tabText: [String]
        private let tabIconNames: [String]
        private let tabViews: [AnyView]
        
        @State private var selection = 0
        
        public init(tabBarPosition: TabBarPosition, content: [(tabText: String, tabIconName: String, view: AnyView)]) {
            self.tabBarPosition = tabBarPosition
            self.tabText = content.map{ $0.tabText }
            self.tabIconNames = content.map{ $0.tabIconName }
            self.tabViews = content.map{ $0.view }
        }
        
        public var tabBar: some View {
            
            HStack {
                Spacer()
                ForEach(0..<tabText.count) { index in
                    HStack {
                        Image(self.tabIconNames[index])
                        Text(self.tabText[index])
                    }
                    .padding()
                    .foregroundColor(self.selection == index ? Color.accentColor : Color.primary)
                    .background(Color.secondaryBackgroundColor)
                    .onTapGesture {
                        self.selection = index
                    }
                }
                Spacer()
            }
            .padding(0)
            .background(Color.secondaryBackgroundColor) // Extra background layer to reset the shadow and stop it applying to every sub-view
            .shadow(color: Color.clear, radius: 0, x: 0, y: 0)
            .background(Color.secondaryBackgroundColor)
            .shadow(
                color: Color.black.opacity(0.25),
                radius: 3,
                x: 0,
                y: tabBarPosition == .top ? 1 : -1
            )
            .zIndex(99) // Raised so that shadow is visible above view backgrounds
        }
        public var body: some View {
            
            VStack(spacing: 0) {
                
                if (self.tabBarPosition == .top) {
                    tabBar
                }
                
                tabViews[selection]
                    .padding(0)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                
                if (self.tabBarPosition == .bottom) {
                    tabBar
                }
            }
            .padding(0)
        }
    }
    

    And here's an example of how you'd use it. Obviously, you could also pass it an entirely custom subview, rather than building them on the fly like this. Just make sure to wrap them inside that AnyView initializer.

    The icons and their names are custom, so you'll have to use your own replacements.

    struct ContentView: View {
        
        var body: some View {
            CustomTabView(
                tabBarPosition: .top,
                content: [
                    (
                        tabText: "Tab 1",
                        tabIconName: "icons.general.home",
                        view: AnyView(
                            HStack {
                                Spacer()
                                VStack {
                                    Spacer()
                                    Text("First Tab!")
                                    Spacer()
                                }
                                Spacer()
                            }
                            .background(Color.blue)
                        )
                    ),
                    (
                        tabText: "Tab 2",
                        tabIconName: "icons.general.list",
                        view: AnyView(
                            HStack {
                                Spacer()
                                VStack {
                                    Spacer()
                                    Text("Second Tab!")
                                    Spacer()
                                }
                                Spacer()
                            }
                            .background(Color.red)
                        )
                    ),
                    (
                        tabText: "Tab 3",
                        tabIconName: "icons.general.cog",
                        view: AnyView(
                            HStack {
                                Spacer()
                                VStack {
                                    Spacer()
                                    Text("Third Tab!")
                                    Spacer()
                                }
                                Spacer()
                            }
                            .background(Color.yellow)
                        )
                    )
                ]
            )
        }
    }