Proper way to stop an infinitely rotating image? and how does one implement removeAllAnimations?

1.3k views Asked by At

I'd like to use a button as a toggle – click once & an image rotates indefinitely. Click again, the image stops, click again, it restarts.

I found this answer helpful in getting the animation to continue: Rotate a view for 360 degrees indefinitely in Swift?

However, I'm unclear on how to stop things. I've implemented the code below & it seems to work, but am curious if this is the proper way to stop an animation, or if there is another, preferred method. Also - my rotation continues until finishing, but I'm wondering if I can freeze the rotation at location when the button is pressed (I've tried .removeAllAnimations() in the second attempt below, but that doesn't seem to work at all.

    @IBOutlet weak var imageView: UIImageView!
    var stopRotation = true

    func rotateView(targetView: UIView, duration: Double = 1.0) {
        if !stopRotation {
            UIView.animate(withDuration: duration, delay: 0.0, options: .curveLinear, animations: {
                targetView.transform = targetView.transform.rotated(by: CGFloat(Double.pi))
            }) { finished in
                self.rotateView(targetView: targetView, duration: duration)
            }
        }
    }
    
    @IBAction func spinPressed(_ sender: UIButton) {
        stopRotation = !stopRotation
        if !stopRotation {
            rotateView(targetView: imageView)
        }
    }

This does work. I was also wondering if it'd be possible to stop the animation mid-spin. The way it's set up, the animation goes the full 180 degrees before stopping. I've also tried adding a removeAnimation in the spinPressed action, and getting rid of the stopRotation check inside rotateView, but that doesn't seem to work – rotation continues & just gets faster if the spinPressed is pressed again (see below):

    @IBOutlet weak var imageView: UIImageView!
    var stopRotation = true
    
    func rotateView(targetView: UIView, duration: Double = 1.0) {
        UIView.animate(withDuration: duration, delay: 0.0, options: .curveLinear, animations: {
            targetView.transform = targetView.transform.rotated(by: CGFloat(Double.pi))
        }) { finished in
            self.rotateView(targetView: targetView, duration: duration)
        }
    }
    
    @IBAction func spinPressed(_ sender: UIButton) {
        stopRotation = !stopRotation
        if stopRotation {
            imageView.layer.removeAllAnimations()
        } else {
            rotateView(targetView: imageView)
        }
    }

A confirm if first approach is sound is welcome. And if there is a way to stop the rotation mid-spin, that'd also be welcome (as well as setting me straight on my flawed thinking on removeAllAnimations).

Thanks! JG

3

There are 3 answers

6
Rob On BEST ANSWER

There are a couple of ways to do what you're asking about:

  1. If supporting iOS 10+, you can use UIViewPropertyAnimator, whose animations you can pause and restart (resuming from where it was paused):

     private var animator: UIViewPropertyAnimator?
    
     deinit {
         animator?.stopAnimation(true)
     }
    
     @IBAction func didTapButton(_ sender: Any) {
         guard let animator = animator else {
             createAnimation()
             return
         }
    
         if animator.isRunning {
             animator.pauseAnimation()
         } else {
             animator.startAnimation()
         }
     }
    
     /// Create and start 360 degree animation
     ///
     /// This will fire off another animation when one 360° rotation finishes.
    
     private func createAnimation() {
         animator = UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 4, delay: 0, options: .curveLinear) { [self] in
             UIView.animateKeyframes(withDuration: 4, delay: 0) {
                 UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 1.0 / 3.0) {
                     animatedView.transform = .init(rotationAngle: .pi * 2 * 1 / 3)
                 }
                 UIView.addKeyframe(withRelativeStartTime: 1.0 / 3.0, relativeDuration: 1.0 / 3.0) {
                     animatedView.transform = .init(rotationAngle: .pi * 2 * 2 / 3)
                 }
                 UIView.addKeyframe(withRelativeStartTime: 2.0 / 3.0, relativeDuration: 1.0 / 3.0) {
                     animatedView.transform = .identity
                 }
             }
         } completion: { [weak self] _ in
             self?.createAnimation()
         }
     }
    
  2. You can alternatively use UIKit Dynamics to rotate the item. You can then remove a UIDynamicItemBehavior that was performing the rotation and it just stops where it was. It automatically leaves the view transform where it was. Then, to resume the rotation, just add a UIDynamicItemBehavior for the rotation again:

     private lazy var animator = UIDynamicAnimator(referenceView: view)
     private var rotate: UIDynamicItemBehavior!
    
     @IBAction func didTapButton(_ sender: Any) {
         if let rotate = rotate {
             animator.removeBehavior(rotate)
             self.rotate = nil
         } else {
             rotate = UIDynamicItemBehavior(items: [animatedView])
             rotate.allowsRotation = true
             rotate.angularResistance = 0
             rotate.addAngularVelocity(1, for: animatedView)
             animator.addBehavior(rotate)
         }
     }
    

    This doesn't let you easily control the speed of the rotation in terms of time, but rather it’s dictated by angularVelocity, but it's a nice simple approach (and supports iOS 7.0 and later).

  3. The old-school approach for stopping an animation and leaving it where you stopped it is to capture the presentationLayer of the animation (which shows where it was mid-flight). Then you can grab the current state, stop the animation, and set the transform to what the presentationLayer reported.

     private var isAnimating = false
    
     @IBAction func didTapButton(_ sender: Any) {
         if isAnimating {
             let transform = animatedView.layer.presentation()!.transform
             animatedView.layer.removeAllAnimations()
             animatedView.layer.transform = transform
         } else {
             let rotate = CABasicAnimation(keyPath: "transform.rotation")
             rotate.byValue = 2 * CGFloat.pi
             rotate.duration = 4
             rotate.repeatCount = .greatestFiniteMagnitude
             animatedView.layer.add(rotate, forKey: nil)
         }
    
         isAnimating = !isAnimating
     }
    
  4. If you want to use UIView block based animation, you have to capture the angle at which you stopped the animation, so you know from where to restart the animation. The trick is grab m12 and m11 of the CATransform3D:

     angle = atan2(transform.m12, transform.m11)
    

    Thus, this yields:

     private var angle: CGFloat = 0
     private var isAnimating = false
    
     @IBAction func didTapButton(_ sender: Any) {
         if isAnimating {
             let transform = animatedView.layer.presentation()!.transform
             angle = atan2(transform.m12, transform.m11)
             animatedView.layer.removeAllAnimations()
             animatedView.layer.transform = transform
         } else {
             UIView.animate(withDuration: 4, delay: 0, options: .curveLinear) { [self] in
                 UIView.animateKeyframes(withDuration: 4, delay: 0, options: .repeat) {
                     UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 1.0 / 3.0) {
                         animatedView.transform = .init(rotationAngle: self.angle + .pi * 2 * 1 / 3)
                     }
                     UIView.addKeyframe(withRelativeStartTime: 1.0 / 3.0, relativeDuration: 1.0 / 3.0) {
                         animatedView.transform = .init(rotationAngle: self.angle + .pi * 2 * 2 / 3)
                     }
                     UIView.addKeyframe(withRelativeStartTime: 2.0 / 3.0, relativeDuration: 1.0 / 3.0) {
                         animatedView.transform = .init(rotationAngle: self.angle)
                     }
                 }
             }
         }
    
         isAnimating = !isAnimating
     }
    
  5. You can rotate the object yourself using CADisplayLink that updates the angle to some calculated value. Then stopping the rotation is as simple as invalidating the display link, thereby leaving it where it was when it stopped. You can then resume animation by simply adding the display link back to your runloop.

    This sort of technique gives you a great deal of control, but is the least elegant of the approaches.

0
AudioBubble On

I have an infinite "flip" of to labels (it's a watermark) that I need to "turn off" to one specific label (or watermark) when the share options are being displayed. I have all of this happening through a timer - to keep a specific label viewed for a few seconds before flipping. When you do this, it's a simple matter of turning the timer off.

public class FlipView:UIView {

    private var labelWatermark:FlipLabel!
    private var labelTap:FlipLabel!

    let transitionOptions: UIViewAnimationOptions = [.transitionFlipFromRight, .showHideTransitionViews]
    var isLabel1 = true
    var timer:Timer!

    convenience public init(appName:String) {

        self.init(frame: CGRect.zero)

        labelTap = FlipLabel("Tap to remove", "Watermark")
        self.addSubview(labelTap)

        labelWatermark = FlipLabel("created with", appName)
        self.addSubview(labelWatermark)

        timer = Timer.scheduledTimer(
            timeInterval: 3.0, target: self, selector: #selector(flipViews),
            userInfo: nil, repeats: true)
        timer.fire()

    }

    internal func startFlip() {
        timer = Timer.scheduledTimer(
            timeInterval: 3.0, target: self, selector: #selector(flipViews),
            userInfo: nil, repeats: true)
        timer.fire()
    }

    internal func stopFlip() {
        timer.invalidate()
        UIView.transition(from: labelTap, to: labelWatermark, duration: 1, options: transitionOptions, completion: nil)
        isLabel1 = true
    }

    @objc func flipViews() {
        if (isLabel1) {
            UIView.transition(from: labelWatermark, to: labelTap, duration: 1, options: transitionOptions, completion: nil)
            isLabel1 = false
        } else {
            UIView.transition(from: labelTap, to: labelWatermark, duration: 1, options: transitionOptions, completion: nil)
            isLabel1 = true
        }
    }
}
0
Bruce1q On

This first approach seems reasonable. If you want fine grain control of the stopping position, then divide both the animation duration and the rotation radians by a constant amount. Your completion hander will be called more frequently to check if the rotation should be continued.

let animationFrequency = 30.0
 func rotateView(targetView: UIView, duration: Double = 1.0 / animationFrequency) {
        UIView.animate(withDuration: duration, delay: 0.0, options: .curveLinear, animations: {
            targetView.transform = targetView.transform.rotated(by: CGFloat(Double.pi / animationFrequency))
        }) { finished in
            self.rotateView(targetView: targetView, duration: duration)
        }
    }