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
.tabBarMinimizeBehaviorif 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.
Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.
