65

I have an endlessly looping CABasicAnimation of a repeating image tile in my view:

a = [CABasicAnimation animationWithKeyPath:@"position"];
a.timingFunction = [CAMediaTimingFunction 
                      functionWithName:kCAMediaTimingFunctionLinear];
a.fromValue = [NSValue valueWithCGPoint:CGPointMake(0, 0)];
a.toValue = [NSValue valueWithCGPoint:CGPointMake(image.size.width, 0)];
a.repeatCount = HUGE_VALF;
a.duration = 15.0;
[a retain];

I have tried to "pause and resume" the layer animation as described in Technical Q&A QA1673.

When the app enters background, the animation gets removed from the layer. To compensate I listen to UIApplicationDidEnterBackgroundNotification and call stopAnimation and in response to UIApplicationWillEnterForegroundNotification call startAnimation.

- (void)startAnimation 
{
    if ([[self.layer animationKeys] count] == 0)
        [self.layer addAnimation:a forKey:@"position"];

    CFTimeInterval pausedTime = [self.layer timeOffset];
    self.layer.speed = 1.0;
    self.layer.timeOffset = 0.0;
    self.layer.beginTime = 0.0;
    CFTimeInterval timeSincePause = 
      [self.layer convertTime:CACurrentMediaTime() fromLayer:nil] - pausedTime;
    self.layer.beginTime = timeSincePause;
}

- (void)stopAnimation 
{
    CFTimeInterval pausedTime = 
      [self.layer convertTime:CACurrentMediaTime() fromLayer:nil];
    self.layer.speed = 0.0;
    self.layer.timeOffset = pausedTime;    
}

The problem is that it starts again at the beginning and there is ugly jump from last position, as seen on app snapshot the system took when application did enter background, back to the start of the animation loop.

I can not figure out how to make it start at last position, when I re-add the animation. Frankly, I just don't understand how that code from QA1673 works: in resumeLayer it sets the layer.beginTime twice, which seems redundant. But when I've removed the first set-to-zero, it did not resume the animation where it was paused. This was tested with simple tap gesture recognizer, that did toggle the animation - this is not strictly related to my issues with restoring from background.

What state should I remember before the animation gets removed and how do I restore the animation from that state, when I re-add it later?

Palimondo
  • 7,281
  • 4
  • 39
  • 58
  • 1
    any luck with this? I have animations pausing and resuming in my game pause. However when I come out of background any interrupted animations seem to be complete. I know I can do this by capturing the state of the presentation layer. Seems like a real pain though. There must be an easier way! – Max MacLeod Oct 27 '11 at 09:17
  • 1
    No luck. I also keep the `[layer presentationLayer]` as a workaround in my back pocket, if the need to fix this gets critical. As you say, it seems like a quite a PITA. – Palimondo Oct 27 '11 at 13:44
  • 1
    I got the Apple QA1673 working and it was great for pausing and resuming the game. But what's the point when it doesn't work for backgrounding? For me the whole point of the QA1673 was to save the bother of doing it the presentationLayer way. – Max MacLeod Oct 27 '11 at 14:37

10 Answers10

53

Hey I had stumbled upon the same thing in my game, and ended up finding a somewhat different solution than you, which you may like :) I figured I should share the workaround I found...

My case is using UIView/UIImageView animations, but it's basically still CAAnimations at its core... The gist of my method is that I copy/store the current animation on a view, and then let Apple's pause/resume work still, but before resuming I add my animation back on. So let me present this simple example:

Let's say I have a UIView called movingView. The UIView's center is animated via the standard [UIView animateWithDuration...] call. Using the mentioned QA1673 code, it works great pausing/resuming (when not exiting the app)... but regardless, I soon realized that on exit, whether I pause or not, the animation was completely removed... and here I was in your position.

So with this example, here's what I did:

  • Have a variable in your header file called something like animationViewPosition, of type *CAAnimation**.
  • When the app exits to background, I do this:

    animationViewPosition = [[movingView.layer animationForKey:@"position"] copy]; // I know position is the key in this case...
    [self pauseLayer:movingView.layer]; // this is the Apple method from QA1673
    
    • Note: Those 2 ^ calls are in a method that is the handler for the UIApplicationDidEnterBackgroundNotification (similar to you)
    • Note 2: If you don't know what the key is (of your animation), you can loop through the view's layer's 'animationKeys' property and log those out (mid animation presumably).
  • Now in my UIApplicationWillEnterForegroundNotification handler:

    if (animationViewPosition != nil)
    {
        [movingView.layer addAnimation:animationViewPosition forKey:@"position"]; // re-add the core animation to the view
        [animationViewPosition release]; // since we 'copied' earlier
        animationViewPosition = nil;
    }
    [self resumeLayer:movingView.layer]; // Apple's method, which will resume the animation at the position it was at when the app exited
    

And that's pretty much it! It has worked for me so far :)

You can easily extend it for more animations or views by just repeating those steps for each animation. It even works for pausing/resuming UIImageView animations, ie the standard [imageView startAnimating]. The layer animation key for that (by the way) is "contents".

Listing 1 Pause and Resume animations.

-(void)pauseLayer:(CALayer*)layer
{
    CFTimeInterval pausedTime = [layer convertTime:CACurrentMediaTime() fromLayer:nil];
    layer.speed = 0.0;
    layer.timeOffset = pausedTime;
}

-(void)resumeLayer:(CALayer*)layer
{
    CFTimeInterval pausedTime = [layer timeOffset];
    layer.speed = 1.0;
    layer.timeOffset = 0.0;
    layer.beginTime = 0.0;
    CFTimeInterval timeSincePause = [layer convertTime:CACurrentMediaTime() fromLayer:nil] - pausedTime;
    layer.beginTime = timeSincePause;
}
Hashem Aboonajmi
  • 13,077
  • 8
  • 66
  • 75
cclogg
  • 709
  • 2
  • 7
  • 11
  • Awesome, glad it helped someone. I should mention if you are pausing a view and its subview (for instance, a container view moving position, with an imageview inside it animating), you have to copy and re-add BOTH animations, but you only have to pause/resume (using QA1673) the superview... and it'll apply it to all subviews... if that makes sense. – cclogg Apr 11 '12 at 00:34
  • It definitely helped me, it even works for CAAnimationGroups (considering there is no 'animationGroupForKey', just 'animationForKey'). – Rick van der Linde Feb 16 '14 at 13:55
  • Nice one - was struggling with this for a while! :) – dijipiji Aug 23 '16 at 11:10
  • Great answer! @cclogg I'm trying to use resume concept but to play animation backwards. Problem is that layer "disappears" when speed is set to -1.0. Have you an idea why? There is question about it here http://stackoverflow.com/questions/38292169/calayer-disappears-after-playing-a-cabasicanimation-backwards – sliwinski.lukas Jan 06 '17 at 17:21
19

After quite a lot of searching and talks with iOS development gurus, it appears that QA1673 doesn't help when it comes to pausing, backgrounding, then moving to foreground. My experimentation even shows that delegate methods that fire off from animations, such as animationDidStop become unreliable.

Sometimes they fire, sometimes they don't.

This creates a lot of problems because it means that, not only are you looking at a different screen that you were when you paused, but also the sequence of events currently in motion can be disrupted.

My solution thus far has been as follows:

When the animation starts, I get the start time:

mStartTime = [layer convertTime:CACurrentMediaTime() fromLayer:nil];

When the user hits the pause button, I remove the animation from the CALayer:

[layer removeAnimationForKey:key];

I get the absolute time using CACurrentMediaTime():

CFTimeInterval stopTime = [layer convertTime:CACurrentMediaTime() fromLayer:nil];

Using the mStartTime and stopTime I calculate an offset time:

mTimeOffset = stopTime - mStartTime;

I also set the model values of the object to be that of the presentationLayer. So, my stop method looks like this:

//--------------------------------------------------------------------------------------------------

- (void)stop
{
    const CALayer *presentationLayer = layer.presentationLayer;

    layer.bounds = presentationLayer.bounds;
    layer.opacity = presentationLayer.opacity;
    layer.contentsRect = presentationLayer.contentsRect;
    layer.position = presentationLayer.position;

    [layer removeAnimationForKey:key];

    CFTimeInterval stopTime = [layer convertTime:CACurrentMediaTime() fromLayer:nil];
    mTimeOffset = stopTime - mStartTime;
}

On resume, I recalculate what's left of the paused animation based upon the mTimeOffset. That's a bit messy because I'm using CAKeyframeAnimation. I figure out what keyframes are outstanding based on the mTimeOffset. Also, I take into account that the pause may have occurred mid frame, e.g. halfway between f1 and f2. That time is deducted from the time of that keyframe.

I then add this animation to the layer afresh:

[layer addAnimation:animationGroup forKey:key];

The other thing to remember is that you will need to check the flag in animationDidStop and only remove the animated layer from the parent with removeFromSuperlayer if the flag is YES. That means that the layer is still visible during the pause.

This method does seem very laborious. It does work though! I'd love to be able to simply do this using QA1673. But at the moment for backgrounding, it doesn't work and this seems to be the only solution.

Iulian Onofrei
  • 9,188
  • 10
  • 67
  • 113
Max MacLeod
  • 26,115
  • 13
  • 104
  • 132
  • I'm curious on how you did your recalculation to resume the animation. Supposing the animation is linear I think you can just calculate the distance between your offset and the end point and that's it. – pablasso Mar 13 '12 at 16:47
14

It's surprising to see that this isn't more straightforward. I created a category, based on cclogg's approach, that should make this a one-liner.

CALayer+MBAnimationPersistence

Simply invoke MB_setCurrentAnimationsPersistent on your layer after setting up the desired animations.

[movingView.layer MB_setCurrentAnimationsPersistent];

Or specify the animations that should be persisted explicitly.

movingView.layer.MB_persistentAnimationKeys = @[@"position"];
Matej Bukovinski
  • 6,152
  • 1
  • 36
  • 36
  • 1
    Works great! I did have to add #import in the .m file to get it to build. – Chris Prince May 04 '16 at 03:13
  • There are few problems here. If you pause the animations from outside of that class and set layer.speed = 0.0 then [layer animationForKey:key] will not return any animations as they are not there. The workaround is to set speed to 1.0 while you are persisting animations, and revert it to previous value just after. Also your code force resume to speed = 1.0 instead of storing the previous speed while it comes back from background - it also calls pause even layer is already paused, which will lead to "jump" in animation. Just if you want to improve :) Check answer for logic that handles all. – Grzegorz Krukowski May 12 '17 at 10:40
  • I've just gotten a couple of crashes reported at the very last line of the `MBPersistentAnimationContainer` `initWithLayer` method. The crashes were reported on iOS 11.3.1. Though one device had 4% RAM free, and the other had 13% RAM free. I wonder if this was just a low RAM situation. – Chris Prince Mar 19 '19 at 13:12
10

I used cclogg's solution but my app was crashing when the animation's view was removed from his superview, added again, and then going to background.

The animation was made infinite by setting animation.repeatCount to Float.infinity.
The solution I had was to set animation.isRemovedOnCompletion to false.

It's very weird that it works because the animation is never completed. If anyone has an explanation, I like to hear it.

Another tip: If you remove the view from its superview. Don't forget to remove the observer by calling NSNotificationCenter.defaultCenter().removeObserver(...).

Robert Dresler
  • 10,580
  • 2
  • 22
  • 40
Pepijn
  • 302
  • 2
  • 10
  • 4
    This is an answer... setting animation.removedOnCompletion to false was all I needed to fix the problem I had with the animation appearing to have stopped on return from background. – dawid Jun 07 '17 at 09:03
  • 3
    This should be the accepted answer. This works flawlessly. – David Baez Dec 01 '17 at 16:44
  • 3
    Excellent! This one line automatically solved the problem I was having with resuming animation after background. – rene Aug 13 '18 at 20:42
6

I write a Swift 4.2 version extension based on @cclogg and @Matej Bukovinski answers. All you need is to call layer.makeAnimationsPersistent()

Full Gist here: CALayer+AnimationPlayback.swift, CALayer+PersistentAnimations.swift

Core part:

public extension CALayer {
    static private var persistentHelperKey = "CALayer.LayerPersistentHelper"

    public func makeAnimationsPersistent() {
        var object = objc_getAssociatedObject(self, &CALayer.persistentHelperKey)
        if object == nil {
            object = LayerPersistentHelper(with: self)
            let nonatomic = objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC
            objc_setAssociatedObject(self, &CALayer.persistentHelperKey, object, nonatomic)
        }
    }
}

public class LayerPersistentHelper {
    private var persistentAnimations: [String: CAAnimation] = [:]
    private var persistentSpeed: Float = 0.0
    private weak var layer: CALayer?

    public init(with layer: CALayer) {
        self.layer = layer
        addNotificationObservers()
    }

    deinit {
        removeNotificationObservers()
    }
}

private extension LayerPersistentHelper {
    func addNotificationObservers() {
        let center = NotificationCenter.default
        let enterForeground = UIApplication.willEnterForegroundNotification
        let enterBackground = UIApplication.didEnterBackgroundNotification
        center.addObserver(self, selector: #selector(didBecomeActive), name: enterForeground, object: nil)
        center.addObserver(self, selector: #selector(willResignActive), name: enterBackground, object: nil)
    }

    func removeNotificationObservers() {
        NotificationCenter.default.removeObserver(self)
    }

    func persistAnimations(with keys: [String]?) {
        guard let layer = self.layer else { return }
        keys?.forEach { (key) in
            if let animation = layer.animation(forKey: key) {
                persistentAnimations[key] = animation
            }
        }
    }

    func restoreAnimations(with keys: [String]?) {
        guard let layer = self.layer else { return }
        keys?.forEach { (key) in
            if let animation = persistentAnimations[key] {
                layer.add(animation, forKey: key)
            }
        }
    }
}

@objc extension LayerPersistentHelper {
    func didBecomeActive() {
        guard let layer = self.layer else { return }
        restoreAnimations(with: Array(persistentAnimations.keys))
        persistentAnimations.removeAll()
        if persistentSpeed == 1.0 { // if layer was playing before background, resume it
            layer.resumeAnimations()
        }
    }

    func willResignActive() {
        guard let layer = self.layer else { return }
        persistentSpeed = layer.speed
        layer.speed = 1.0 // in case layer was paused from outside, set speed to 1.0 to get all animations
        persistAnimations(with: layer.animationKeys())
        layer.speed = persistentSpeed // restore original speed
        layer.pauseAnimations()
    }
}
ArtFeel
  • 11,701
  • 4
  • 29
  • 41
4

Just in case anyone needs a Swift 3 solution for this problem:

All you have to do is to subclass your animated view from this class. It always persist and resume all animations on it's layer.

class ViewWithPersistentAnimations : UIView {
    private var persistentAnimations: [String: CAAnimation] = [:]
    private var persistentSpeed: Float = 0.0

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.commonInit()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        self.commonInit()
    }

    func commonInit() {
        NotificationCenter.default.addObserver(self, selector: #selector(didBecomeActive), name: NSNotification.Name.UIApplicationWillEnterForeground, object: nil)
        NotificationCenter.default.addObserver(self, selector: #selector(willResignActive), name: NSNotification.Name.UIApplicationDidEnterBackground, object: nil)
    }

    deinit {
        NotificationCenter.default.removeObserver(self)
    }

    func didBecomeActive() {
        self.restoreAnimations(withKeys: Array(self.persistentAnimations.keys))
        self.persistentAnimations.removeAll()
        if self.persistentSpeed == 1.0 { //if layer was plaiyng before backgorund, resume it
            self.layer.resume()
        }
    }

    func willResignActive() {
        self.persistentSpeed = self.layer.speed

        self.layer.speed = 1.0 //in case layer was paused from outside, set speed to 1.0 to get all animations
        self.persistAnimations(withKeys: self.layer.animationKeys())
        self.layer.speed = self.persistentSpeed //restore original speed

        self.layer.pause()
    }

    func persistAnimations(withKeys: [String]?) {
        withKeys?.forEach({ (key) in
            if let animation = self.layer.animation(forKey: key) {
                self.persistentAnimations[key] = animation
            }
        })
    }

    func restoreAnimations(withKeys: [String]?) {
        withKeys?.forEach { key in
            if let persistentAnimation = self.persistentAnimations[key] {
                self.layer.add(persistentAnimation, forKey: key)
            }
        }
    }
}

extension CALayer {
    func pause() {
        if self.isPaused() == false {
            let pausedTime: CFTimeInterval = self.convertTime(CACurrentMediaTime(), from: nil)
            self.speed = 0.0
            self.timeOffset = pausedTime
        }
    }

    func isPaused() -> Bool {
        return self.speed == 0.0
    }

    func resume() {
        let pausedTime: CFTimeInterval = self.timeOffset
        self.speed = 1.0
        self.timeOffset = 0.0
        self.beginTime = 0.0
        let timeSincePause: CFTimeInterval = self.convertTime(CACurrentMediaTime(), from: nil) - pausedTime
        self.beginTime = timeSincePause
    }
}

On Gist: https://gist.github.com/grzegorzkrukowski/a5ed8b38bec548f9620bb95665c06128

Grzegorz Krukowski
  • 18,081
  • 5
  • 50
  • 71
  • 1
    To anyone having the issue where the animation disappears when having the animation removed from the visible window (e.g. tab bar controller changes tab), simply override didMoveToWindow, if window != nil, then call `didBecomeActive()`, else call `willResignActive()` :) (This is to be implemented inside Grzegorz Krukowski's `ViewWithPersistentAnimations`) – Roger Oba Nov 11 '17 at 15:54
  • @RogerOba do you mind editing the answer with this code, and I can accept it - or post updated GIST with this method, thanks – Grzegorz Krukowski Nov 11 '17 at 21:48
2

I was able to restore the animation (but not the animation position) by saving a copy of the current animation and adding it back on resume. I called startAnimation on load and when entering the foreground and pause when entering the background.

- (void) startAnimation {
    // On first call, setup our ivar
    if (!self.myAnimation) {
        self.myAnimation = [CABasicAnimation animationWithKeyPath:@"transform"];
        /*
         Finish setting up myAnimation
         */
    }

    // Add the animation to the layer if it hasn't been or got removed
    if (![self.layer animationForKey:@"myAnimation"]) {
        [self.layer addAnimation:self.spinAnimation forKey:@"myAnimation"];
    }
}

- (void) pauseAnimation {
    // Save the current state of the animation
    // when we call startAnimation again, this saved animation will be added/restored
    self.myAnimation = [[self.layer animationForKey:@"myAnimation"] copy];
}
Warpling
  • 2,024
  • 2
  • 22
  • 38
  • Ehm, well, the original question specifies, that the animation should continue where it left of when entering background. You don’t seem to address that part at all... – Palimondo Feb 25 '15 at 22:05
  • You're correct; I wrongly thought making a copy of the animation was preserving its current time. I spoke too soon. – Warpling Feb 25 '15 at 23:23
  • Edit: clarified that this technique doesn't save the position. – Warpling Jul 09 '15 at 17:57
1

I use cclogg's solution to great effect. I also wanted to share some additional info that might help someone else, because it frustrated me for a while.

In my app I have a number of animations, some that loop forever, some that run only once and are spawned randomly. cclogg's solution worked for me, but when I added some code to

- (void)animationDidStop:(CAAnimation *)theAnimation finished:(BOOL)flag

in order to do something when only the one-time animations were finished, this code would trigger when I resumed my app (using cclogg's solution) whenever those specific one-time animations were running when it was paused. So I added a flag (a member variable of my custom UIImageView class) and set it to YES in the section where you resume all the layer animations (resumeLayer in cclogg's, analogous to Apple solution QA1673) to keep this from happening. I do this for every UIImageView that is resuming. Then, in the animationDidStop method, only run the one-time animation handling code when that flag is NO. If it's YES, ignore the handling code. Switch the flag back to NO either way. That way when the animation truly finishes, your handling code will run. So like this:

- (void)animationDidStop:(CAAnimation *)theAnimation finished:(BOOL)flag
    if (!resumeFlag) { 
      // do something now that the animation is finished for reals
    }
    resumeFlag = NO;
}

Hope that helps someone.

Gothic
  • 322
  • 3
  • 12
0

I was recognizing the gesture state like so:

// Perform action depending on the state
switch gesture.state {
case .changed:
    // Some action
case .ended:
    // Another action

// Ignore any other state
default:
    break
}

All I needed to do was change the .ended case to .ended, .cancelled.

George
  • 25,988
  • 10
  • 79
  • 133
0

iOS will remove all animations when view disappears from the visible area (not only when app goes into background). To fix it I created custom CALayer subclass and overrided 2 methods so the system doesn't remove animations - removeAnimation and removeAllAnimations:

class CustomCALayer: CALayer {

    override func removeAnimation(forKey key: String) {

        // prevent iOS to clear animation when view is not visible
    }

    override func removeAllAnimations() {

        // prevent iOS to clear animation when view is not visible
    }

    func forceRemoveAnimation(forKey key: String) {

        super.removeAnimation(forKey: key)
    }
}

In the view where you want this layer to be used as main layer override layerClass property:

override class var layerClass: AnyClass {

    return CustomCALayer.self
}

To pause and resume animation:

extension CALayer {

    func pause() {

        guard self.isPaused() == false else {

            return
        }

        let pausedTime: CFTimeInterval = self.convertTime(CACurrentMediaTime(), from: nil)
        self.speed = 0.0
        self.timeOffset = pausedTime
    }

    func resume() {

        guard self.isPaused() else {

            return
        }
        let pausedTime: CFTimeInterval = self.timeOffset
        self.speed = 1.0
        self.timeOffset = 0.0
        self.beginTime = 0.0
        self.beginTime = self.convertTime(CACurrentMediaTime(), from: nil) - pausedTime
    }

    func isPaused() -> Bool {

        return self.speed == 0.0
    }
}
sash
  • 8,423
  • 5
  • 63
  • 74