How to create a custom Tab Bar in SwiftUI

Share on facebook
Share on twitter
Share on pinterest
Updated for Xcode 12 and SwiftUI 2.0 ✅

Hello and welcome to a new SwiftUI tutorial! In SwiftUI, it’s super easy to embed your app’s views into a tab bar using a TabView. However, We are limited to the standard tab bar design that Apple provides us with. But the in-house tab bar can get boring and sometimes doesn’t offer the functionality we need for our app. In this tutorial, we will learn how to create our own custom and fully customizable tab bar. By the way: we will not use a UITabBarController for this but implement the navigation logic only with SwiftUI and from scratch.

Our finished tab bar will look like this:

With the knowledge from this article you should be able to create almost any tab bar you can imagine using SwiftUI only!

Let’s get started! After opening Xcode 12 and creating a new “App” under “iOS” or “Multiplatform”, we can begin preparing our custom tab bar. For the purpose of this tutorial, we will use the default ContentView.swift file.

We will need three different colors for our tab bar. Let’s put them in our Assets.xcassets folder right now. For our app, we need one color for the big “plus” icon of the tab bar, one for the background of the tab bar, and one for the selected tab bar icon (each one with respect to dark and light mode).

Preparing our ContentView 👨‍💻

By using our tab bar, we will later be able to jump between two different views, a “Home” view, and a “Settings” view. By default, we want to present the “Home” view. This view should simply consist of a Text view reading “Home”. For this, we can replace the String in the default “Hello Word” Text view. We also remove the .padding modifier.

import SwiftUI

 struct ContentView: View {
     var body: some View {
         Text("Home")
     }
 }

We want to place the tab bar at the bottom of the ContentView. Therefore, we have to wrap our Text view into a VStack. To make sure that the Text is always centered, we add two Spacer views.

VStack {
     Spacer()
     Text("Home")
     Spacer()
 }

We almost finished preparing our ContentView. Finally, we want to have the capability to know the width and height of the ContentView depending on the particular device the app runs on. We need to know this to adjust the size of the tab bar dynamically. For this purpose, we wrap our VStack into a GeometryReader.

GeometryReader { geometry in
     VStack {
         Spacer()
         Text("Home")
         Spacer()
     }
 }

The GeometryReader reads out its parent view’s size dimensions. In our case, that should be the ContentView as the overall super-view, the one that covers the entire screen.

Note: Don’t worry if the Text view is not centered anymore after embedding the VStack into the GeometryReader. We will fix this in a moment.

We finished our preparations. Now it’s time to actually design our custom tab bar!

Composing our Tab Bar 🎨🖌

Our tab bar should contain five different icons arranged horizontally. Therefore, we insert an HStack into our VStack below the last Spacer view.

VStack {
     Spacer()
     Text("Home")
     Spacer()
     HStack {
         
     }
 }

Next, we can set our HStack to be always one-eighth of the ContentView‘s height. We also add a white background with a smooth shadow effect to it.

HStack {
     
 }
     .frame(width: geometry.size.width, height: geometry.size.height/8)
     .background(Color("TabBarBackground").shadow(radius: 2))

Each tab bar icon should consist of an Image view and a Text. The icon for the Image view is taken from the SF Symbols catalog. For the first icon we use the “homekit” symbol.

HStack {
     VStack {
         Image(systemName: "homekit")
             .resizable()
             .aspectRatio(contentMode: .fit)
             //Since we have five icons, we want everyone to be one-fifth of the ContentView's width
             .frame(width: geometry.size.width/5, height: geometry.size.height/28)
             .padding(.top, 10)
         Text("Home")
             .font(.footnote)
         Spacer()
     }
 }

Your preview should now look like this:

To make it reusable, outsource CMD-click on the VStack and select “Extract subview”. Call the subview “TabBarIcon”. We need to derive the width, height, symbol name tab name of each TabBarIcon from the ContentView that hosts the particular TabBarIcon instance. So let’s add these properties to the TabBarIcon view and replace the fixed values.

struct TabBarIcon: View {
     
     let width, height: CGFloat
     let systemIconName, tabName: String
     
     
     var body: some View {
         VStack {
             Image(systemName: systemIconName)
                 .resizable()
                 .aspectRatio(contentMode: .fit)
                 .frame(width: width, height: height)
                 .padding(.top, 10)
             Text(tabName)
                 .font(.footnote)
             Spacer()
         }
     }
 }

Next, we need to pass those values to the initalised TabBarIcon inside our ContentView‘s HStack:

HStack {
     TabBarIcon(width: geometry.size.width/5, height: geometry.size.height/28, systemIconName: "homekit", tabName: "Home")
 }

Let’s add four more TabBarIcons to our ContentView!

HStack {
     TabBarIcon(width: geometry.size.width/5, height: geometry.size.height/28, systemIconName: "homekit", tabName: "Home")
     TabBarIcon(width: geometry.size.width/5, height: geometry.size.height/28, systemIconName: "heart", tabName: "liked")
     TabBarIcon(width: geometry.size.width/5, height: geometry.size.height/28, systemIconName: "plus", tabName: "Add")
     TabBarIcon(width: geometry.size.width/5, height: geometry.size.height/28, systemIconName: "waveform", tabName: "Records")
     TabBarIcon(width: geometry.size.width/5, height: geometry.size.height/28, systemIconName: "person.crop.circle", tabName: "Account")
 }

Our tab bar slowly takes shape. However, it is (depending on the device the app runs on) slightly away from the bottom edge. This is because, by default, a SwiftUI view’s content stays inside the so-called safe area. This prevents our tab bar from reaching the lower edge of our screen. But we can explicitly tell our SwiftUI view to exceed the safe area’s lower boundary by adding the following modifier to the overall VStack:

GeometryReader { geometry in
     VStack {
         //...
     }
         .edgesIgnoringSafeArea(.bottom)
 }

Finally, we increase the spacing between our TabBarIcons by adding some negative horizontal .padding to each one.

struct TabBarIcon: View {
     
     //...
     
     
     var body: some View {
         VStack {
             //...
         }
             .padding(.horizontal, -4)
     }
 }

Next, we want to replace the “Plus”-TabBarIcon with a slightly shifted plus button. We create this by inserting a ZStack between the two icons. The first view inside this ZStack should simply be a white Circle:

ZStack {
     Circle()
         .foregroundColor(.white)
         .frame(width: geometry.size.width/7, height: geometry.size.width/7)
         .shadow(radius: 4)
 }

On top of this Circle, we stack our actual plus icon:

ZStack {
     Circle()
         //…
     Image(systemName: "plus.circle.fill")
         .resizable()
         .aspectRatio(contentMode: .fit)
         .frame(width: geometry.size.width/7-6 , height: geometry.size.width/7-6)
         .foregroundColor(Color("DarkPurple"))
 }

To ensure that the whole plus tab bar icon is placed slightly above the tab view, we apply the .offset modifier to our ZStack. Our ZStack should be shifted upwards by half the height of the tab bar. Since our tab view is one-eight as high as the screen, we write:

ZStack {
     //…
 }
     .offset(y: -geometry.size.height/8/2)

Awesome, we finished designing our own custom tab bar. It’s simple as that! Your preview should now look like this:

Implementing the navigation logic ⛓

Now it’s time to write the code for the navigation logic. How to navigate between views independently in SwiftUI is shown in detail in this tutorial. Therefore, we’ll keep it brief in the following.

To be able to switch back and forth between the different views, we need a “manager” that tells our ContentView which view it should display. For this purpose, we create a new File-New-File and select Swift file. We call this file “ViewRouter”. 

Next, we import the SwiftUI framework and create a class named ViewRouter that conforms to the ObservableObject protocol.

import SwiftUI


class ViewRouter: ObservableObject {
   
}

The user should be able switch between four tabs. To represent those tabs in our ViewRouter, we prepare a corresponding enum which we place right below our ViewRouter class.

enum Page {
     case home
     case liked
     case records
     case user
 }

Within our ViewRouter, we need a variable to keep the observing view(s) updated about which Page should currently be displayed. Thus, we declare a variable currentPage. By default, we want to display the .home tab.

class ViewRouter: ObservableObject {
     
     @Published var currentPage: Page = .home
     
 }

With the @Published property wrapper, we notify all observing views to update themselves whenever the Page assigned to the currentView variable changes.

That’s it! We can now use a ViewRouter as a @StateObject within our ContentView.

struct ContentView: View {
     
     @StateObject var viewRouter: ViewRouter
     
     var body: some View {
         //...
     }
 }
 

 struct ContentView_Previews: PreviewProvider {
     static var previews: some View {
         ContentView(viewRouter: ViewRouter())
     }
 }

We create the actual ViewRouter instance in our App struct and pass it to the observing ContentView like this:

@main
 struct CustomTabBarTempApp: App {
     
     @StateObject var viewRouter = ViewRouter()
     
     var body: some Scene {
         WindowGroup {
             ContentView(viewRouter: viewRouter)
         }
     }
 }

As said, make sure you read this tutorial if you don’t really understand the navigation logic we are using!

Depending on the viewRouter’s currentView variable, we want to show an appropriate Text view as a placeholder. Therefore, we replace our current Text view within the two Spacers with the following switch-statement:

Spacer()
 switch viewRouter.currentPage {
 case .home:
     Text("Home")
 case .liked:
     Text("Liked")
 case .records:
     Text("Records")
 case .user:
     Text("User")
 }
 Spacer()

To be able to switch between the different tabs, we need to access the viewRouter from each TabBarIcon. To know which Page is assigned to the particular TabBarIcon instance we also add a property to our TabBarIcon struct.

struct TabBarIcon: View {
     
     @StateObject var viewRouter: ViewRouter
     let assignedPage: Page
     
     //...
     
     
     var body: some View {
         //...
     }
 }

Now we need to update the TabBarIcon initializations in our ContentView.

HStack {
     TabBarIcon(viewRouter: viewRouter, assignedPage: .home, width: geometry.size.width/5, height: geometry.size.height/28, systemIconName: "homekit", tabName: "Home")
     TabBarIcon(viewRouter: viewRouter, assignedPage: .liked, width: geometry.size.width/5, height: geometry.size.height/28, systemIconName: "heart", tabName: "Liked")
     ZStack {
         //…
     }
         .offset(y: -geometry.size.height/8/2)
     TabBarIcon(viewRouter: viewRouter, assignedPage: .records, width: geometry.size.width/5, height: geometry.size.height/28, systemIconName: "waveform", tabName: "Records")
     TabBarIcon(viewRouter: viewRouter, assignedPage: .user, width: geometry.size.width/5, height: geometry.size.height/28, systemIconName: "person.crop.circle", tabName: "Account")
 }

We can enable the user to navigate between the different tabs by adding a tap gesture to the VStack in the TabBarIcon view struct that assigns the viewRouter’s currentView variable to the assignedPage.

struct TabBarIcon: View {
     
     //...
     
     
     var body: some View {
         VStack {
             //...
         }
             //...
             .onTapGesture {
                 viewRouter.currentPage = assignedPage
             }
     }
 }

The @Published property wrapper’s functionality causes our observing ContentView to rebuild itself with eventually showing us the corresponding Text view!

Awesome, we’re now able to jump between the different TabBarIcons by tapping on them!

To indicate to the user which view is currently shown, we can conditionally highlight the corresponding TabBarIcon. For example, when our “Home” view is currently being shown, we want the corresponding TabBarIcon with its Image and Text view to be darkened. Otherwise, we want it to be gray (and vice versa for dark mode). We can achieve this by adding the following modifier to it:

struct TabBarIcon: View {
     
     //...
     
     
     var body: some View {
         VStack {
             //...
         }
             //...
             .foregroundColor(viewRouter.currentPage == assignedPage ? Color("TabBarHighlight") : .gray)
     }
 }

Awesome, our tab bar now indicates which view is currently being shown!

Creating the pop-up menu ➕

Finally, we want to display a cool pop-up menu when the user taps the plus sign icon.

For keeping track of whether the menu should be displayed, we add a corresponding @State property to our ContentView, right below our viewRouter @StateObject.

@State var showPopUp = false

When this State is true, we want to display the menu on top of our tab bar (we will offset its position in a moment). Therefore, we wrap our HStack into a ZStack.

ZStack {
     HStack {
         //…
     }
         //…
 }

For the menu itself, you can add the following struct right below your ContentViews_Previews struct:

struct PlusMenu: View {
   
  let widthAndHeight: CGFloat
   
  var body: some View {
    HStack(spacing: 50) {
      ZStack {
        Circle()
          .foregroundColor(Color("DarkPurple"))
          .frame(width: widthAndHeight, height: widthAndHeight)
        Image(systemName: "record.circle")
          .resizable()
          .aspectRatio(contentMode: .fit)
          .padding(15)
          .frame(width: widthAndHeight, height: widthAndHeight)
          .foregroundColor(.white)
      }
      ZStack {
        Circle()
          .foregroundColor(Color("DarkPurple"))
          .frame(width: widthAndHeight, height: widthAndHeight)
        Image(systemName: "folder")
          .resizable()
          .aspectRatio(contentMode: .fit)
          .padding(15)
          .frame(width: widthAndHeight, height: widthAndHeight)
          .foregroundColor(.white)
      }
    }
  }
}

If you want to learn how to create floating menus in SwiftUI, take a look at this tutorial!

Depending on the showPopUp State, we can now initialize the PlusMenu on top of our tab bar.

ZStack {
     if showPopUp {
         PlusMenu(widthAndHeight: geometry.size.width/7)
     }
     HStack {
         //...
     }
         //...
 }

We position the PlusMenu above our tab bar by using the .offset modifier again.

if showPopUp {
     PlusMenu(widthAndHeight: geometry.size.width/7)
         .offset(y: -geometry.size.height/6)
 }

We want to toggle the State by tapping on the plus icon. For this purpose, we apply a tap gesture to the ZStack containing the Circle and Image view.

ZStack {
     //…
 }
     .offset(y: -geometry.size.height/8/2)
     .onTapGesture {
         showPopUp.toggle()
     }

When we now tap on the plus icon of our tab bar, the floating menu gets displayed! We can animate this by wrapping the toggle statement into a withAnimation clause.

.onTapGesture {
     withAnimation {
         showPopUp.toggle()
     }
 }

To replace the default fade animation with a catchier one, we can add the .transition modifier to our PlusMenu’s view while choosing the .scale option:

var body: some View {
     HStack(spacing: 50) {
         //...
     }
         .transition(.scale)
 }

While running a live preview, tap the plus icon again to see how it looks!

Hint: Probably your live preview doesn’t display the animation correctly. In this case, run your app in the regular simulator.

Additionally, we want to rotate our plus icon when tapping on it. To do this, add the following modifier to the Image view inside the corresponding ZStack.

Image(systemName: "plus.circle.fill")
     //…
     .rotationEffect(Angle(degrees: showPopUp ? 90 : 0))

Depending on whether the plus menu is being shown, this modifier rotates the plus icon by 90 degrees.

Our whole plus menu animation should now look like this:

Conclusion 🎊

Awesome, we finished creating our own custom tab bar in SwiftUI! We’ve learned how to design a tab bar’s UI, how to implement a proper navigation logic, and how to animate a cool pop-up menu. With this knowledge, you should be able to create your own custom tab bar!

We’ve uploaded the whole source code of this app to GitHub.

You liked the tutorial, and you want to learn more about developing iOS apps with SwiftUI? Then check out our Interactive Mastering SwiftUI Book!

27 replies on “How to create a custom Tab Bar in SwiftUI”

I would like to know how to change the background color to the “More” screen that is created when you have more .tabItem than will fit on the bottom TabBar.

Thank you for this tutorial! and your solution of using the ViewRouter!!! It saved me soooooo much headache!!! I was having issues with the navigation back to the list view after a user would click the nav link to view a “Detailed View”. This solved it quick!!!

Hello Have you figured out a way to achieve this!? I have been struggling to do so myself!? I can not get the proper behavior…

Nice writing. But just have a comment with that code:
“`
struct ContentView: View {
@ObservedObject var viewRouter = ViewRouter()
“`
Since `ViewRouter` had initial inside `ContentView` that we should use `@StateObject` instated of `@ObservedObject`

Can you take it one step further… make one of the tabs have a list of items and the selection of one of the items navigates to a detailed view of the item selected in the list to a detailed view, which should hide the tab bar?

Is it possible to use custom images in the tab bar and change the image upon selection? Similar to “To indicate the user which view is currently being shown, we can conditionally highlight the corresponding tab bar icon.”

Sure, just initialise a file from your Assets folder. You could achieve a change in your Image by using something like this: @State var isHomeTabSelected = false. Then turn the State to true when the HomeView is shown. Correspondingly, you could initialise your Image view inside the TabView instance like this: Image(isHomeTabSelected ? *asset1* : *asset2″)

I actually have the same question as a number of other commenters so I am hoping you may be able to answer, but is there a way to hide the tab bar if the user makes a selection like the camera or photo library icon?

When having tab views being NavigationViews, you lose the state of the NavigationView when navigating between tabs

Leave a Reply

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

small_c_popup.png

Covid-19 Forces you into quarantine?

Start Mastering swiftUI Today save your 33% discount