I'm so confused: why @State var inside a View do not change when you call a method on this view from some outer View?

Sigh at SwiftUI... yeah, that's the problem with the current animation in SwiftUI, that it doesn't support keyframe animation, and so we somehow end up with Animatable shenanigan. Though I'm not even sure if keyframe would make sense in SwiftUI.

Anyhow, we need to split the animation to utilise the nontrivial curves they provide. If we're to split the animation anyway, might as well just go simple.

Text(verbatim: "🤔").font(.system(size: 150))
  .scaleEffect(animationFlag ? 1 : 1.2)
  .animation(Animation
    .linear(duration: Self.animationDuration)
    .delay(animationFlag ? Self.animationDuration : 0))

  .scaleEffect(animationFlag ? 1 : (1 / 1.2))
  .animation(Animation
    .linear(duration: Self.animationDuration)
    .delay(animationFlag ? 0 : Self.animationDuration))

It should still be safer than using async but I don't do this too often, so I can't really say.


If you want the asymmetric animation: you can reset the animationFlag, then toggle it in a different transaction/animation.

func doFoo() {
  withAnimation {
    animationFlag = false
  }

  animationFlag = true
}

Then the animation sequence also becomes slightly simpler, as it becomes unidirectional:

Text(verbatim: "🤔").font(.system(size: 150))
  .scaleEffect(animationFlag ? 1.2 : 1)
  .animation(Animation
    .linear(duration: Self.animationDuration))

  .scaleEffect(animationFlag ? (1 / 1.2) : 1)
  .animation(Animation
    .linear(duration: Self.animationDuration)
    .delay(Self.animationDuration))

It'd be more obvious how they stitch together if you try to use rotationEffect on the second half.

The solution is actually very simple with your code and combine with Amination.repeatCount(n, autoreverse: true): just make an AnimatableModifier to scale relative to its origin and fully to the end. autoreverse: true would take care of animating back. It works as long as the repeat count is an odd number. No sure why it has to be an odd number.

I put some print statements in trying to understand. Though the animation on screen look correct. The print out don't make sense to me. On every other run if the starting value was 0, instead of interpolate from 0, it starts at 1. Same with 1, it would start interpolate from 0. The animation looks correct. I don't know what's going on here.

import SwiftUI
import Combine

struct BarView: View {
    let fooTrigger = ObservableObjectPublisher()

    var body: some View {
        VStack {
            FooView(trigger: fooTrigger)

            Button("Animate above...") {
                self.fooTrigger.send()
            }
        }
    }
}

struct FooView: View {
    static var animationDuration = 1.0

    let trigger: ObservableObjectPublisher
    @State var animationFlag = false

    func doFoo() {
        animationFlag.toggle()
    }

    var body: some View {
        VStack {
            ZStack {
                Text(verbatim: "🤔").font(.system(size: 150))
                    .modifier(UnidirectionalScalingModifier(animationFlag))
                    .animation(Animation.easeInOut(duration: Self.animationDuration).repeatCount(3, autoreverses: true))
            }
            .frame(width: 200, height: 200)
            Text(verbatim: "self.flag = \(self.animationFlag)")
        }
        .onReceive(trigger, perform: self.doFoo)
    }
}

struct UnidirectionalScalingModifier: AnimatableModifier {
  var animatableData: CGFloat
  let origin: CGFloat

  init(_ value: Bool) {
    animatableData = value ? 0 : 1
    origin = animatableData
      print("\n\nScalingModifier\n-=-=-=-=-=-=-\norigin: \(animatableData)")
  }

  func body(content: Content) -> some View {
    content
        .scaleEffect(currentScale)
  }

  private var currentScale: CGFloat {
    print("ScalingModifier: animatableData = \(animatableData)")
    let maxScale: CGFloat = 1.2, minScale: CGFloat = 1

    let currentValue = abs(animatableData - origin)     // scale relative to the origin
    return minScale + (maxScale - minScale) * currentValue
  }
}

struct BarView_Previews: PreviewProvider {
    static var previews: some View {
        BarView()
    }
}

Some useful tips, you can add text to AnimatableModifier to see what's going on.

struct Modifier: AnimatableModifier {
  var animatableData: CGFloat

  func body(content: Content) -> some View {
    Text("\(animatableData, specifier: "%.4f")").font(Font.system(size: 40).monospacedDigit())
  }
}

You'll see that repeatCount(3, autoreverse: false) will just do 0->1 0->1 0->1, but repeatCount(3, autoreverse: true) will do 0->1->0->1 (3 arrows).

And also that animatableData is set to destination at the animation end, regardless of the value right before the end.

Since the destination is always on the opposite side with even value, (0->1->0 will have destination of 1), it'll just blink like that.

We can cheat a bit and treat 1 as 0 by doing animatableData.truncatingRemainder(dividingBy: 1).

struct Modifier: AnimatableModifier {
  var animatableData: CGFloat

  func body(content: Content) -> some View {
    content.scaleEffect(currentScale)
  }

  var currentScale: CGFloat {
    let value = animatableData.truncatingRemainder(dividingBy: 1)
    let maxScale: CGFloat = 1.2, minScale: CGFloat = 1.0
    return minScale + (maxScale - minScale) * value
  }
}

Now you just need to make sure that when you trigger doFoo, the animatableData is 0 and is animating to 1.

@State var animationFlag = false
func doFoo() {
  withAnimation(nil) {
    animationFlag = false
  }

  animationFlag = true
}

var body: some View {
  Text(verbatim: "🤔").font(.system(size: 150))
    .modifier(Modifier(animatableData: animationFlag ? 0 : 1))
    .animation(Animation.linear(duration: Self.animationDuration).repeatCount(2, autoreverses: true))
}

The caveat is that if the interpolated data contains 1 it'll blink, but I think it should be fine in most cases.

1 Like