Fix: tabBarMinimizeBehavior(.onScrollDown) Not Expanding on Scroll Up in SwiftUI

Fix: tabBarMinimizeBehavior(.onScrollDown) Not Expanding on Scroll Up in SwiftUI

Written by

in

Quick answer: SwiftUI’s .tabBarMinimizeBehavior(.onScrollDown) only controls when the tab bar minimizes, not when it expands. It stays hidden until you reach the top of the scroll view. For immediate expansion on scroll up, implement a custom tab bar using onScrollGeometryChange to track scroll direction and animate visibility yourself.

Why this happens

SwiftUI’s .tabBarMinimizeBehavior(.onScrollDown) modifier was introduced to let the system automatically hide the tab bar when the user scrolls down. However, the expansion logic is conservative by design. The framework decides when to restore the tab bar, and there is no public API to adjust that threshold. In current iOS versions, the bar remains minimized until the scroll view reaches its top edge (content offset 0) or the user taps the minimized bar lip.

This isn’t a bug—it’s the intended behavior of the native implementation. The modifier defines the minimize trigger, not the expand trigger. Apps like Reddit, X, and Safari achieve instant show/hide by driving their own custom tab bars rather than relying on the system modifier.

How to fix it

1. Build a custom tab bar with onScrollGeometryChange (recommended)

Use onScrollGeometryChange (iOS 17+) to observe the scroll view’s content offset and direction, then animate a custom tab bar overlay. This gives you full control over hide/show timing.

import SwiftUI

struct CustomTabBarView: View {
    @State private var scrollOffset: CGFloat = 0
    @State private var lastOffset: CGFloat = 0
    @State private var isTabBarHidden = false
    @State private var selectedTab = 0

    private let tabs = [
        ("Tab 1", "text.bubble"),
        ("Tab 2", "heart"),
        ("Tab 3", "gear")
    ]

    var body: some View {
        ZStack(alignment: .bottom) {
            TabView(selection: $selectedTab) {
                ForEach(tabs.indices, id: \.self) { index in
                    ScrollView {
                        LazyVStack {
                            ForEach(0...100, id: \.self) { id in
                                Text("Item \(id)")
                                    .frame(maxWidth: .infinity, alignment: .leading)
                                    .padding()
                            }
                        }
                    }
                    .onScrollGeometryChange(for: CGFloat.self) { geo in
                        geo.contentOffset.y
                    } action: { oldValue, newValue in
                        handleScrollChange(oldValue: oldValue, newValue: newValue)
                    }
                    .tag(index)
                    .tabItem {
                        Label(tabs[index].0, systemImage: tabs[index].1)
                    }
                }
            }

            // Custom animated tab bar
            if !isTabBarHidden {
                CustomTabBar(selectedTab: $selectedTab, tabs: tabs)
                    .transition(.move(edge: .bottom).combined(with: .opacity))
            }
        }
        .animation(.easeInOut(duration: 0.2), value: isTabBarHidden)
    }

    private func handleScrollChange(oldValue: CGFloat, newValue: CGFloat) {
        let delta = newValue - oldValue
        let threshold: CGFloat = 10 // minimum scroll to trigger

        // Scrolling down -> hide
        if delta > threshold && !isTabBarHidden {
            isTabBarHidden = true
        }
        // Scrolling up -> show immediately
        else if delta < -threshold && isTabBarHidden {
            isTabBarHidden = false
        }

        lastOffset = newValue
    }
}

struct CustomTabBar: View {
    @Binding var selectedTab: Int
    let tabs: [(String, String)]

    var body: some View {
        HStack {
            ForEach(tabs.indices, id: \.self) { index in
                Button {
                    selectedTab = index
                } label: {
                    VStack(spacing: 4) {
                        Image(systemName: tabs[index].1)
                            .font(.system(size: 22, weight: selectedTab == index ? .semibold : .regular))
                        Text(tabs[index].0)
                            .font(.caption2)
                    }
                    .foregroundStyle(selectedTab == index ? .blue : .secondary)
                    .frame(maxWidth: .infinity)
                }
            }
        }
        .padding(.horizontal, 16)
        .padding(.vertical, 8)
        .background(.ultraThinMaterial)
        .overlay(alignment: .top) {
            Divider()
        }
        .safeAreaPadding(.bottom)
    }
}

Key points:
– onScrollGeometryChange fires on every scroll update with the current contentOffset.y.
– Compare newValue vs oldValue to detect direction (positive = down, negative = up).
– Apply a small threshold (e.g., 10pt) to avoid jitter on tiny movements.
– Animate the custom bar with .transition(.move(edge: .bottom).combined(with: .opacity)) for a native feel.

2. Wrap UIKit’s UITabBarController for full system behavior

If you need the exact system tab bar (including badge support, more menu, etc.) but with responsive hide/show, wrap a UITabBarController via UIViewControllerRepresentable and drive its hidesBottomBarWhenPushed or custom scroll delegate logic.

import SwiftUI
import UIKit

struct UIKitTabBarContainer: UIViewControllerRepresentable {
    let viewControllers: [UIViewController]
    @Binding var selectedIndex: Int

    func makeUIViewController(context: Context) -> UITabBarController {
        let tabBarController = UITabBarController()
        tabBarController.viewControllers = viewControllers
        tabBarController.delegate = context.coordinator
        return tabBarController
    }

    func updateUIViewController(_ uiViewController: UITabBarController, context: Context) {
        uiViewController.selectedIndex = selectedIndex
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    class Coordinator: NSObject, UITabBarControllerDelegate, UIScrollViewDelegate {
        var parent: UIKitTabBarContainer
        var lastOffset: CGFloat = 0

        init(_ parent: UIKitTabBarContainer) {
            self.parent = parent
        }

        func scrollViewDidScroll(_ scrollView: UIScrollView) {
            let offset = scrollView.contentOffset.y
            let delta = offset - lastOffset
            let tabBar = parent.viewControllers.first?.tabBarController?.tabBar

            if delta > 10, !(tabBar?.isHidden ?? true) {
                UIView.animate(withDuration: 0.2) {
                    tabBar?.isHidden = true
                }
            } else if delta < -10, tabBar?.isHidden == true {
                UIView.animate(withDuration: 0.2) {
                    tabBar?.isHidden = false
                }
            }
            lastOffset = offset
        }
    }
}

Use this when you must keep the native UITabBar (badges, system more menu) but want custom scroll-driven visibility.

How to prevent it

  • Don’t rely on .tabBarMinimizeBehavior if you need instant expansion on scroll up.
  • Test scroll-driven UI on real devices—simulator scrolling behaves differently.
  • Keep custom tab bar animations short (0.15–0.25s) to match system feel.
  • Respect safe areas: use .safeAreaPadding(.bottom) on the custom bar.

FAQ

Does .tabBarMinimizeBehavior(.automatic) behave differently?

No. .automatic currently resolves to the same conservative expansion logic as .onScrollDown. There is no public variant that expands on direction reversal.

Can I combine the native modifier with a custom bar?

Not cleanly. The native modifier takes ownership of the system tab bar’s visibility. If you need custom timing, replace the system bar entirely with your own overlay as shown above.

Will Apple add an expansion-sensitivity parameter in a future iOS version?

There’s no public roadmap. The current API surface (TabBarMinimizeBehavior struct) has no configuration options. File a Feedback Assistant request if this behavior blocks your design.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.