SwiftUI TabView insets are not respected during rotation

92 views Asked by At

For a Carousel custom component we are using a TabView with the following modifier:

.tabViewStyle(.page(indexDisplayMode: .never))

Everything is working quite well, except that during rotation handling, a strange bottom inset appear on the bottom:

Landscape present a strange bottom insets

Looking more in deep with the Debug view, we found that top origin is wrongly translated by about 10px.

Top origin is wrongly translated.

Attached a snip of code that reproduce the problem, running Xcode15.2 and iOS17.2:

import SwiftUI

@main
struct TabViewDemoIssueApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .ignoresSafeArea(.all, edges: .all)
        }
    }
}

struct ContentView: View {
    var body: some View {
        TabView {
            Rectangle().foregroundColor(.red)
                .ignoresSafeArea(.all, edges: .all)
            Rectangle().foregroundColor(.yellow)
            .ignoresSafeArea(.all, edges: .all)
        }
        .ignoresSafeArea(.all, edges: .all)
        .background(.green)
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .tabViewStyle(.page(indexDisplayMode: .never))
    }
}

#Preview {
    ContentView()
}

UPDATE: We opened a bug to Apple: FB13526097

2

There are 2 answers

4
MatBuompy On

I've give it a look and came up with a solution. It's a workaround really, it was the best I could come up with in a short amount of time. It's a mistery to me too why your view behaves like that. So, what I've done is I'm detecting the device rotations and apply a small scale bump if it is not in portrait mode. Here's the code:

    // Our custom view modifier to track rotation and call our action
struct DeviceRotationViewModifier: ViewModifier {
    let action: (UIDeviceOrientation) -> Void
    
    func body(content: Content) -> some View {
        content
            .onAppear()
            .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
                action(UIDevice.current.orientation)
            }
    }
}

// A View wrapper to make the modifier easier to use
extension View {
    func onRotate(perform action: @escaping (UIDeviceOrientation) -> Void) -> some View {
        self.modifier(DeviceRotationViewModifier(action: action))
    }
}

And you edit your view like this:

TabView {
        Rectangle().foregroundColor(.red)
            .ignoresSafeArea(.all, edges: .all)
        Rectangle().foregroundColor(.yellow)
            .ignoresSafeArea(.all, edges: .all)
    }
    .scaleEffect(orientation != .portrait ? 1.06 : 1, anchor: .center)
    .ignoresSafeArea(.all, edges: .all)
    .background(.green)
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .tabViewStyle(.page(indexDisplayMode: .never))
    .onRotate { newOrientation in
        orientation = newOrientation
    }
    .overlay(alignment: .center) {
            ZStack(alignment: .center) {
                Image(.pic1)
                    .resizable()
                    .scaledToFit()
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .padding(.top, 20)
            }
        }

Let me know if that could work for you, I know it's a workaround. Using the overlay, any view inside won't be affected be the small resizing of the undelying view. Here's the result: Image not being resizes in overlay

0
Benzy Neez On

This appears to be a similar problem to the one reported in iOS 17 SwiftUI: Color Views in ScrollView Using containerRelativeFrame Overlap When Scrolling.

The gap at the bottom depends on the bottom insets. If you try it on a device without bottom insets (such as an iPhone SE) then there is no gap.

As a workaround, you could try measuring the safe-area insets and adding negative bottom padding equal to half the bottom insets. However, this isn't necessary on first launch, so you could use an .onChange handler to detect a change in the size of the bottom insets and only apply the padding after a change has been detected.

@State private var bottomPadding = CGFloat.zero

var body: some View {
    GeometryReader { proxy in
        let bottomInsets = proxy.safeAreaInsets.bottom
        TabView {
            Image(systemName: "ladybug")
                .resizable()
                .scaledToFit()
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .ignoresSafeArea()
                .background(.red)
            Rectangle().foregroundColor(.yellow)
                .ignoresSafeArea(.all, edges: .all)
        }
        .padding(.bottom, bottomPadding)
        .ignoresSafeArea(.all, edges: .all)
        .background(.green)
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .tabViewStyle(.page(indexDisplayMode: .never))
        .onChange(of: bottomInsets) {
            bottomPadding = -bottomInsets / 2
        }
    }
}