animationswiftuitask

SwiftUI Task seems changing the hardcoded value during the animation


I have a button style that offsets on press but it can be forced to offset without pressing:

let distance: CGFloat = 50 // Hardcoded distance limit

struct ContentView: View {
    @State var isForced = false

    var body: some View {
        Button("") {
//            Task { // 👈 Uncommenting causes the issue
                isForced.toggle()
//            }
        }
        .buttonStyle(MyButtonStyle(isForced: isForced))

        .overlay(alignment: .leading) { Color.yellow.frame(width: 2).offset(x: -distance) } // Animation issue visibility helper
    }
}

struct MyButtonStyle: ButtonStyle {
    var isForced: Bool

    func makeBody(configuration: Configuration) -> some View {
        Color.red.frame(width: 100, height: 100)
            .offset(x: configuration.isPressed || isForced ? -distance : 0)
    }
}

Everything works as expected: Expected

But as soon as I put the toggle() ation in a Task, it start to behaviour weirdly and offsets the view to an unknown distance on release of the touch.

Unexpected

Why it goes beyond the defined offset and how much is the extra value? How can I avoid that?

Thanks.

p.s. This is the minimum reproducible demo project. In reality, the toggle() is connected to an async service in the viewModel, so it can not be outside of the task.


Solution

  • Let's print out configuration.isPressed and isForced in makeBody to see what changes they undergo.

    func makeBody(configuration: Configuration) -> some View {
        let _ = print(configuration.isPressed, isForced)
        ...
    

    With the Task, this outputs:

    1. false false initially
    2. true false when you put your finger down
    3. false false when you lift your finger up
    4. false true immediately after

    Without the task, it goes from true false straight to false true when you lift your finger up, without going through the false false state. This shows the difference that the Task makes - it creates an extra change.

    Now consider which of these changes are animated. The default behaviour of Button is that it only animates when configuration.isPressed changes from true to false (i.e. only when you lift your finger). With the Task, this means only the change from true false to false false is animated. Without the Task, only the change from true false to false true is animated.

    A change from true false to false true does not change the offset, so you cannot visually see any animation after you lift the finger. This only happens in the case without Task.

    In the case with Task, the change from true false to false false is animated, so SwiftUI would start an animation that moves the red square 50pt to the right since the offset changes from -50 to 0.

    But immediately after that, there is another change from false false to false true, this time without animation. This change causes the red square to go 50pt to the left since the offset changes from 0 to -50.

    The second change happens while the animation for the first change hasn't finished. At this point SwiftUI will merge the two transactions. To be clear, these are the transactions that it is trying to merge:

    1. move the square 50pt to the left without animation (instantly)
    2. move the square 50pt to the right with animation

    Imagine these two things happening simultaneously. The animation will start at the same time as the square instantly moves 50pt to the left. Recall that the square is already at an offset of -50pt (recall that the previous state is true false). As a result, the square appears at an offset of -100pt, and the animation moves it back to an offset of -50pt.

    You could say that the visual glitch happens because SwiftUI merged two very different transactions together (an instantaneous one and one that takes some time).

    Now that we understand that, we can create an even more minimal example that reproduces the behaviour:

    @State var x = true
    
    var body: some View {
        Button("Go") {
            withAnimation(.default) { // default animation
                x = false
            }
            withAnimation(nil) { // no animation
                x = true
            }
        }
        
        Color.red.frame(width: 100, height: 100)
            .offset(x: x ? -distance : 0)
    }
    

    One way to fix this is to have the change from false false to false true be animated in the same way (same animation curve) as the change from true false to false false. When the animations are merged, they will cancel out each other exactly for every frame.

    Other than using withAnimation as sonle suggested, you can also put

    .animation(.default, value: isForced)
    

    in makeBody. This assumes that the button's built-in animation is also .default. If you want to be extra sure, you can additionally do:

    .animation(!configuration.isPressed ? .default : nil, value: configuration.isPressed)