Creating custom tab bars
Sometimes, you must implement your take on a tab bar because what you want cannot be created with the standard one; for example, you want something that resembles a tab bar but has custom graphical requirements and is in a different position on the screen. In this section, you will also learn that going for a custom approach requires much more work than the “standard Apple way” of doing things, besides having other drawbacks.
For our customized tab bar, we want it to have round corners, to be drawn in a frame, with a shadow, and to show the title in a different bright color, say in red and in bold, when a tab is selected. We also want it to sit at the top of the user screen rather than at the bottom.
To begin, it is essential to establish the necessary information for presenting a tab, which includes an image representing the non-selected state, an image representing the selected state, and a title. For the sake of simplicity, we will limit the usage to system images and utilize a struct that consists of three String
values, as illustrated in the following code snippet:
struct ItemValue { let image: String let selectedImage: String let title: String }
Then, we are going to need a view capable of displaying our own take on tabItem
, in both selected and normal mode. We will call this view MyTabItemView
, which will accept two parameters: an ItemValue
parameter and the selected
Boolean flag.
Depending on the selected
flag, the proper system image will be chosen, and the title will be shown either in light or bold, with the selected title also colored in red for good measure.
Create a new SwiftUI view. Go to the Xcode menu and then navigate to File | New | File.... Under iOS, select SwiftUI View under User Interface. In the Save As dialog box, save the file as MyTabItemView.swift
. Type the following code as shown:
struct MyTabItemView: View { let itemContent: ItemValue let selected: Bool var body: some View { VStack { Image(systemName: selected ? itemContent.selectedImage : itemContent.image) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 40, height: 40) Spacer().frame(height: 6) Text(itemContent.title) .fontWeight(selected ? .bold : .light ) .foregroundColor(selected ? .red : .gray) .font(.system(size: 16)) } } } struct TabItemView_Previews: PreviewProvider { static var previews: some View { MyTabItemView(itemContent: ItemValue(image: "moon", selectedImage: "moon.fill", title: "Moon"), selected: true) } }
Then, we are going to need a view to contain all the buttons in our imitation of TabView
; let’s call it MyTabView
. Create another new SwiftUI View
file named MyTabView.swift
and add the following code:
import SwiftUI struct MyTabView: View { let items: [ItemValue] var height: CGFloat = 72 var width: CGFloat = UIScreen.main.bounds.width - 28 @Binding var selectedIndex: Int var body: some View { HStack { Spacer() ForEach(items.indices, id: \.self) { index in let item = items[index] Button { self.selectedIndex = index } label: { let isSelected = selectedIndex == index MyTabItemView(itemContent: item, selected: isSelected) } Spacer() } } .frame(width: width, height: height) .background(Color.white) .cornerRadius(8) .shadow(radius: 3, x: 1, y: 2) } } struct TabBottomView_Previews: PreviewProvider { static var previews: some View { MyTabView( items: [ ItemValue(image: "star", selectedImage: "star.fill", title: "Star"), ItemValue(image: "triangle", selectedImage: "triangle.fill", title: "Triangle"), ItemValue(image: "circle", selectedImage: "circle.fill", title: "Circle"),ItemValue(image: "square", selectedImage: "square.fill", title: "Square")], selectedIndex: .constant(0)) } }
In the previous code fragment, we keep track of the selected tab using selectedIndex
. A tab view knows that it has been selected if its index is the same as selectedIndex
. As we want selectedIndex
to be accessible externally from this view, we declare it as @Binding
.
Also, notice the use of .constant(0)
as a way to simulate a value for a binding value in our preview.
Then, we want to use an enum to represent the different values of our tabs, and to be able to use it in a loop, we make it conform to the CaseIterable
protocol:
enum Tabs: Int, CaseIterable { case moon = 0 case star case triangle case square var tabItem: ItemValue { switch self { case .moon: return ItemValue(image: "moon", selectedImage: "moon.fill", title: "Moon") case .star: return ItemValue(image: "star", selectedImage: "star.fill", title: "Star") case .triangle: return ItemValue(image: "triangle", selectedImage: "triangle.fill", title: "Triangle") case .square: return ItemValue(image: "square", selectedImage: "square.fill", title: "Square") } } }
Furthermore, we need four different views to be selected: one to contain the selection mechanism and the selected view, and one that is that selection mechanism. Let’s call the first CustomTabView
and the second HomeTabView
. The four views to be selected, in our example, will show their name. In a real app, they can be whatever you want. Our HomeTabView
view will cycle through all the values of the enum tabs, using the @ViewBuilder tabView()
function to produce its contained views. You will want to use this approach, using @ViewBuilder
when you want to produce multiple child views via a single closure or function call. The following code fragment shows how to build all the contained views: StarView
, MoonView
, TriangleView
, and SquareView
(they are just Text
views with modifiers, each displaying its own name). They all share the same layout with padding, rounded corners, background material, and a shadow.
Create another new SwiftUI View
file named HomeTabView.swift
and add the following code:
import SwiftUI struct StarView: View { var body: some View { Text("This is a Star") .padding(.all) .background(.regularMaterial) .cornerRadius(8.0) .shadow(color: .gray,radius: 5,x: 2.0,y: 2) } } struct MoonView: View { var body: some View { Text("This is a Moon") .padding(.all) .cornerRadius(8.0) .background(.regularMaterial) .shadow(color: .gray,radius: 5,x: 2.0,y: 2) } } struct TriangleView: View { var body: some View { Text("This is a Triangle") .padding(.all) .cornerRadius(8.0) .background(.regularMaterial) .shadow(color: .gray,radius: 5,x: 2.0,y: 2) } } struct SquareView: View { var body: some View { Text("This is a Square") .padding(.all) .cornerRadius(8.0) .background(.regularMaterial) .shadow(color: .gray,radius: 5,x: 2.0,y: 2) } } struct CustomTabView<Content: View>: View { let tabs: [ItemValue] @Binding var selectedIndex: Int @ViewBuilder let content: (Int) -> Content var body: some View { ZStack { TabView(selection: $selectedIndex) { ForEach(tabs.indices, id: \.self) { index in content(index) .tag(index) } } VStack { MyTabView(items: tabs, selectedIndex: $selectedIndex) Spacer() } .padding(.bottom, 8) } } } struct HomeTabView: View { @State var selectedIndex: Int = 0 var body: some View { CustomTabView(tabs: Tabs.allCases.map({ $0.tabItem }), selectedIndex: $selectedIndex) { index in let type = Tabs(rawValue: index) ?? .star tabView(type: type) } } @ViewBuilder func tabView(type: Tabs) -> some View { switch type { case .star: StarView() case .moon: MoonView() case .triangle: TriangleView() case .square: SquareView() } } } struct MainTabView_Previews: PreviewProvider { static var previews: some View { HomeTabView() } }
The following points explain the preceding code block:
CustomTabView
is a struct that takes a view (Content: View
) as a generic argument. It has the following properties:tabs
: An array ofItemValue
, which describes each tabselectedIndex
: A@State Int
that keeps track of the currently selected tab indexcontent
: A view builder closure that takes anInt
(the index of the selected tab) and returnsContent
- In
body
, it usesTabView
and a custom tab view (MyTabView
) in aZStack
to lay out the tabs
HomeTabView
is the main view of the app. It has aselectedIndex
@State
variable that tracks the currently selected tab.HomeTabView
usesCustomTabView
and sets its tabs withTabs.allCases.map({ $
0.tabItem })
.Tabs
is an enum that describes each tab and has a computedtabItem
property of theItemValue
type.- The
@ViewBuilder func tabView(type: Tabs) -> some View
function switches depending on theTabs
enum to determine which of the previously defined views (StarView
,MoonView
, etc.) is to be displayed.
To finish, we need to add HomeTabView
to our default scene, WindowGroup
, so that it is the view displayed at the application start. We can delete the ContentView
view created by the Xcode wizard, as this is not necessary anymore:
import SwiftUI @main struct CH6_CustomTabBarApp: App { var body: some Scene { WindowGroup { HomeTabView() } } }
The result is shown in the simulator in the following figure:
Figure 6.4 – Our custom tab bar at the top of the screen
We can conclude that often customizing existing interface elements just for esthetics can be fun but involves much more work. In our intentionally simple case, this effort was over an order of magnitude higher than just using the native UI elements provided by Apple. Be warned that precisely matching the original component’s capabilities and functionality is also difficult. In our case, we sacrificed compatibility and ease of use with Apple TV and future Apple devices to display our titles in red and position the tab bar at the top of the screen in iOS.
Users may already be familiar with the existing UI elements unless using your customized versions is immediately understandable by a user. So, whenever you decide to implement your own modified version of an existing UI element, you should question yourself and consider whether that time would be better spent producing more value for your user by implementing more actual features.
Besides, if your customization effort goes too deep, it might be based on some mechanism that Apple will want to change in the next versions of iOS.
If you stick to Apple UI recommendations, if Apple decides to change the look and feel of apps in the next version of the operating system, your app will adapt, often without any modifications required on your side.
When this does not happen automatically, you may want to let your app perform differently under different conditions – namely, different versions of the compiler (which practically means different versions of Xcode) or different versions of the iOS operating system. In the next section, we will teach you how to adapt source code to different versions of the OS at compile time.
Adapting your code to different versions of the operating system
Conditional compilation rather than runtime check in Swift
If you want to adapt your code so that it is produced only for a certain version of the compiler or operating system, you can use conditional compilation. In the following example, different versions of the code will be compiled, depending on the version of Swift.
You can also check for a specific version of an operating system, both at compile time and at runtime. If you don’t want to link to the wrong version of an API, be sure to use conditional compilation.
An example of conditional compilation depending on the compiler is the following code fragment:
#if compiler(>=5.5) return self.previewCGImageRepresentation() #else return self.previewCGImageRepresentation()?.takeUnretainedValue() #endif
The previous code fragment provides a different implementation of the preview image representation, depending on the compiler version. The inappropriate version would not be even linked, and this will avoid producing an error if the compiler version does not support that functionality.
If, instead, you want to compile different code depending on the supported API for different versions of the operating systems, you can use if #available
, as in the following code fragment:
struct ContentView: View { var body: some View { Group { if #available(iOS 14.0, *) { ScrollView { AnyView(LazyVStack { content.padding(.horizontal, 15) }) } } else { List { content } } } } }
In the previous code example, ContentView
will contain a Group
-based view hierarchy or a simple List
, depending on the version of iOS being equal to or above 14.0.
In the next section, we will examine modal views in detail.