swiftui

SwiftUI pause/resume rotation animation


So far I've seen the following technique for stopping an animation, but what I'm looking for here is that the rotating view stops at the angle it was at the moment and not return to 0.

struct DemoView: View {
    @State private var isRotating: Bool = false
    var foreverAnimation: Animation {
        Animation.linear(duration: 1.8)
            .repeatForever(autoreverses: false)
    }
    var body: some View {
        Button(action: {
            self.isRotating.toggle()
        }, label: {
            Text("🐰").font(.largeTitle)
            .rotationEffect(Angle(degrees: isRotating ? 360 : 0))
            .animation(isRotating ? foreverAnimation : .linear(duration: 0))
        })
    }
}

It seems having the rotation angle to be either 360 or 0 doesn't let me freeze it at an intermediate angle (and eventually resume from there). Any ideas?


Solution

  • Pausing and resuming the animation in SwiftUI is really easy and you're doing it right by determining the animation type in the withAnimation block.

    The thing you're missing is two additional pieces of information:

    1. What is the value at which the animation was paused?
    2. What is the value to which the animation should proceed while resumed?

    These two pieces are crucial because, remember, SwiftUI views are just ways of expressing what you want your UI to look like, they are only declarations. They are not keeping the current state of UI unless you yourself provide the update mechanism. So the state of the UI (like the current rotation angle) is not saved in them automatically.

    While you could introduce the timer and compute the angle values yourself in a discreet steps of X milliseconds, there is no need for that. I'd suggest rather letting the system compute the angle value in a way it feels appropriate.

    The only thing you need is to be notified about that value so that you can store it and use for setting right angle after pause and computing the right target angle for resume. There are few ways to do that and I really encourage you to read the 3-part intro to SwiftUI animations at https://swiftui-lab.com/swiftui-animations-part1/.

    One approach you can take is using the GeometryEffect. It allows you to specify transform and rotation is one of the basic transforms, so it fits perfectly. It also adheres to the Animatable protocol so we can easily participate in the animation system of SwiftUI.

    The important part is to let the view know what is the current rotation state so that it know what angle should we stay on pause and what angle should we go to when resuming. This can be done with a simple binding that is used EXCLUSIVELY for reporting the intermediate values from the GeometryEffect to the view.

    The sample code showing the working solution:

    import SwiftUI
    
    struct PausableRotation: GeometryEffect {
      
      // this binding is used to inform the view about the current, system-computed angle value
      @Binding var currentAngle: CGFloat
      private var currentAngleValue: CGFloat = 0.0
      
      // this tells the system what property should it interpolate and update with the intermediate values it computed
      var animatableData: CGFloat {
        get { currentAngleValue }
        set { currentAngleValue = newValue }
      }
      
      init(desiredAngle: CGFloat, currentAngle: Binding<CGFloat>) {
        self.currentAngleValue = desiredAngle
        self._currentAngle = currentAngle
      }
      
      // this is the transform that defines the rotation
      func effectValue(size: CGSize) -> ProjectionTransform {
        
        // this is the heart of the solution:
        //   reporting the current (system-computed) angle value back to the view
        //
        // thanks to that the view knows the pause position of the animation
        // and where to start when the animation resumes
        //
        // notice that reporting MUST be done in the dispatch main async block to avoid modifying state during view update
        // (because currentAngle is a view state and each change on it will cause the update pass in the SwiftUI)
        DispatchQueue.main.async {
          self.currentAngle = currentAngleValue
        }
        
        // here I compute the transform itself
        let xOffset = size.width / 2
        let yOffset = size.height / 2
        let transform = CGAffineTransform(translationX: xOffset, y: yOffset)
          .rotated(by: currentAngleValue)
          .translatedBy(x: -xOffset, y: -yOffset)
        return ProjectionTransform(transform)
      }
    }
    
    struct DemoView: View {
      @State private var isRotating: Bool = false
      
      // this state keeps the final value of angle (aka value when animation finishes)
      @State private var desiredAngle: CGFloat = 0.0
      
      // this state keeps the current, intermediate value of angle (reported to the view by the GeometryEffect)
      @State private var currentAngle: CGFloat = 0.0
      
      var foreverAnimation: Animation {
        Animation.linear(duration: 1.8)
          .repeatForever(autoreverses: false)
      }
    
      var body: some View {
        Button(action: {
          self.isRotating.toggle()
          // normalize the angle so that we're not in the tens or hundreds of radians
          let startAngle = currentAngle.truncatingRemainder(dividingBy: CGFloat.pi * 2)
          // if rotating, the final value should be one full circle furter
          // if not rotating, the final value is just the current value
          let angleDelta = isRotating ? CGFloat.pi * 2 : 0.0
          withAnimation(isRotating ? foreverAnimation : .linear(duration: 0)) {
            self.desiredAngle = startAngle + angleDelta
          }
        }, label: {
          Text("🐰")
            .font(.largeTitle)
            .modifier(PausableRotation(desiredAngle: desiredAngle, currentAngle: $currentAngle))
        })
      }
    }