Swift & iOS Dec 2, 2025 ~ 4 min read

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 DispatchQueue calls create multi-phase effects