In this post we create a beautiful custom Tabbar using SwiftUI. The implementation features the following:
Adaptable and dynamic spacing of the tabour items.
Use of iOS’s thin material background.
Using symbolVariant to differentiate the selected tab
A tabbar indicator that correctly positions itself with the selected tabor item, and adapts to different spacings. For example, this works well even in landscape mode.
Use of @ViewBuilder to create the correct spacings for the tabbar indicator.
🚨 Full code below and also on Github
🤝 Find more about me on saidmarouf.com. I’m also on X, Threads, and LinkedIn
import SwiftUI
struct CustomTabbarDemoView: View {
var body: some View {
ZStack {
Image("background")
.resizable()
.scaledToFill()
.edgesIgnoringSafeArea(.all)
VStack {
Spacer()
CustomTabBar()
.padding(.bottom)
Spacer()
}
}
}
}
struct CustomTabBar: View {
enum TabItemKind: Int, Identifiable {
var id: Int {
self.rawValue
}
case home = 0
case trends
case post
case search
case profile
}
struct TabItem {
let imageName: String
let type: TabItemKind
}
private let items = [
TabItem(imageName: "house", type: .home),
TabItem(imageName: "chart.bar", type: .trends),
TabItem(imageName: "plus.circle", type: .post),
TabItem(imageName: "magnifyingglass", type: .search),
TabItem(imageName: "gearshape", type: .profile)
]
@State var selectedTab: TabItemKind = .home
@State private var scale = 1.0
private let tabItemWidth = 60.0
private let indicatorColor =
Color(red: 224/255.0, green: 103/255.0, blue: 111/255.0)
var body: some View {
ZStack {
HStack {
Spacer()
ForEach(items, id: \.type) { item in
Image(systemName: item.imageName)
.frame(width: tabItemWidth, height: tabItemWidth)
// to make the full frame tappable, not just the image
.contentShape(Rectangle())
.scaleEffect(selectedTab == item.type ? scale : 1.0)
.symbolVariant(selectedTab == item.type ? .fill : .none)
.foregroundStyle(selectedTab ==
item.type ? .primary : .secondary)
.onTapGesture {
withAnimation(.spring(response: 0.3,
dampingFraction: 0.6)) {
selectedTab = item.type
scale = 1.1
}
}
Spacer()
}
}
VStack(alignment: .leading) {
// Push indicator to the bottom
Spacer()
HStack {
// Leading dynamic spacing
leadingSpacers()
Capsule()
.frame(width: 32, height: 3)
.offset(y: -3)
.foregroundStyle(indicatorColor)
.padding(.horizontal, 14)
.shadow(color: indicatorColor, radius: 5, x: 0, y: -1)
// Trailing dynamic spacing
trailingSpacers()
}
}
.frame(maxWidth: .infinity)
}
.frame(maxWidth: .infinity)
.frame(height: 64)
.background(.thinMaterial, in: .capsule)
.shadow(color: .black.opacity(0.6), radius: 0.0, y: 0.5)
.padding()
}
@ViewBuilder
func leadingSpacers() -> some View {
let leadingMaxIndex = selectedTab.rawValue + 1
ForEach(0..<leadingMaxIndex, id: \.self) { _ in
Spacer()
}
Spacer().frame(width: tabItemWidth * CGFloat((leadingMaxIndex - 1)))
}
@ViewBuilder
func trailingSpacers() -> some View {
let trailingMaxIndex = items.count - selectedTab.rawValue
ForEach(0..<trailingMaxIndex, id: \.self) { _ in
Spacer()
}
Spacer().frame(width: tabItemWidth * CGFloat(trailingMaxIndex - 1))
}
}
#Preview {
CustomTabbarDemoView()
}
💡 SwiftUI FYI #9 - Custom Tabbar