Gooey Tab Bar in SwiftUI
A custom tab bar with elastic indent animation and bouncing ball indicator, inspired by Cuberto’s design. This post covers the key SwiftUI techniques: custom Layout, PreferenceKey, and animatable Shape.
1. Custom Layout with Layout Protocol
The first challenge is evenly distributing tab buttons. SwiftUI’s Layout protocol (iOS 16+) gives full control over how child views are positioned:
struct TabBarLayout: Layout {
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
let maxHeight = subviews.map {
$0.sizeThatFits(.unspecified).height
}.max() ?? 0
return CGSize(width: proposal.width ?? 0, height: maxHeight)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
guard !subviews.isEmpty else { return }
let width = bounds.width / CGFloat(subviews.count)
for (index, subview) in subviews.enumerated() {
let x = bounds.minX + width * CGFloat(index) + width / 2
subview.place(
at: CGPoint(x: x, y: bounds.midY),
anchor: .center,
proposal: .unspecified
)
}
}
}
This divides the available width equally and centers each button in its slot.
2. Track Button Positions with PreferenceKey
To animate the indent to the correct position, we need to know where each button is. PreferenceKey lets child views communicate their geometry upward to parent views:
struct TabButtonPreferenceKey: PreferenceKey {
static var defaultValue: [Int: CGRect] = [:]
static func reduce(
value: inout [Int: CGRect],
nextValue: () -> [Int: CGRect]
) {
value.merge(nextValue(), uniquingKeysWith: { $1 })
}
}
Each button reports its frame using a GeometryReader:
TabButton(item: item, isSelected: selectedIndex == item.id, action: { ... })
.background(
GeometryReader { geometry in
Color.clear
.preference(
key: TabButtonPreferenceKey.self,
value: [item.id: geometry.frame(in: .named("tabBar"))]
)
}
)
The parent collects all frames via .onPreferenceChange and uses the selected button’s midX to position the indent.
3. Gooey Shape with Bezier Curve
The indent effect comes from a custom Shape with a Bezier curve. The key is animatableData - it tells SwiftUI which properties can be interpolated during animations:
struct GooeyTabBarShape: Shape {
var centerX: CGFloat
var indentDepth: CGFloat
var animatableData: AnimatablePair<CGFloat, CGFloat> {
get { AnimatablePair(centerX, indentDepth) }
set {
centerX = newValue.first
indentDepth = newValue.second
}
}
func path(in rect: CGRect) -> Path {
var path = Path()
let indentWidth: CGFloat = 80
let curveControlOffset: CGFloat = indentWidth / 4
path.move(to: CGPoint(x: 0, y: rect.maxY))
path.addLine(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: centerX - indentWidth / 2, y: 0))
// Bezier curve creates the smooth dip
path.addCurve(
to: CGPoint(x: centerX + indentWidth / 2, y: 0),
control1: CGPoint(x: centerX - curveControlOffset, y: indentDepth),
control2: CGPoint(x: centerX + curveControlOffset, y: indentDepth)
)
path.addLine(to: CGPoint(x: rect.maxX, y: 0))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
path.closeSubpath()
return path
}
}
Without animatableData, the shape would jump instantly. With it, the indent smoothly glides across the tab bar.
4. Ball Indicator with Droplet Effect
The ball adds visual feedback. A “droplet” effect makes transitions feel more physical - shrink on lift, overshoot on land, then settle:
private func animateToTab(_ newIndex: Int) {
// Shrink as it "lifts off"
withAnimation(.easeOut(duration: 0.1)) {
ballScale = 0.6
}
selectedIndex = newIndex
// Overshoot as it "lands"
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
withAnimation(.spring(response: 0.2, dampingFraction: 0.4)) {
ballScale = 1.3
}
}
// Settle back to normal
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
withAnimation(.spring(response: 0.15, dampingFraction: 0.5)) {
ballScale = 1.0
}
}
}
The sequence: 0.6x → 1.3x → 1.0x creates a bounce that feels responsive.
5. Putting It Together
The main component combines everything:
struct GooeyTabBar: View {
@Binding var selectedIndex: Int
let items: [TabItem]
@State private var buttonFrames: [Int: CGRect] = [:]
@State private var ballScale: CGFloat = 1.0
private let tabBarHeight: CGFloat = 60
private let indentDepth: CGFloat = 15
private let ballSize: CGFloat = 8
private var selectedCenterX: CGFloat {
buttonFrames[selectedIndex]?.midX ?? 0
}
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .top) {
// Gooey shape background
GooeyTabBarShape(centerX: selectedCenterX, indentDepth: indentDepth)
.fill(Color(.systemBackground))
.shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: -4)
.animation(.spring(response: 0.4, dampingFraction: 0.6), value: selectedCenterX)
// Ball indicator
Circle()
.fill(Color.accentColor)
.frame(width: ballSize, height: ballSize)
.scaleEffect(ballScale)
.position(x: selectedCenterX, y: indentDepth / 2)
.animation(.spring(response: 0.4, dampingFraction: 0.6), value: selectedCenterX)
// Tab buttons
TabBarLayout {
ForEach(items) { item in
TabButton(item: item, isSelected: selectedIndex == item.id, action: { animateToTab(item.id) })
.background(/* PreferenceKey geometry reader */)
}
}
.padding(.top, indentDepth + 5)
}
}
.onPreferenceChange(TabButtonPreferenceKey.self) { buttonFrames = $0 }
.frame(height: tabBarHeight + indentDepth)
.coordinateSpace(name: "tabBar")
}
}
Key Takeaways
- Layout protocol: Full control over child view positioning
- PreferenceKey: Child-to-parent geometry communication
- animatableData: Enables smooth shape interpolation
- Chained animations: Multiple
DispatchQueuecalls create multi-phase effects