Why does SwiftUI animation work differently when triggered from within gesture?

132 views Asked by At

I'm curious, has anyone else run into this situation where animation, that works in other situations, doesn't work from within a gesture?

I'm trying to reproduce the swipe-to-delete gesture like in the Reminders app, and I don't know why I can't get the animation for the hiding of the grey and orange buttons to work correctly during a swipe gesture.

I have set up two buttons ("Swipe a Bit" and "Swipe a Lot") to set the exact same State variables (swipeAmount and omitExtraButtons) in effectively the same way as in the gesture closure. The animation works fine when I hit the "Swipe a Lot" button. But during the swipe gesture, the layout is correct, but the animation for going from omitExtraButtons == false to omitExtraButtons == true does not happen. How can we explain this?

Here's the code I'm using:

struct StagedSwipeToDelete: View {
    @State private var swipeAmount = 0.0
    @State private var omitExtraButtons = false
    
    private let buttonWidth = 250.0
    private let buttonHeight = 50.0
    private let deleteThreshold = 100.0

    struct UnderButton: View {
        var text: String
        var color: Color
        var body: some View {
            Text(text)
                .lineLimit(1)
                .foregroundStyle(.white)
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .background(color)
        }
    }
    
    var body: some View {
        VStack {
            ZStack {
                // Controls hidden underneath Top-Level View
                HStack(spacing: 0) {
                    Color.clear
                        .frame(width: buttonWidth - swipeAmount)
                    if !omitExtraButtons {
                        UnderButton(text: "Details", color: .secondary)
                        UnderButton(text: "Flag", color: .orange)
                    }
                    UnderButton(text: "Delete", color: .red)
                }
                .frame(width: buttonWidth, height: buttonHeight)
                
                // Top-level View
                Text("Hello, World!")
                    .frame(width: buttonWidth, height: buttonHeight)
                    .background(.white)
                    .border(.gray)
                    .offset(x: -swipeAmount)
                    .gesture(
                        DragGesture()
                            .onChanged {
                                swipeAmount = max(0, $0.startLocation.x - $0.location.x)
                                // Shouldn't be needed, but I'm not setting omitExtraButtons
                                // unless it's value needs to change.
                                if !omitExtraButtons && swipeAmount > deleteThreshold {
                                    withAnimation(.easeInOut(duration: 3)) {
                                        omitExtraButtons = true
                                        print("omitExtraButtons turned on, with animation")
                                    }
                                }
                                if omitExtraButtons && swipeAmount <= deleteThreshold {
                                    withAnimation(.easeInOut(duration: 3)) {
                                        omitExtraButtons = false
                                        print("omitExtraButtons turned off, with animation")
                                    }
                                }
                            }
                    )
            }
            
            // Randomness added so that we can test multiple events happening during animation
            Button("Swipe a Bit") {
                swipeAmount = Double.random(in: 1.0...(deleteThreshold-1.0))
                withAnimation(.easeInOut(duration: 3.0)) {
                    omitExtraButtons = false
                }
            }
            Button("Swipe a Lot") {
                swipeAmount = Double.random(in: (deleteThreshold+1.0)...buttonWidth)
                withAnimation(.easeInOut(duration: 3.0)) {
                    omitExtraButtons = true
                }
            }
        }
        .padding()
    }
}

Edit: There was a suggestion in an answer that the behaviour might be due to omission of some views (around the if !omitExtraButtons code) or because of the use of a separate State variable for omitExtraButtons. I investigated this be re-implementing my example code, but the behaviour is unchanged. In other words we still don't have a good understanding of why we have the bad behaviour.

Again, compare how the animation works when you use a swipe gesture vs how it works when you hit the "Swipe a Lot" button. Especially if you hit it many times quickly.

Note: Potentially, there is a clue in the fact that the text of the "Delete" button animates in the way we expect, but the red background for it is resizing instantly.

Here's the updated code:

struct StagedSwipeToDelete: View {
    @State private var swipeAmount = 0.0
    private var omitExtraButtons: Bool {
        swipeAmount > deleteThreshold
    }

    private let buttonWidth = 250.0
    private let buttonHeight = 50.0
    private let deleteThreshold = 100.0

    struct UnderButton: View {
        var text: String
        var color: Color
        var body: some View {
            Text(text)
                .lineLimit(1)
                .foregroundStyle(.white)
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .background(color)
        }
    }
    
    var body: some View {
        VStack {
            ZStack {
                // The Controls under the Top-Level View
                HStack(spacing: 0) {
                    Color.clear
                        .frame(width: max(0, buttonWidth - swipeAmount))
                    ZStack(alignment: .trailing) {
                        // The Detail and Flag buttons are in a layer underneath
                        HStack(spacing: 0) {
                            UnderButton(text: "Details", color: .secondary)
                            UnderButton(text: "Flag", color: .orange)
                            Color.red     // placeholder for "Delete" button
                        }
                        
                        // Our actual "Delete" button is in a layer above
                        HStack(spacing:0) {
                            let placeholderWidth: CGFloat? = omitExtraButtons ? 0 : swipeAmount/3
                            
                            Color.clear.frame(width: placeholderWidth) // placeholder for "Details"
                            Color.clear.frame(width: placeholderWidth) // placeholder for "Flag"
                            UnderButton(text: "Delete", color: .red)
                        }
                        .animation(.easeInOut(duration: 2), value: omitExtraButtons)
                    }
                    .frame(width: min(swipeAmount, buttonWidth))
                }
                .frame(width: buttonWidth, height: buttonHeight)
                
                
                // Top-level View
                Text("Hello, World!")
                    .frame(width: buttonWidth, height: buttonHeight)
                    .background(.white)
                    .border(.gray)
                    .offset(x: -swipeAmount)
                    .gesture(
                        DragGesture()
                            .onChanged { val in
                                swipeAmount = max(0, -val.translation.width)
                            }
                    )
            }
            
            // Randomness added so that we can test multiple events happening during animation
            Button("Swipe a Bit") {
                swipeAmount = Double.random(in: 1.0...(deleteThreshold-1.0))
            }
            Button("Swipe a Lot") {
                swipeAmount = Double.random(in: (deleteThreshold+1.0)...buttonWidth)
            }
        }
        .padding()
    }
}

1

There are 1 answers

6
Benzy Neez On

When the two buttons for Details and Flag are omitted, a transition happens. The buttons themselves fade out with an .opacity transition and the Delete button fills the gap that is caused by their removal.

If the change happens due to a test button being pressed, SwiftUI examines the start state and end state and animates the transition nicely -> OK. But if it happens during a drag gesture, my guess is that the repaints are so frequent that the animation is lost, because the transition happens between one repaint and the next -> NOK.

You get more of a transition if you use withAnimation to perform changes to swipeAmount, instead of to omitExtraButtons. But then there is actually too much animation and all sorts of lag effects begin to be seen.

The following version works around these issues. The main changes:

  • omitExtraButtons is implemented as a computed property, instead of as a separate State variable.
  • The Delete button is shown in an overlay and its width is adjusted to cover the hidden buttons when swipeAmount exceeds the threshold.
  • The first two buttons do not actually get omitted, they are just covered instead.
  • The overlay actually has its own overlay and background. The base is plain red, with changes animated in connection with changes to swipeAmount. When it switches from a narrow button to a wide button, the change in width is animated.
  • The Delete button itself is shown with a clear background as an overlay over the red base. The button is animated in connection with changes to omitExtraButtons. This means, there is an animation when the width switches, but otherwise the label moves in synchronization with the drag gesture.
  • Because the animations to the red base of the Delete button are in connection with changes to swipeAmount (as opposed to omitExtraButtons), every change in width is animated. This causes an undesirable lag effect when the button is either small or large, in other words, at all times when it is not undergoing animation from small to large. To mask out this lag, Color.red is shown in the layers behind it, so as to fill the gaps.
  • A separate state variable is used to control the opacity of the red background layer. Changes are only animated when going from hidden to visible (with delay), not from visible to hidden.

Here you go, hope it helps:

//@State private var omitExtraButtons = false
@State private var backgroundOpacity = CGFloat.zero

private var omitExtraButtons: Bool {
    swipeAmount > deleteThreshold
}

var body: some View {
    VStack {
        ZStack {
            // Controls hidden underneath Top-Level View
            HStack(spacing: 0) {
                Color.clear
                    .frame(width: max(0, buttonWidth - swipeAmount))
                UnderButton(text: "Details", color: .secondary)
                UnderButton(text: "Flag", color: .orange)
                Color.red // placeholder for button in overlay
            }
            .overlay {
                Color.red
                    .frame(width: min(swipeAmount, buttonWidth) / (omitExtraButtons ? 1 : 3))
                    .animation(.easeInOut, value: swipeAmount)
                    .overlay {
                        UnderButton(text: "Delete", color: .clear)
                            .animation(.easeInOut, value: omitExtraButtons)
                            .contentShape(Rectangle())
                    }
                    .frame(maxWidth: .infinity, alignment: .trailing)
                    .background {
                        Color.red
                            .allowsHitTesting(false)
                            .opacity(backgroundOpacity)
                            //.onChange(of: omitExtraButtons) { newVal in // pre iOS 17
                            .onChange(of: omitExtraButtons) { _, newVal in
                                if newVal {
                                    withAnimation(.easeInOut(duration: 0.05).delay(0.2)) {
                                        backgroundOpacity = 1
                                    }
                                } else {
                                    backgroundOpacity = 0
                                }
                            }
                    }
            }
            .frame(width: buttonWidth, height: buttonHeight)

            // Top-level View
            Text("Hello, World!")
                .frame(width: buttonWidth, height: buttonHeight)
                .background(.white)
                .border(.gray)
                .offset(x: -swipeAmount)
                .gesture(
                    DragGesture()
                        .onChanged { val in
                            swipeAmount = max(0, -val.translation.width)
                        }
                )
        }
        // Randomness added so that we can test multiple events happening during animation
        Button("Swipe a Bit") {
            swipeAmount = Double.random(in: 1.0...(deleteThreshold-1.0))
        }
        Button("Swipe a Lot") {
            swipeAmount = Double.random(in: (deleteThreshold+1.0)...buttonWidth)
        }
    }
    .padding()
}

Animation