Search code examples
animationswiftuiphaseanimator

SwiftUI - phaseAnimator: prevent opacity change?


I'm trying to get a view to move laterally, and when it has reached the end of that animation, snap (i.e. without animation) back to the starting position (seemingly instantly). At the same time it snaps back, the view should display something different (in this example that is just text). That last part disrupts what seemed to be a straightforward application of phaseAnimator.

Here is the code:

struct ContentView: View
{
    @State
    private var value: Int = 0
    
    var body: some View
    {
        VStack
        {
            GeometryReader { geometry in
                /// Replacing this with `Text("\(value)")` will cause the unintended effect
                Text("Static text")
                    .phaseAnimator([0, 1], trigger: value) { view, phase in
                        view
                            .offset(x: phase == 1 ? 100 : 0)
                    } animation: { phase in
                        switch phase
                        {
                            case 1: .linear(duration: 1)
                            default: nil
                        }
                    }
            }
            
            Button("Start Animation")
            {
                value += 1
            }
        }
    }
}

When changing the displayed text, the view's opacity goes to zero, moves (invisibly and instantly) to the new position and then opacity goes back to one. My suspicion is that this has something to do with the way SwiftUI understands whether a view is the same or not via some kind of ID, but I'm not sure how to troubleshoot that.


Solution

  • This is perhaps happening because the value being updated is also being used as the trigger for the animation. At the start of the animation, the old value is shown, at the end of the animation, the new value is shown.

    • If you add .compositingGroup() immediately after the Text then you see the old value moving, but you also see the new value fading in, so it's not a great solution.
    • If instead you add .drawingGroup() immediately after the Text then you see the value change as it is moving, also not a great solution.

    It works better by using a separate trigger. This can be updated in an .onChange handler that responds to changes to the value:

    @State private var value: Int = 0
    @State private var trigger = false
    
    var body: some View {
        VStack {
            GeometryReader { geometry in
                Text("\(value)")
                    .phaseAnimator([0, 1], trigger: trigger) { view, phase in
                        view
                            .offset(x: phase == 1 ? 100 : 0)
                    } animation: { phase in
                        switch phase {
                        case 1: .linear(duration: 1)
                        default: nil
                        }
                    }
                    .onChange(of: value) { trigger.toggle() }
            }
            Button("Start Animation") {
                value += 1
            }
        }
    }
    

    If you would prefer to see the old value move across and then have it snap back to the new value, try toggling the trigger in the button callback and then updating the value in an animation completion callback:

    VStack {
        GeometryReader { geometry in
            Text("\(value)")
                .phaseAnimator([0, 1], trigger: trigger) { view, phase in
                    view
                        .offset(x: phase == 1 ? 100 : 0)
                } animation: { phase in
                    switch phase {
                    case 1: .linear(duration: 1)
                    default: nil
                    }
                }
        }
        Button("Start Animation") {
            withAnimation {
                trigger.toggle()
            } completion: {
                value += 1
            }
        }
    }