TabView content stops off-center after an animated transition

121 views Asked by At

There seems to be a bug with SwiftUI's TabView when used with .tabViewStyle(.page) and any animated .transition() that moves the view horizontally (e.g. .move(edge: .leading/.trailing)), .slide, .offset(), etc.) in landscape orientation.

When the tab view transitions in, the content appears off-center and the animation goes back and forth before it stabilizes.

Did anyone else experience this and is there any known workaround?

TabViewTransitionBug

import SwiftUI

struct ContentView: View {
    @State private var showTabView = false
    var body: some View {
        VStack {
            Button("Toggle TabView") {
                showTabView.toggle()
            }
            Spacer()
            if showTabView {
                TabView {
                    Text("Page One")
                    Text("Page Two")
                }
                .tabViewStyle(.page)
                .transition(.slide)
            }
        }
        .animation(.default, value: showTabView)
        .padding()
    }
}

#Preview {
    ContentView()
}

@main
struct TabViewTransitionBugApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Tested on Xcode 15.3 (15E204a), iOS 17.3.1 iPhone, iOS 17.4 Simulator.

Bug report FB13687638 filed with Apple.

Update 1

It seems to be related to safe areas as it doesn't happen on Touch ID devices.

TabViewTransitionBugNoSafeAreas

Update 2

As suggested in the answer below, adding .ignoresSafeArea(edges: .horizontal) on the TabView, changing the animation to easeInOut and removing the .padding() from the VStack fixes the initial transition, but swiping between tabs is now off-center.

TabViewTransitionBugStill

Update 3

With all changes from the answer below (https://stackoverflow.com/a/78167053/1104534) the UI still has issues. Unfortunately, it seems that the only "solution" is to use a different transition...

TabViewTransitionBugStill3

1

There are 1 answers

8
Benzy Neez On

This behaviour certainly seems to be irregular. It reminds me of the issue described in iOS 17 SwiftUI: Color Views in ScrollView Using containerRelativeFrame Overlap When Scrolling, so I tried some similar workarounds.

It works a lot better with the following changes:

1. Ignore the horizontal safe area insets

TabView {
    Text("Page One")
    Text("Page Two")
}
.tabViewStyle(.page)
.ignoresSafeArea(edges: .horizontal) // <- ADDED
.transition(.slide)

2. Use .easeInOut animation

The animation of the page indicators seems to take a little longer than the animation of the transition. When the default animation effect of .spring is used, this is especially noticeable. It is less noticeable if .easeInOut is used:

VStack {
    // content as before
}
.animation(.easeInOut, value: showTabView) // <- CHANGED

Animation


EDIT Some more tuning:

  • Ignoring the safe area insets will leave you with the issue that the tabs now fill the full width of the screen. If you want to enforce the safe areas in the usual way then a GeometryReader can be used to measure the screen width.

  • I found that if negative padding is used to expand the width of the TabView by a large amount (overall width > twice the normal width) then the animation of the TabView and the page indicators become synchronized!

  • When the view slides out to the right, it pauses before fully disappearing. This can be hidden by combining the slide transition with .opacity too.

So here is an updated of your example with all the changes applied:

struct ContentView: View {

    let loremIpsum = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."

    @State private var showTabView = false

    var body: some View {
        GeometryReader { proxy in
            VStack {
                Button("Toggle TabView") {
                    showTabView.toggle()
                }
                .frame(maxWidth: .infinity)
                Spacer()
                if showTabView {
                    TabView {
                        Text(loremIpsum)
                            .frame(width: proxy.size.width)
                        Text("Page Two")
                            .frame(width: proxy.size.width)
                    }
                    .tabViewStyle(.page)
                    .padding(.horizontal, -500) // greater than width / 2
                    .ignoresSafeArea(edges: .horizontal)
                    .transition(.slide.combined(with: .opacity))
                }
            }
            .animation(.spring(duration: 1), value: showTabView)
        }
    }
}

Animation