TabView(.page) extended under the tab bar shifts its pages up after the container height shrinks while visible

Summary

A page-style TabView that extends under the bottom tab bar with .ignoresSafeArea(edges: .bottom) leaves its pages shifted upward after its height shrinks while it is on screen. The shift is half of the bottom safe area (41.67 pt with an 83.33 pt tab bar area), and the top of each page is hidden.

The internal UICollectionView (contentInsetAdjustmentBehavior == .automatic) seems to briefly add the bottom safe area to adjustedContentInset.bottom during the resize. The inset goes back to 0 afterwards, but the visible cell is left at frame.minY == -41.67.

Environment
  • Xcode 27.0 (27A266a), iPhone Duo Simulator (iOS 27.1), deployment target iOS 18.0
  • In a unit test it also reproduces when only the window height shrinks (386×678 → 386×600), so it does not depend on the foldable device
Steps to reproduce
  1. Run the code below on the iPhone Duo Simulator while it is folded
  2. Unfold the device
Expected

The red "Top of page 0" area stays directly below the yellow header, as it does when the app is launched unfolded.

Actual

The top of the red area is hidden under the header. Log output:

folded (launch): height=628.00 adjustedContentInset.bottom=0.00 cell.minY=0.00
after unfolding: height=619.00 adjustedContentInset.bottom=0.00 cell.minY=-41.67

In this sample the page returns to its position when the device is folded again. In our app it stays shifted, and after the shift, tapping to switch pages sometimes stops working until the user swipes.

Notes
  • Removing .ignoresSafeArea(edges: .bottom) avoids the issue, but the content then no longer extends under the translucent tab bar
  • Setting edgesForExtendedLayout = [] on the hosting controller also avoids it, with the same visual change. safeAreaRegions = [] and setContentScrollView(nil, for:) do not help
  • A non-transparent background on the TabView or its pages, as suggested in https://developer.apple.com/forums/thread/762286, does not help. The trigger here is a size change, not a page switch, so this seems to be a different issue
  • Current workaround: find the internal UICollectionView through the superview chain and set contentInsetAdjustmentBehavior = .never. This works, but it relies on the private view hierarchy. Is there a supported way to avoid this?
Minimal reproduction
import SwiftUI
import UIKit

// A TabView(.page) extended under the tab bar with .ignoresSafeArea(edges: .bottom).
// Resizing the window while it is visible (e.g. unfolding) shifts the page up.
struct PagerView: View {
    @State private var selection = 0
    var body: some View {
        VStack(spacing: 0) {
            Color.yellow.frame(height: 50).overlay(Text("Header"))
            TabView(selection: $selection) {
                ForEach(0..<2) { index in
                    ScrollView {
                        Color.red.frame(height: 120).overlay(Text("Top of page \(index)"))
                        ForEach(0..<30) { Text("Row \($0)").frame(maxWidth: .infinity, minHeight: 44) }
                    }
                    .tag(index)
                }
            }
            .tabViewStyle(.page(indexDisplayMode: .never))
        }
        .ignoresSafeArea(edges: .bottom)
    }
}

final class PagerViewController: UIHostingController<PagerView> {
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.logCollectionView() }
    }

    private func logCollectionView() {
        func find(_ view: UIView) -> UICollectionView? {
            view as? UICollectionView ?? view.subviews.lazy.compactMap(find).first
        }
        guard let collectionView = find(view) else { return }
        let cellMinY = collectionView.visibleCells.first?.frame.minY ?? .nan
        NSLog("FORUM height=%.2f adjustedContentInset.bottom=%.2f cell.minY=%.2f", collectionView.bounds.height, collectionView.adjustedContentInset.bottom, cellMinY)
    }
}

@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication, configurationForConnecting session: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        let configuration = UISceneConfiguration(name: nil, sessionRole: session.role)
        configuration.delegateClass = SceneDelegate.self
        return configuration
    }
}

final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        let pager = PagerViewController(rootView: PagerView())
        pager.tabBarItem = UITabBarItem(title: "Pager", image: UIImage(systemName: "square"), tag: 0)
        let other = UIViewController()
        other.tabBarItem = UITabBarItem(title: "Other", image: UIImage(systemName: "circle"), tag: 1)
        let tabBarController = UITabBarController()
        tabBarController.viewControllers = [pager, other]
        window = UIWindow(windowScene: scene as! UIWindowScene)
        window?.rootViewController = tabBarController
        window?.makeKeyAndVisible()
    }
}

I also submitted FB25495328.

Is this similar to the bug mentioned here?

Thanks for pointing that out! It does look similar, but I think it's a different issue.

In 856261, the size changes while the app is in the background, and the TabView is the scene's root. In my case, the size changes while the page-style TabView (inside a UITabBarController) is on screen, and it only happens with .ignoresSafeArea(edges: .bottom). The pages shift up by exactly half of the bottom safe area.

They might share a root cause in how the safe area is recalculated after a size change, though, so it's good to have both reports linked. Thanks again!