Search code examples
iosobjective-canimationcore-animationcakeyframeanimation

Changing the speed of a CAKeyframeAnimation mid-animation in iOS


I am trying to allow the user to change the speed of my animation. I am making a CAKeyframeAnimation with a bezier path, and I can get it to display and run correctly. I try to change the speed by creating a new animation path with a different duration. The planes go back to the beginning (which I haven't tried to fix yet) and do speed up. The path that they are being drawn on goes away at the time it would have had the animation never changed speeds. When the planes finish, another appears at the point at which the animation was paused in the first place. I have no idea what I'm doing wrong. My question is similar to this one modifying dynamically the duration of CAKeyframeAnimation, but I don't understand what the OP said about finally using blocks.

//The first two methods are in a class subclassing UIView
/** Pause each plane's animation */
- (void)pauseAnimation
{    
    CFTimeInterval pausedTime = [[self layer] convertTime:CACurrentMediaTime() fromLayer:nil];
    [self layer].speed = 0.0;
    [self layer].timeOffset = pausedTime;
}

/** Resume each plane's animation */
- (void)resumeAnimation
{
    CFTimeInterval pausedTime = [[self layer] timeOffset];
    [self layer].speed = 1.0;
    [self layer].timeOffset = 0.0;
    CFTimeInterval timeSincePause = [[self layer] convertTime:CACurrentMediaTime() fromLayer:nil] - pausedTime;

    for(SEPlane *plane in planes){
        plane.planeAnimationPath.speedMultiplier = 5;
        [plane.planeAnimationPath beginAnimation:self];
    }
    //[self layer].beginTime = timeSincePause;
}

//This method is in the class of planeAnimationPath
/** Begin animating plane along given path */
- (void)beginAnimation:(UIView *) view
{
    planeAnimation = nil;
    // Create animation layer for animating plane
    planeAnimation = [CAKeyframeAnimation animationWithKeyPath:@"position"];    
    planeAnimation.path = [bezierPath CGPath];
    planeAnimation.duration = approximateLength/(ANIMATION_SPEED * self.speedMultiplier);
    planeAnimation.calculationMode = kCAAnimationPaced;
    planeAnimation.fillMode = kCAFillModeForwards;
    planeAnimation.rotationMode = kCAAnimationRotateAuto;
    planeAnimation.removedOnCompletion = YES;
    [planeAnimation setDelegate:self];    

    // Add animation to image-layer
    [imageLayer addAnimation:planeAnimation forKey:animationKey];

    // Add image-layer to view
    [[view layer] addSublayer:imageLayer];
}

Solution

  • Unlike default animations, which animate from the current position to the target position, CAKeyframeAnimations don't (as far as I can tell). Besides how would you interpret an animation where the current position is not on the path?

    The simplest option that I can think of is to do the following in the setter of speedMultiplier:

    1. Create a new animation with the desired path.
    2. Set the duration as if speedMultiplier was 1
    3. Set the speed to speedMultiplier
    4. Set the timeOffset to duration * "percent of new animation already complete"
    5. Add the animation to the layer.

    As you might have guessed, the tricky part is step 4. For simple paths this is easy but for arbitrary paths, it will get a bit more complicated. As a starting point, you will need the formula for bezier quadratic and cubic curves. Search for "distance parametrization of bezier curves" and you will find tons of stuff.

    Here is a code sample for a simple rectangular path. The window simply has a MPView and a slider:

    @implementation MPView {
    
        IBOutlet NSSlider *_slider;  // Min=0.0, Max=5.0
    
        CALayer  *_hostLayer;
        CALayer  *_ballLayer;
    
        CAKeyframeAnimation *_ballPositionAnimation;
    
        double _speed;
    }
    
    - (void) awakeFromNib
    {
        CGRect bounds = self.bounds;
    
        [CATransaction begin];
        [CATransaction setDisableActions:YES];
    
        _speed = 1.0;
    
        _hostLayer = [CALayer layer];
        _hostLayer.backgroundColor = CGColorGetConstantColor(kCGColorBlack);
        self.layer = _hostLayer;
        self.wantsLayer = YES;
    
        _ballLayer = [CALayer layer];
        _ballLayer.bounds = CGRectMake(0, 0, 32, 32);
        _ballLayer.position = CGPointMake(40, 40);
        _ballLayer.backgroundColor = CGColorGetConstantColor(kCGColorWhite);
        _ballLayer.cornerRadius = 16;
    
        _hostLayer.sublayers = @[_ballLayer];
    
    
        CGMutablePathRef path = CGPathCreateMutable();
        CGPathMoveToPoint(path, NULL, _ballLayer.position.x, _ballLayer.position.y);
        CGPathAddRect(path, NULL, CGRectInset(bounds, 40, 40));
        CGPathCloseSubpath(path);
    
        _ballPositionAnimation = [CAKeyframeAnimation animationWithKeyPath:@"position"];
        _ballPositionAnimation.path = path;
        _ballPositionAnimation.duration = 6;
        _ballPositionAnimation.repeatCount = HUGE_VALF;
    
        CGPathRelease(path);
    
        [_ballLayer addAnimation:_ballPositionAnimation forKey:_ballPositionAnimation.keyPath];
    
        [CATransaction commit];
    
        [_slider bind:NSValueBinding toObject:self withKeyPath:@"speed" options:@{NSContinuouslyUpdatesValueBindingOption:@YES}];
    }
    
    - (double) speed
    {
        return _speed;
    }
    
    - (void) setSpeed:(double)speed
    {
        _speed = speed;
    
        CGPoint pos = [(CALayer*)_ballLayer.presentationLayer position];
    
        [CATransaction begin];
        [CATransaction setDisableActions:YES];
    
        _ballPositionAnimation.speed = _speed;
        _ballPositionAnimation.duration = 5.0;
        _ballPositionAnimation.timeOffset = _ballPositionAnimation.duration * [self percentOfPathCompleted:pos];
        [_ballLayer addAnimation:_ballPositionAnimation forKey:_ballPositionAnimation.keyPath];
    
        [CATransaction commit];
    }
    
    - (double) percentOfPathCompleted:(CGPoint)p
    {
        CGRect rect = CGRectInset(self.bounds, 40, 40);
        double minX = NSMinX(rect);
        double minY = NSMinY(rect);
        double maxX = NSMaxX(rect);
        double maxY = NSMaxY(rect);
        double offset = 0.0;
    
        if (p.x == minX && p.y == minY)
            return 0.0;
        else if (p.x > minX && p.y == minY)
            offset = (p.x - minX) / rect.size.width * 0.25;
        else if (p.x == maxX && p.y < maxY)
            offset = (p.y - minY) / rect.size.height * 0.25 + 0.25;
        else if (p.x > minX && p.y == maxY)
            offset = (1.0 - (p.x - minX) / rect.size.width) * 0.25 + 0.50;
        else
            offset = (1.0 - (p.y - minY) / rect.size.height) * 0.25 + 0.75;
    
        NSLog(@"Offset = %f",offset);
        return offset;
    }
    
    @end