I've made a wrapper for compact creation of CABasicAnimation instances.
It's implemented through a category for UIView as an instance method named change:from:to:in:ease:delay:done:. So for example, I can do:
[self.logo
change:#"y"
from:nil // Use current self.logo.layer.position.y
to:#80
in:1 // Finish in 1000 ms
ease:#"easeOutQuad" // A selector for a CAMediaTimingFunction category method
delay:0
done:nil];
The problem
When the CABasicAnimation starts, animationDidStart: handles setting self.logo.layer.position.y to 80 (the end value). Before it worked like this, I tried using animationDidStop:finished: to do the same thing, but found the layer flickering after completing the animation. Now, the layer goes straight to the end value, and no interpolation occurs. I implemented animationDidStart: in my UIView category like so:
- (void)animationDidStart:(CAAnimation *)animation
{
[self.layer
setValue:[animation valueForKey:#"toValue"]
forKeyPath:[animation valueForKey:#"keyPath"]];
}
I'm setting the end value in order to match the model layer with the presentation layer (in other words, to prevent resetting back to the start position).
Here's the implementation to change:from:to:in:ease:delay:done:...
- (CABasicAnimation*) change:(NSString*)propertyPath
from:(id)from
to:(id)to
in:(CGFloat)seconds
ease:(NSString*)easeName
delay:(CGFloat)delay
done:(OnDoneCallback)done
{
NSString* keyPath = [app.CALayerAnimationKeyPaths objectForKey:propertyPath];
if (keyPath == nil) keyPath = propertyPath;
CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:keyPath];
if (delay > 0) animation.beginTime = CACurrentMediaTime() + delay;
if (easeName != nil) animation.timingFunction = [CAMediaTimingFunction performSelector:NSSelectorFromString(easeName)];
if (from != nil) animation.fromValue = from;
animation.toValue = to;
animation.duration = seconds;
animation.delegate = self;
[self.layer setValue:done forKey:#"onDone"];
[self.layer addAnimation:animation forKey:keyPath];
return animation;
}
In the first line of the above code, this is the NSDictionary I use to convert property shortcuts to the real keyPath. This is so I can just type #"y" instead of #"position.y" every time.
app.CALayerAnimationKeyPaths = #{
#"scale": #"transform.scale",
#"y": #"position.y",
#"x": #"position.x",
#"width": #"frame.size.width",
#"height": #"frame.size.height",
#"alpha": #"opacity",
#"rotate": #"transform.rotation"
};
Any questions?
What you're seeing is, I think, expected behavior. You are setting an animatable property after animation of that property has started. That is in fact the most common and recommended way to do exactly what you are seeing happen, i.e. cancel the animation and jump right to the final position now. So you are deliberately canceling your own animation the minute it gets going.
If that's not what you want, don't do that. Just set the animated property to its final value before the animation is attached to the layer - being careful, of course, not to trigger implicit animation as you do so.
Related
Here's some relevant code inside a UIView subclass:
- (void) doMyCoolAnimation {
CABasicAnimation* anim = [CABasicAnimation animationWithKeyPath:#"position.x"];
anim.duration = 4;
[self.layer setValue:#200 forKeyPath:anim.keyPath];
[self.layer addAnimation:anim forKey:nil];
}
- (CGFloat) currentX {
CALayer* presLayer = self.layer.presentationLayer;
return presLayer.position.x;
}
When I use [self currentX] while the animation is running, I get 200 (the end value) rather than a value between 0 (the start value) and 200. And yes, the animation is visible to the user, so I'm really confused here.
Here's the code where I call doMyCoolAnimation:, as well as currentX after 1 second.
[self doMyCoolAnimation];
CGFloat delay = 1; // 1 second delay
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
NSLog(#"%f", [self currentX]);
});
Any ideas?
I don't know where the idea for using KVC setters in animation code came from, but that's what the animation itself is for. You're basically telling the layer tree to immediately update to the new position with this line:
[self.layer setValue:#200 forKeyPath:anim.keyPath];
Then wondering why the layer tree won't animate to that position with an animation that has no starting or ending values. There's nothing to animate! Set the animation's toValue and fromValue as appropriate and ditch the setter. Or, if you wish to use an implicit animation, keep the setter, but ditch the animation and set its duration by altering the layer's speed.
My UIView's layer's presentationLayer was not giving me the current values. It was instead giving me the end values of my animation.
To fix this, all I had to do was add...
anim.fromValue = [self.layer valueForKeyPath:#"position.x"];
...to my doMyCoolAnimation method BEFORE I set the end value with:
[self.layer setValue:#200 forKeyPath:#"position.x"];
So in the end, doMyCoolAnimation looks like this:
- (void) doMyCoolAnimation {
CABasicAnimation* anim = [CABasicAnimation animationWithKeyPath:#"position.x"];
anim.duration = 4;
anim.fromValue = [self.layer valueForKeyPath:anim.keyPath];
[self.layer setValue:#200 forKeyPath:anim.keyPath];
[self.layer addAnimation:anim forKey:nil];
}
The way you are creating your animation is wrong, as CodaFi says.
Either use an explicit animation, using a CABasicAnimation, or use implicit animation by changing the layer's properties directly and NOT using a CAAnimation object. Don't mix the two.
When you create a CABasicAnimation object, you use setFromValue and/or setToValue on the animation. Then the animation object takes care of animating the property in the presentation layer.
So, I am fairly new to iOS programming, and have inherited a project from a former coworker. We are building an app that contains a gauge UI. When data comes in, we want to smoothly rotate our "layer" (which is a needle image) from the current angle to a new target angle. Here is what we have, which worked well with slow data:
-(void) MoveNeedleToAngle:(float) target
{
static float old_Value = 0.0;
CABasicAnimation *rotateCurrentPressureTick = [CABasicAnimation animationWithKeyPath:#"transform.rotation");
[rotateCurrentPressureTick setDelegate:self];
rotateCurrentPressureTick.fromValue = [NSSNumber numberWithFloat:old_value/57.2958];
rotateCurrentPressureTick.removedOnCompletion=NO;
rotateCurrentPressureTick.fillMode=kCAFillModeForwards;
rotateCurrentPressureTick.toValue=[NSSNumber numberWithFloat:target/57.2958];
rotateCurrentPressureTick.duration=3; // constant 3 second sweep
[imageView_Needle.layer addAnimation:rotateCurrentPressureTick forKey:#"rotateTick"];
old_Value = target;
}
The problem is we have a new data scheme in which new data can come in (and the above method called) faster, before the animation is complete. What's happening I think is that the animation is restarted from the old target to the new target, which makes it very jumpy.
So I was wondering how to modify the above function to add a continuous/restartable behavior, as follows:
Check if the current animation is in progress and
If so, figure out where the current animation angle is, and then
Cancel the current and start a new animation from the current rotation to the new target rotation
Is it possible to build that behavior into the above function?
Thanks. Sorry if the question seems uninformed, I have studied and understand the above objects/methods, but am not an expert.
Yes you can do this using your existing method, if you add this bit of magic:
- (void)removeAnimationsFromView:(UIView*)view {
CALayer *layer = view.layer.presentationLayer;
CGAffineTransform transform = layer.affineTransform;
[layer removeAllAnimations];
view.transform = transform;
}
The presentation layer encapsulates the actual state of the animation. The view itself doesn't carry the animation state properties, basically when you set an animation end state, the view acquires that state as soon as you trigger the animation. It is the presentation layer that you 'see' during the animation.
This method captures the state of the presentation layer at the exact moment you cancel the animation, and then applies that state to the view.
Now you can use this method in your animation method, which will look something like this:
-(void) MoveNeedleToAngle:(float) target{
[self removeAnimationsFromView:imageView_Needle];
id rotation = [imageView_Needle valueForKeyPath:#"layer.transform.rotation.z"];
CGFloat old_value = [rotation floatValue]*57.2958;
// static float old_value = 0.0;
CABasicAnimation *rotateCurrentPressureTick = [CABasicAnimation animationWithKeyPath:#"transform.rotation"];
[rotateCurrentPressureTick setDelegate:self];
rotateCurrentPressureTick.fromValue = [NSNumber numberWithFloat:old_value/57.2958];
rotateCurrentPressureTick.removedOnCompletion=NO;
rotateCurrentPressureTick.fillMode=kCAFillModeForwards;
rotateCurrentPressureTick.toValue=[NSNumber numberWithFloat:target/57.2958];
rotateCurrentPressureTick.duration=3; // constant 3 second sweep
[imageView_Needle.layer addAnimation:rotateCurrentPressureTick forKey:#"rotateTick"];
old_value = target;
}
(I have made minimal changes to your method: there are a few coding style changes i would also make, but they are not relevant to your problem)
By the way, I suggest you feed your method in radians, not degrees, that will mean you can remove those 57.2958 constants.
You can get the current rotation from presentation layer and just set the toValue angle. No need to keep old_value
-(void) MoveNeedleToAngle:(float) targetRadians{
CABasicAnimation *animation =[CABasicAnimation animationWithKeyPath:#"transform.rotation"];
animation.duration=5.0;
animation.fillMode=kCAFillModeForwards;
animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseOut];
animation.removedOnCompletion=NO;
animation.fromValue = [NSNumber numberWithFloat: [[layer.presentationLayer valueForKeyPath: #"transform.rotation"] floatValue]];
animation.toValue = [NSNumber numberWithFloat:targetRadians];
// layer.transform= CATransform3DMakeRotation(rads, 0, 0, 1);
[layer addAnimation:animation forKey:#"rotate"];
}
Another way i found (commented line above) is instead of using fromValue and toValue just set the layer transform. This will produce the same animation but the presentationLayer and the model will be in sync.
Here's some relevant code inside a UIView subclass:
- (void) doMyCoolAnimation {
CABasicAnimation* anim = [CABasicAnimation animationWithKeyPath:#"position.x"];
anim.duration = 4;
[self.layer setValue:#200 forKeyPath:anim.keyPath];
[self.layer addAnimation:anim forKey:nil];
}
- (CGFloat) currentX {
CALayer* presLayer = self.layer.presentationLayer;
return presLayer.position.x;
}
When I use [self currentX] while the animation is running, I get 200 (the end value) rather than a value between 0 (the start value) and 200. And yes, the animation is visible to the user, so I'm really confused here.
Here's the code where I call doMyCoolAnimation:, as well as currentX after 1 second.
[self doMyCoolAnimation];
CGFloat delay = 1; // 1 second delay
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
NSLog(#"%f", [self currentX]);
});
Any ideas?
I don't know where the idea for using KVC setters in animation code came from, but that's what the animation itself is for. You're basically telling the layer tree to immediately update to the new position with this line:
[self.layer setValue:#200 forKeyPath:anim.keyPath];
Then wondering why the layer tree won't animate to that position with an animation that has no starting or ending values. There's nothing to animate! Set the animation's toValue and fromValue as appropriate and ditch the setter. Or, if you wish to use an implicit animation, keep the setter, but ditch the animation and set its duration by altering the layer's speed.
My UIView's layer's presentationLayer was not giving me the current values. It was instead giving me the end values of my animation.
To fix this, all I had to do was add...
anim.fromValue = [self.layer valueForKeyPath:#"position.x"];
...to my doMyCoolAnimation method BEFORE I set the end value with:
[self.layer setValue:#200 forKeyPath:#"position.x"];
So in the end, doMyCoolAnimation looks like this:
- (void) doMyCoolAnimation {
CABasicAnimation* anim = [CABasicAnimation animationWithKeyPath:#"position.x"];
anim.duration = 4;
anim.fromValue = [self.layer valueForKeyPath:anim.keyPath];
[self.layer setValue:#200 forKeyPath:anim.keyPath];
[self.layer addAnimation:anim forKey:nil];
}
The way you are creating your animation is wrong, as CodaFi says.
Either use an explicit animation, using a CABasicAnimation, or use implicit animation by changing the layer's properties directly and NOT using a CAAnimation object. Don't mix the two.
When you create a CABasicAnimation object, you use setFromValue and/or setToValue on the animation. Then the animation object takes care of animating the property in the presentation layer.
I am trying to animate a CAEmitterLayer's emitterPosition like this:
CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:#"emitterPosition.x"] ;
animation.toValue = (id) toValue ;
animation.removedOnCompletion = NO ;
animation.duration = self.translationDuration ;
animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut] ;
animation.completion = ^(BOOL finished)
{
[self animateToOtherSide] ;
} ;
[_emitterLayer addAnimation:animation forKey:#"emitterPosition"] ;
CGPoint newEmitterPosition = CGPointMake(toValue.floatValue, self.bounds.size.height/2.0) ;
_emitterLayer.emitterPosition = newEmitterPosition ;
Note that animation.completion is declared in a category that just calls the corresponding CAAnimation delegate method.
The problem is that this doesn't animate at all and shows the emitter in its final position. I was under the impression that once you add the animation to the layer, you should change the actual model behind it to its final position so that when the animation completes the model is in its final state; i.e., to prevent the animation from "snapping back" to its original position.
I have tried placing the last two lines in the animation.completion block, and this does indeed animate as expected. However, when the animation finishes some particles are intermittently emitted at the emitter's original position. If you put the system under load (for example, scrolling a tableview while the animation is playing), this happens more often.
Another solution I was thinking about is to not move the emitterPosition at all but just move the CAEmitterLayer itself, although I haven't tried that yet.
Thanks in advance for any help you can provide.
Perhaps emitterPosition.x is not a valid key path for animation. Try using emitterPosition instead (and so you'll have to provide CGPoint values wrapped up in an NSValue).
I just tried this on my own machine and it works fine:
CABasicAnimation* ba = [CABasicAnimation animationWithKeyPath:#"emitterPosition"];
ba.fromValue = [NSValue valueWithCGPoint:CGPointMake(30,100)];
ba.toValue = [NSValue valueWithCGPoint:CGPointMake(200,100)];
ba.duration = 6;
ba.autoreverses = YES;
ba.repeatCount = HUGE_VALF;
[emit addAnimation:ba forKey:nil];
Other things to think about:
You can typically use nil as the key in addAnimation:forKey:, unless you're going to need to find this animation later (e.g. to remove it or override it in some way). The key path is the important thing.
Setting removedOnCompletion to NO is almost always wrong and is typically the last refuge of a scoundrel (i.e. due to not understanding how animation works).
If, as you say, setting _emitterLayer.emitterPosition = newEmitterPosition inside the completion block does animate, then when why are you using CABasicAnimation at all? Why not just call UIView animate... and set the emitterPosition in the animations block? If that works, it will kill two birds with one stone, moving the position and animating it too.
i'm rotating a CALayer using CABasicAnimation and works fine. The problem is, when I try to rotate the same layer, it returns back to its original position before it will rotate. My expected output is that, for the next rotation, it should start from where it has ended. Here's my code:
CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:#"transform.rotation"];
animation.fromValue = 0;
animation.toValue = [NSNumber numberWithFloat:3.0];
animation.duration = 3.0;
animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
animation.removedOnCompletion = NO;
animation.fillMode = kCAFillModeForwards;
animation.autoreverses = NO;
[calayer addAnimation:animation forKey:#"rotate"];
Is there anything missing on my code? thanks
What's happening is that you're seeing the animation in the presentation layer. However, that doesn't update the actual position of your layer. So, once the animation finishes, you see the layer as it was because it hasn't changed.
It's really worth reading the "Core Animation Rendering Architecture". Otherwise this can be very confusing.
To fix it, set a delegate to your CABasicAnimation as follows:
[animation setDelegate:self];
Then, create a method to set your target properties that you want when the animation completes. Now, here's the confusing part. You should do this on animationDidStart not animationDidStop. Otherwise, the presentation layer animation will finish, and you'll get a flicker as you see the calayer in the original position then it jumps - without animation - to the target position. Try it with animationDidStop and you'll see what I mean.
I hope that's not too confusing!
- (void)animationDidStart:(CAAnimation *)theAnimation
{
[calayer setWhateverPropertiesExpected];
}
EDIT:
I later discovered that Apple recommend a much better way to do this.
Oleg Begemann has a nice description of the correct technique in his blog post Prevent Layers from Snapping Back to Original Values When Using Explicit CAAnimations
Basically what you do is before you start the animation, you take a note of the layer's current value, i.e., the original value:
// Save the original value
CGFloat originalY = layer.position.y;
Next, set the toValue on the layer's model. Therefore the layer model has the final value of whatever animation you are about to do:
// Change the model value
layer.position = CGPointMake(layer.position.x, 300.0);
Then, you set the animation up with the animation fromValue being that original value that you noted above:
CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:#"position.y"];
// Now specify the fromValue for the animation because
// the current model value is already the correct toValue
animation.fromValue = #(originalY);
animation.duration = 1.0;
// Use the name of the animated property as key
// to override the implicit animation
[layer addAnimation:animation forKey:#"position"];
Note that code in edit above was copy/pasted from Ole Begemann's blog for clarity
If you want the animation to start from where it has ended, then set the fromValue property to the CALayer's current rotation.
Obtaining that value is tricky, but this SO post shows you how: https://stackoverflow.com/a/6706604/1072846