SwiftUI: Implicit Animation shows different animation than explicit animation when using AnimatableModifier

Viewed 112

I'm running into a problem where it's difficult to use explicit animation for a particular state change, and I'd like to rely on SwiftUI implicit animation using a AnimatableModifier. Example code below to show a rotation flip effect.

When I trigger the state change using withAnimation call I get the expected animation that shows the flip happening when the view object is rotated by 90 degrees.

When I rely on implicit animation modifier of the view "flips" earlier - at somewhere around 45-60.

Not sure if I'm using AnimatableModifier wrong or it cannot be used implicitly?

enter image description here


struct ContentView: View {

    @State var implicitPressed: Bool = false
    @State var explicitPressed: Bool = false
    
    var body: some View {

        VStack {
            Text("Implicit")
            FlippedRectangle(isPressed: $implicitPressed)
                .onTapGesture {
                    implicitPressed.toggle()
                }
                .animation(.easeInOut(duration: 3))

            Text("Explicit")
            FlippedRectangle(isPressed: $explicitPressed)
                .onTapGesture {
                    withAnimation(.easeInOut(duration: 3)) {
                        explicitPressed.toggle()
                    }
                }
        }
    }
}

struct FlippedRectangle: View {

    @Binding var isPressed: Bool

    var body: some View {

        ZStack {

            RoundedRectangle(cornerRadius: 25.0)
                .fill(Color.blue)
                .modifier(FlipModifer(angle: isPressed ? 180 : 0, reversed: true))
            
            RoundedRectangle(cornerRadius: 25.0)
                .fill(Color.green)
                .modifier(FlipModifer(angle: isPressed ? 180 : 0))
        }
        .frame(width: 150, height: 200)
    }
}

struct FlipModifer: AnimatableModifier {

    var angle: Double
    var zIndex: Double = 0
    var reversed: Bool = false
    
    var animatableData: Double {

        get {
            angle
        }
        set {
            self.angle = newValue
            self.zIndex = (newValue < 90) ? (reversed ? -1 : 0) : (reversed ? 0 : -1)
        }
    }
    
    private var rotation: Angle { Angle.degrees(angle) + ( reversed ? .degrees(180) : .degrees(0)) }
    
    func body(content: Content) -> some View {
        content
            .rotation3DEffect(rotation, axis: (x: 0.0, y: 1.0, z: 0.0))
            .zIndex(zIndex)
    }
}
1 Answers

Tried a bunch of things ... still not sure if AnimatableModifiers can work as you expect implicitly for a state change in a view.

Worked around by creating a middle man generic container view that has an observer .onChange to a bound flipped state and calls a local state explicitly on change. Seems horribly complex and non-declarative - so I'm sure there are way better solutions.

enter image description here

struct Flip<Obverse, Reverse>: View where Obverse: View, Reverse: View {

@Binding var isFlipped: Bool
var animation: Animation
var obverse: () -> Obverse
var reverse: () -> Reverse
var axis: Axis

init(isFlipped: Binding<Bool>,
     animation: Animation = .default,
     axis: Axis = .y,
     @ViewBuilder obverse: @escaping () -> Obverse,
     @ViewBuilder reverse: @escaping () -> Reverse
) {
    self._isFlipped = isFlipped
    self.animation = animation
    self.axis = axis
    self.obverse = obverse
    self.reverse = reverse
}

@State var flipAnimate: Bool = false

var body: some View {
    obverse()
        .modifier(
            FlipModifer(
                isFlipped: flipAnimate,
                axis: axis,
                obverse: obverse(),
                reverse: reverse() )
        )
        .onChange(of: isFlipped) { _ in
            withAnimation(animation) {
                flipAnimate = isFlipped
            }
        }
}

private struct FlipModifer<Obverse,Reverse>: AnimatableModifier
where Obverse: View, Reverse: View {
    
    var obverse: Obverse
    var reverse: Reverse
    var rotation: Double
    var axis: Axis
    
    var flipped: Bool { rotation > 90 }
    
    init(isFlipped: Bool, axis: Axis, obverse: Obverse, reverse: Reverse) {
        self.rotation = isFlipped ? 180 : 0
        self.axis = axis
        self.obverse = obverse
        self.reverse = reverse
    }
    
    var animatableData: Double {
        get { rotation }
        set { rotation = newValue }
    }
    
    func body(content: Content) -> some View {
        Group {
            if !flipped {
                obverse
            }
            else {
                reverse
            }
        }
        .rotation3DEffect(.degrees(rotation), axis: axis.value)
    }
}

enum Axis {
    case x, y
    var value: (CGFloat, CGFloat, CGFloat) {
        switch self {
        case .x: return (1,0,0)
        case .y: return (0,1,0)
        }
    }
}

}

Related