Has the behavior of asymmetric transitions changed in SwiftUI 4 / iOS 16?

I've been using an asymmetric transition which has been working flawlessly during the time of iOS 15. However, with the introduction of iOS 16 the transition no longer works as expected.

In short: In my app I'm using a paged tabview. When you swipe pages I want to insert/remove another view with an animation/transition, matching the page swipe direction.

Since it's much easier to describe the problem in code I've created an isolated case where my problem is repeatable, so you can see it for yourself.

import SwiftUI

class SharedStore: ObservableObject {
    
    @Published var selectedPage: Int = 0 {
        willSet {
            if newValue > selectedPage  {

                self.pageDirection = .forward
                
            } else {
                
                self.pageDirection = .reverse
            }
        }
    }
    
    var pageDirection: UIPageViewController.NavigationDirection = .forward
}

struct ContentView: View {
    
    @StateObject var sharedStore = SharedStore()
    
    var body: some View {
        
        VStack {
            Text("Page \(sharedStore.selectedPage)")
                .id(sharedStore.selectedPage)
                .frame(maxWidth: UIScreen.main.bounds.width)
                .animation(.easeInOut, value: sharedStore.selectedPage)
                .transition(
                    .asymmetric(
                        insertion: .move(edge: sharedStore.pageDirection == .forward ? .trailing : .leading),
                        removal: .move(edge: sharedStore.pageDirection == .forward ? .leading : .trailing)
                    )
                )

            TabView(selection: $sharedStore.selectedPage) {
                ForEach(0..<10, id:\.self) { index in
                    Text("Hello \(index)")
                }
            }
            .tabViewStyle(.page)
        }
        .padding()
    }
}

Run the above on a iOS 16 simulator. Swipe a couple of pages and then change direction. Notice that the transition of the top view gets weird. The view is removed and inserted from the same edge.

If you however run the same code on a iOS 15 simulator, you will see that the top view animation matches the page swipe direction nicely, even when you change direction.

My assumption is that something has changed with the asymmetric transitions in SwiftUI 4 / iOS 16, hence the title of the post. Or could it be something the published property and the willSet observer?

Note that I'm far from a professional iOS developer, so I'm also considering that this actually worked in iOS 15 by pure luck :)

Yep, I've been noticing the changes to the .move transition since iOS 16. I feel like they reversed the behaviour. To be fair, I think the iOS 16 behaviour is actually the expected one. The behaviour on iOS 15 was kinda broken.

The solution I can see is to have an adapter or a custom transition that toggle between the two behaviours depending on the OS version.

I am in a similar situation. I'm managing a view transition using asymmetric move animations which are triggered by a bottom tab view. When I change direction, I toggle the two animations.

This is an example

let previousTab = selectedTab
self.tabAnimationEdge = newTab > previousTab
        ? .trailing : .leading
self.tabAnimationEdgeOpposite = self.tabAnimationEdge == .leading ? .trailing : .leading
self.selectedTab = newTab

Now with iOS 16 (and Xcode 14.0.1 when I submit this comment) it seems the removal animation is not updated in time and iOS will use the old one. I found that delaying the selected tab update with DispatchQueue.main.asyncAfter and a very small delay (for example 0.05) will hide this strange behaviour.

So I took a closer look and it turns out there are TWO issues. And the two issues both happen on iOS 15 and coincidentally they can result in a behaviour that looks correct (you can think of it as double negative resulting in positive). On iOS 16, However, one of the issues is fixed. Therefore, we start to actually notice the faulty behaviour.

Use Xcode 14.0:

Issuing one (iOS 15 only):

.move/.offset/.slide/.scale/.opacity transition is broken when several views, within a switch or if-else statements, have different transitions. They will effect each other. It appears the transition on the insertion view will replace the same transition on the removal view.

There're two factors to this:

  1. Two or more views in a switch or if-else statement, forming as a group. If you have separate conditions such as two if statements, then this issue won't happen. In OP's example, the old/new Texts (differentiated by the .id modifier) are considered as a group.

  2. The participating views have the same type of transition for the corresponding action (inserting or removal). In OP's example, both the old and the new Texts have .move for inserting and .move for removal. It doesn't matter what edge it is, only the type of transition matters. (similarly, .offset(x: 100) and .offset(x: -10) are the same type)

Issue Two (Both iOS 15 & iOS 16):

change .transition dynamically won't be applied to removing views immediately. Only applied in the next cycle. It makes sense in that transitions are calculated when the view is rendered. It's already there for the existing view so won't be able to change immediately. For insertion view however, the transition is based on the new value so it's applied immediately.

To be fair, this one is less of an issue. The behaviour is actually reasonable but not that intuitive.

Now, why does OP's example work on iOS 15? Let's say we have Text 1 and Text 2. And we swipe forward, so that pageDirection is set to .forward. What happens?

  1. According to Issue Two, when Text 1 is rendered, pageDirection has a default value .forward. At that moment, the insertion transition is .move(.trailing) and the removal transition is .move(.leading).
  2. Now, Text 2 is required for insertion. And SwiftUI is going to render it, it will use the current pageDirection, which again is set to .forward. Its insertion transition is .move(.trailing) again and removal .move(.leading).
  3. According to Issue One, the inserting view's transition will override the removing view's transition. In this case, they're the same so Text 1 is animating away to the leading edge while Text 2 is animating in from the trailing edge.

Now, let's swipe back, so the pageDirection is set to .reverse. What happens?

  1. According to Issuing Two, Text 2 will keep using the existing transition. So it will still have insertion transition to be .move(.trailing) and removal .move(.leading).
  2. Text 1, on the other hand, is being rendered with pageDirection already set to .reverse. So it will have insertion transition to be .move(.leading) and removal .move(.trailing).
  3. According to Issuing One, Text 2 will instead use Text 1's removal transition, .move(.trailing) and you see it animating away to the trailing edge and Text 1 animating in from the leading edge.

Perfect! Both behaviours look exactly like what we wanted.

However, Issue One is fixed in iOS 16. If we redo the above operation. What happens?

First, we swipe forward, pageDirection is set to .forward.

  1. Same as iOS 15.
  2. Same as iOS 15.
  3. Because the transitions between the two views are the same, even without issue one, we still see the same behaviour as iOS 15.

But, if we swipe back, pageDirection is set to .reverse.

  1. Same as iOS 15
  2. Same as iOS 15
  3. Now, without issue one, we'd respect the transition set in the above two steps. Text 2 will animate away to the leading edge and Text 1 will animate in from the leading edge too!

That is exactly the behaviour OP has observed.

Using a delay like @CoorielRaw mentioned is a way to fix this on iOS 16. Another option is to create a custom transition like this (very raw, needs a lot of polish):

extension AnyTransition {

    static func myMove(forward: Binding<Bool>) -> AnyTransition {

        return .asymmetric(

            insertion: .modifier(

                active: MyMoveModifier(width: 100, forward: forward),

                identity: MyMoveModifier(width: 0, forward: .constant(true))

            ),

            removal: .modifier(

                active: MyMoveModifier(width: -100, forward: forward),

                identity: MyMoveModifier(width: 0, forward: .constant(true))

            )

        )

    }

}



struct MyMoveModifier: ViewModifier {

    let width: CGFloat

    @Binding var forward: Bool

    

    func body(content: Content) -> some View {

        content

            .offset(x: (forward ? 1 : -1) * width)

    }

}

I'm planning on writing a blog post about this to better share the problems and solutions.

Has the behavior of asymmetric transitions changed in SwiftUI 4 / iOS 16?
 
 
Q