SwiftUI Transition System is confusing...

Hi everyone,

I’m having trouble getting the correct horizontal slide transitions when navigating between multiple screens in my SwiftUI app. I have three screens (enum cases with an int assigned as index depending on the navigation order): .checkClient (index 0), .login(document: String) (index 1), and .register (index 2).

My goal is:

  1. When moving forward (e.g., from .checkClient to .login, or from .login to any other with a greater index), the new screen should enter from the right (trailing) and the old one should exit to the left (leading).
  2. When going backward (from .register back to .checkClient, for example), the new screen should enter from the left (leading) and the old one should exit to the right (trailing).

I’ve been using a state property isAdvancing to determine the direction of transitions (I use TCA, so my logic is in a Reducer body, my properties in a State and my views are normal SwiftUI Views):

case .updateCurrentScreen(let newScreen):
    state.isAdvancing = newScreen.index > state.currentScreen.index
    state.currentScreen = newScreen
    return .none

I tried applying .transition directly inside each case:

.transition(
    .asymmetric(
        insertion: .move(edge: store.isAdvancing ? .trailing : .leading),
        removal: .move(edge: store.isAdvancing ? .leading : .trailing)
    )
)

This works correctly the first time I navigate forward. However, when I go to the .register screen and then hit back, the directions become inconsistent. Sometimes the removal happens in the wrong direction, and after returning to .checkClient, forward navigations stop working as intended.

Then, I tried placing the transition at a higher level, wrapping the switch in a ZStack and using a single .transition(...) outside:

ZStack {
    switch store.currentScreen {
    case .checkClient:
        StartView(...)
    case .login:
        LoginView(...)
    case .register:
        RegisterView(...)
    }
}
.transition(
    .asymmetric(
        insertion: .move(edge: store.isAdvancing ? .trailing : .leading),
        removal: .move(edge: store.isAdvancing ? .leading : .trailing)
    )
)
.animation(.easeInOut, value: store.currentScreen)

But doing this results in some transitions reverting to a fade instead of a horizontal slide.

I’ve also tried ensuring that isAdvancing updates before changing the currentScreen. Unfortunately, I still encounter inconsistent transitions when navigating back and forth between these screens.

Here is my complete view logic (even though is not finished nor polished yet):

  var body: some View {
        WithPerceptionTracking {
            ZStack {
                AdaptiveSheetView(
                    backgroundImage: Asset.AuthorizationDomain.background,
                    hasBackButton: store.showsBackButton,
                    isFullScreen: store.isFullScreen,
                    backAction: {
                        store.send(.goBack)
                    }
                ) {
                    if store.showsIATILogo {
                        Image(asset: Asset.iatiLogo)
                            .padding(spacing: .medium)
                    }
                    ZStack {
                        switch store.currentScreen {
                        case .checkClient:
                            StartView(store: store.scope(state: \.startState, action: \.startAction))
                        case .login:
                            if let newStore = store.scope(state: \.loginState, action: \.loginAction) {
                                LoginView(store: newStore)
                            }
                        case .register:
                            if let newStore = store.scope(state: \.registerState, action: \.registerAction) {
                                RegisterView(store: newStore)
                            }
                        }
                    }
                    .transition(
                        .asymmetric(
                            insertion: .move(edge: store.isAdvancing ? .trailing : .leading),
                            removal: .move(edge: store.isAdvancing ? .leading : .trailing)
                        )
                    )
                }
                .animation(.easeInOut, value: store.currentScreen)
                
                if store.startState.checkUser == .loading { LoadingSpinner() }
                
                PreloadView(store: store.scope(state: \.preloadState, action: \.preloadAction))
                    .opacity(store.preloadViewShown ? 1.0 : 0.0)
                    .animation(.linear(duration: 0.5), value: store.preloadViewShown)
                    .onChange(of: store.preloadViewShown) { shown in
                        if !shown { store.send(._checkPreviousSessions) }
                    }
            }
        }
    }

Has anyone experienced similar issues or found a reliable pattern for achieving these “push/pop” style transitions in SwiftUI? Any guidance would be greatly appreciated!

My minimum target is iOS 16, so I can not make use of TabView with paginated style for this AFAIK.

Thanks in advance for any time and attention you dedicate to me 🙏🏼

As a visual reference, the first view transition executes correctly. The "Start" view removes to the left meanwhile the "Login" view inerts from the right.

This behavior is not kept although using the same transition for the next one, where the "Login" view removes to the right and the "Register" View inserts from the right, which is not coherent.

Ignore the fact that the Register is not expected to come from the login, this pieces where ordered for demonstration purposes.

At first I though this was happening because of the isAdvancing was not correctly calculated, but after debugging I've seen it actually is working as expected, but the UI transitions are not.

SwiftUI Transition System is confusing...
 
 
Q