7

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:

  • It has a rounded border around it, which means it looks awful with any sort of background color in the subviews.
  • It doesn't support tab icons.
  • It's very limited in terms of customization.
  • It's buggy (sometimes it doesn't switch views as expected).
  • It's pretty dated-looking.

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)
        }
    }
}
TheNeil
  • 3,321
  • 2
  • 27
  • 52

3 Answers3

13

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:

  • It uses a basic extension to Color so it can use system background colors, rather than hard-coding.
  • The only slightly hacky part is the extra background & shadow modifiers, which are needed to prevent SwiftUI applying the shadow to every subview(!). Of course, if you don't want a shadow, you can just remove all of those lines (including the zIndex).

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)
                    )
                )
            ]
        )
    }
}
TheNeil
  • 3,321
  • 2
  • 27
  • 52
  • Since you just replace the tabView inside tabViews array with a @State on each tab change your tabView's view will be re-rendered. With system provided TabView its different, it holds the view and wont re-render on changes. But actually i could not find any better solution than this if we want to use custom TabBar. – Balázs Papp Oct 09 '20 at 20:45
  • Thanks for the input. Yeah, it's a great point about view re-rendering, and agreed, it's not ideal. Could likely be made more straightforward with generics and `@ViewBuilder` (such as I've done in this example https://stackoverflow.com/a/63679567/2272431), but I'm not sure that would provide a huge amount of benefit. Hopefully macOS will support improved tab views themselves one day! – TheNeil Oct 09 '20 at 20:56
  • 1
    Great control TheNeil. I adapted it to my needs. – alex.bour Mar 14 '23 at 13:37
  • Is there a way to implement reordering of the tabs using drag and drop? – iPadawan Apr 04 '23 at 10:27
  • Using XCode 14.2 with Swift 5 and macOS 12.6, I did see no Icons. I did add systemName: in the line Image(self.tabIconNames[index]) to get Image(systemName: self.tabIconNames[index]) – iPadawan Apr 04 '23 at 10:53
  • Yeah, as mentioned in my answer, “The icons and their names are custom, so you'll have to use your own replacements.” – TheNeil Apr 04 '23 at 12:58
  • I imagine it would certainly be possible to implement drag & drop re-ordering; but that would be no minor change, and rather outside of the core question scope. Sorry. You're welcome to give it a try and post under a separate question though. – TheNeil Apr 04 '23 at 13:49
2

You could simply hide the borders of TabView by applying negative padding and using your own control view to set the visible tab item.

struct MyView : View
{
    @State private var selectedTab : Int = 1

    var body: some View
    {
        HSplitView
        {
            Picker("Tab Selection", selection: $selectedTab)
            {
                Text("A")
                    .tag(1)

                Text("B")
                    .tag(2)
            }


            TabView(selection: $selectedTab)
            {
                ViewA()
                    .tag(1)

                ViewB()
                    .tag(2)
            }
            // applying negative padding to hide the ugly frame
            .padding(EdgeInsets(top: -26.5, leading: -3, bottom: -3, trailing: -3))
        }
    }
}

Of course, this hack works only as long as Apple makes no changes to the visual design of TabView.

Kai Oezer
  • 104
  • 1
  • 3
1

In reply to TheNeil (I don't have enough reputation to add a comment):

I like your solution, modified it a little bit.

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 {
        VStack {
            Spacer()
                .frame(height: 5.0)
            HStack {
                Spacer()
                    .frame(width: 50)
                ForEach(0..<tabText.count) { index in
                    VStack {
                        Image(systemName: self.tabIconNames[index])
                            .font(.system(size: 40))
                        Text(self.tabText[index])
                    }
                    .frame(width: 65, height: 65)
                    .padding(5)
                    .foregroundColor(self.selection == index ? Color.accentColor : Color.primary)
                    .background(Color.secondaryBackgroundColor)
                    .onTapGesture {
                        self.selection = index
                    }
                    .overlay(
                        RoundedRectangle(cornerRadius: 25)
                            .fill(self.selection == index ? Color.backgroundColor.opacity(0.33) : Color.red.opacity(0.0))
                    )               .onTapGesture {
                        self.selection = index
                    }

                }
                Spacer()
            }
            .frame(alignment: .leading)
            .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, alignment: .top)
        
            if (self.tabBarPosition == .bottom) {
            tabBar
            }
    }
    .padding(0)
    }
}

I added a screenshot, no clue how to make it display inline. Fixed it

modified CustomTabView

a1cd
  • 17,884
  • 4
  • 8
  • 28
24unix
  • 11
  • 1
  • 5