I've been having a problem that I can't seem to figure out. In my app I use a custom UIImageView class to represent movable objects. Some are loaded with static .png images and use frame animation, while others are loaded with CAShapeLayer paths from .svg files. For some of those, I'm getting stutter when the layer is animating from one path to another, while the UIImageView is moving.
You can see it in the linked video. When I touch the mermaid horn, a note spawns (svg path) and animates into a fish (another svg path), all while drifting upwards. The stuttering occurs during that animation / drift. It's most noticeable the third time I spawn the fish, around 19 seconds into the video. (The jumping at the end of each animation I need to fix separately so don't worry about that)
http://www.youtube.com/watch?v=lnrNWuvqQ4w
This does not occur when I test the app in iOS Simulator - everything is smooth. On my iPad 2 it stutters. When I turn off the note/fish movement (drifting upward), it animates smooth on the iPad, so obviously it has to do with moving the view at the same time. There's something I'm missing but I can't figure it out.
Here is how I set up the animation. PocketSVG is a class I found on Github that converts an .svg to a Bezier path.
animShape = [CAShapeLayer layer];
[animShape setShouldRasterize:YES];
[animShape setRasterizationScale:[[UIScreen mainScreen] scale]];
PocketSVG *tSVG;
UIBezierPath *tBezier;
PocketSVG *tSVG2;
UIBezierPath *tBezier2;
CAShapeLayer *tLayer2;
CABasicAnimation *animBezier;
CABasicAnimation *animScale;
tSVG = [[PocketSVG alloc] initFromSVGFileNamed:[NSString stringWithFormat:#"%#", tName]];
// Use PocketSVG to convert the SVG to a Bezier Path
tBezier = tSVG.bezier;
animShape.path = tBezier.CGPath;
animShape.lineWidth = 1;
animShape.strokeColor = [[UIColor blackColor] CGColor];
animShape.fillColor = [[UIColor blackColor] CGColor];
animShape.fillRule = kCAFillRuleNonZero;
// Set the frame & bounds to position the piece and set anchor point for rotation
// parentPaper is the Mermaid the note spawns from
CGPoint newCenter;
CGFloat imageScale = fabsf(parentPaper.transform.a);
newCenter = // Code excised, set center based on custom spawn points in relation to parent position and scale
animShape.bounds = CGPathGetBoundingBox(animShape.path);
CGRect lBounds = CGRectMake(newCenter.x, newCenter.y, animShape.bounds.size.width, animShape.bounds.size.height);
[animShape setFrame:lBounds];
halfSize = CGPointMake(animShape.bounds.size.width/2, animShape.bounds.size.height/2);
animShape.anchorPoint = CGPointMake(prp.childSpawnX, prp.childSpawnY);
// Create the end path for animation
tSVG2 = [[PocketSVG alloc] initFromSVGFileNamed:[NSString stringWithFormat:#"%#2", tName]];
tBezier2 = tSVG2.bezier;
tLayer2 = [CAShapeLayer layer];
tLayer2.path = tBezier2.CGPath;
// Create the animation and add it to the layer
animBezier = [CABasicAnimation animationWithKeyPath:#"path"];
animBezier.delegate = self;
animBezier.duration = prp.frameDur;
animBezier.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
animBezier.fillMode = kCAFillModeForwards;
animBezier.removedOnCompletion = NO;
animBezier.autoreverses = behavior.autoReverse;
if (prp.animID < 0) {
animBezier.repeatCount = FLT_MAX;
}
else {
animBezier.repeatCount = prp.animID;
}
animBezier.fromValue = (id)animShape.path;
animBezier.toValue = (id)tLayer2.path;
[animShape addAnimation:animBezier forKey:#"animatePath"];
// also scale the animation if spawned from a parent
// i.e. small note to normal-sized fish
if (parentPaper != nil) {
animScale = [CABasicAnimation animationWithKeyPath:#"transform"];
animScale.delegate = self;
animScale.duration = prp.frameDur;
animScale.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
animScale.fillMode = kCAFillModeForwards;
animScale.removedOnCompletion = YES;
animScale.autoreverses = NO;
animScale.repeatCount = 1;
animScale.fromValue = [NSValue valueWithCATransform3D:CATransform3DMakeScale(imageScale, imageScale, 0.0)];
animScale.toValue = [NSValue valueWithCATransform3D:CATransform3DMakeScale(1.0, 1.0, 0.0)];
[animShape addAnimation:animScale forKey:#"animateScale"];
}
[self.layer addSublayer:animShape];
And this is basically how I'm moving the UIImageView. I use a CADisplayLink at 60 frames, figure out the new spot based on existing position of animShape.position, and update like this, where self is the UIImageView:
[self.animShape setPosition:paperCenter];
Everything else runs smooth, it's just this one set of objects that stutter and jump about when running on the iPad itself. Any ideas of what I'm doing wrong? Moving it the wrong way, perhaps? I do get confused with layers, frames and bounds still.
To fix this I ended up changing the way the UIImageView moves. Instead of changing the frame position through my game loop, I switched it to a CAKeyframeAnimation on the position since it's a temporary movement that only runs once. I used CGPathAddCurveToPoint to replicate the sine wave movement that I originally had. It was rather simple and I probably should have done it that way to begin with.
I can only surmise that the stuttering was due to moving it through the CADisplayLink loop while its animation separately.
Related
I have been trying to update the position of button(s) after the completion of a CAKeyFrameAnimation. As of now, I have stopped the animation at a certain angle on the arc (final angle) but, it seems the touches the buttons take is at the point it was earlier in before the animation takes place.
Here's the code that I have been using:
for(int i = 0; i <numberOfButtons; i++)
{
double angle = [[self.buttonsArray objectAtIndex:i] angle];
if(angle != -50 && angle !=270){
CGPoint arcStart = CGPointMake([[self.buttonsArray objectAtIndex:i] buttonForCricle].center.x, [[self.buttonsArray objectAtIndex:i] buttonForCricle].center.y);
CGPoint arcCenter = CGPointMake(centerPoint.x, centerPoint.y);
CGMutablePathRef arcPath = CGPathCreateMutable();
CGPathMoveToPoint(arcPath, NULL, arcStart.x, arcStart.y);
CGPathAddArc(arcPath, NULL, arcCenter.x, arcCenter.y, 150, ([[self.buttonsArray objectAtIndex:i] angle]*M_PI/180), (-50*M_PI/180), YES);
UIButton *moveButton = (UIButton *)[self.view viewWithTag:i+1];
CAKeyframeAnimation *pathAnimation = [CAKeyframeAnimation animationWithKeyPath:#"position"];
pathAnimation.calculationMode = kCAAnimationPaced;
pathAnimation.fillMode = kCAFillModeForwards;
// pathAnimation.values = #[#0];
pathAnimation.removedOnCompletion = NO;
pathAnimation.duration = 1.0;
pathAnimation.path = arcPath;
CGPathRelease(arcPath);
// UIView* drawArcView = nil;
// drawArcView = [[UIView alloc] initWithFrame: self.view.bounds];
// CAShapeLayer* showArcLayer = [[CAShapeLayer alloc] init];
// showArcLayer.frame = drawArcView.layer.bounds;
// showArcLayer.path = arcPath;
// showArcLayer.strokeColor = [[UIColor blackColor] CGColor];
// showArcLayer.fillColor = nil;
// showArcLayer.lineWidth = 1.0;
// [drawArcView.layer addSublayer: showArcLayer];
// [self.view addSubview:drawArcView];
[moveButton.layer addAnimation:pathAnimation forKey:#"arc"];
[CATransaction commit];
[self.view bringSubviewToFront:moveButton];
}
}
How can I update the buttons position after this?
Why this is happening
What you're seeing is that animations only change the "presentation values" of a layer, without changing the "model values". Add to this the fact that animations by default are removed when they complete and you have an explanation for why the button(s) would return to their original position after the animation finishes.
This is the state of the actual button, and the location where it is hit tested when the user taps on it.
The combination of removedOnCompletion = NO and fillMode = kCAFillModeForwards makes it look like the button has moved to it's final position, but it's actually causing the difference between presentation values and model values to persists.
Put simply, the button looks like it's in one position, but it's actually in another position.
How to fix it
For this type of situation, I recommend to let the animation be removed when it finishes, and to not specify any special fill mode, and then solve the problem of the button(s) position being incorrect.
They way to do this is to set the buttons position/center to its final value. This will make it so that the button's model position changes immediately, but during the animation its presentation position remains the same. After the animation finishes and is removed the button goes back to its model value, which is the same as the final value of the animation. So the button will both look and be in the correct position.
Usually the easiest place to update the model value is after having created the animation object but before adding it to the layer.
Changing the positions will create and add an implicit animation. But as long as the explicit animation (the one you're creating) is longer, the implicit animation won't be visible.
If you don't want this implicit animation to be created, then you can create a CATransansaction around only the line where the position property is updated, and disable actions (see setDisableActions:).
If you want to learn more, I have a blog post about how layers work with multiple animations and an answer to a question about when to update the layer.
I built this for my company: https://github.com/busycm/BZYStrokeTimer and during the course of building, I noticed an interesting "bug" that I can't seem to mitigate when using UIBezierPath. Right when the animation starts, the path jumps a certain number of pixels forward (or backwards depending if it's counterclockwise) instead of starting up with a smooth, incremental animation. And what I found that's really interesting is how much the path jumps forward is actually the value of the line width for the CAShaperLayer.
So for example, if my bezier path starts off at CGRectGetMidX(self.bounds) and the line with is 35, the animation actually starts from CGRectGetMidX(self.bounds)+35 and the larger the line width, the more noticeable the jump is. Is there any way to get rid of that so that path will smoothly animate out from the start point?
Here's a picture of the first frame. This is what it looks like immediately after the animation starts.
Then when I resume the animation and pause again, the distance moved is about 1/100th of the distance you see in the picture.
Here's my bezier path code:
- (UIBezierPath *)generatePathWithXInset:(CGFloat)dx withYInset:(CGFloat)dy clockWise:(BOOL)clockwise{
UIBezierPath *path = [UIBezierPath bezierPath];
[path moveToPoint:CGPointMake(CGRectGetMidX(self.bounds)+dx/2, dy/2)];
[path addLineToPoint:CGPointMake(CGRectGetMaxX(self.bounds)-dx/2, dy/2)];
[path addLineToPoint:CGPointMake(CGRectGetMaxX(self.bounds)-dx/2, CGRectGetMaxY(self.bounds)-dy/2)];
[path addLineToPoint:CGPointMake(dx/2, CGRectGetMaxY(self.bounds)-dy/2)];
[path addLineToPoint:CGPointMake(dx/2, dy/2)];
[path closePath];
return clockwise ? path : [path bezierPathByReversingPath];
}
Here's the animation code:
CABasicAnimation *wind = [self generateAnimationWithDuration:self.duration == 0 ? kDefaultDuration : self.duration fromValue:#(self.shapeLayer.strokeStart) toValue:#(self.shapeLayer.strokeEnd) withKeypath:keypath withFillMode:kCAFillModeForwards];
wind.timingFunction = [CAMediaTimingFunction functionWithName:self.timingFunction];
wind.removedOnCompletion = NO;
self.shapeLayer.path = [self generatePathWithXInset:self.lineWidth withYInset:self.lineWidth clockWise:self.clockwise].CGPath;
[self.shapeLayer addAnimation:wind forKey:#"strokeEndAnimation"];
And here's how I construct the CAShapeLayer.
- (CAShapeLayer *)shapeLayer {
return !_shapeLayer ? _shapeLayer = ({
CAShapeLayer *layer = [CAShapeLayer layer];
layer.lineWidth = kDefaultLineWidth;
layer.fillColor = UIColor.clearColor.CGColor;
layer.strokeColor = [UIColor blackColor].CGColor;
layer.lineCap = kCALineCapSquare;
layer.frame = self.bounds;
layer.strokeStart = 0;
layer.strokeEnd = 1;
layer;
}) : _shapeLayer;
}
I think what's happening here is that, in this frame of the animation, you are drawing a line that consists of a single point. Since the line has a thickness associated with it, and the line cap type is kCALineCapSquare, that'll get rendered as a square with height and width equal to the line width.
You can think of it as if you are drawing a line with a square marker, and you are going to drag the midpoint of the marker so that it goes through every point in the curve you specified. For the first point in the line, it's as if the marker touches down at that point, leaving a square behind.
Here's a visual representation the different line cap types that will hopefully make it more intuitive. You should probably change the line cap style to kCALineCapButt.
Sidenote:
After you make that change, in this line of code
[path moveToPoint:CGPointMake(CGRectGetMidX(self.bounds)+dx/2, dy/2)];
you probably don't have to offset the x coordinate by dx/2 anymore.
I'm using CAKeyframeAnimation to move CALayer on a circle trajectory. Sometimes I need to stop animation and to move animation to the point at which the animation stopped. Here the code:
CAKeyframeAnimation* circlePathAnimation = [CAKeyframeAnimation animationWithKeyPath:#"position"];
CGMutablePathRef circularPath = the circle path;
circlePathAnimation.path = circularPath;
circlePathAnimation.delegate = self;
circlePathAnimation.duration = 3.0f;
circlePathAnimation.repeatCount = 1;
circlePathAnimation.fillMode = kCAFillModeForwards;
circlePathAnimation.removedOnCompletion = NO;
circlePathAnimation.timingFunction = [CAMediaTimingFunction functionWithControlPoints:.43 :.78 :.69 :.99];
[circlePathAnimation setValue:layer forKey:#"animationLayer"];
[layer addAnimation:circlePathAnimation forKey:#"Rotation"];
- (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)flag
{
CALayer *layer = [anim valueForKey:#"animationLayer"];
if (layer) {
CALayer *presentationLayer = layer.presentationLayer;
layer.position = presentationLayer.position;
}
}
But the presentation layer has no changes in position!!! I read that it is no longer reliable from ios 8. So is there any other way I can find the current position of animated layer?
I wasn't aware that the position property of the presentation layer stopped telling you the location of your layer in iOS 8. That's interesting. Hit testing using the hitTest method on the presentation layer still works, I just tried it on an app I wrote a while back.
Pausing and resuming an animation also still works.
For a simple path like a circle path you could check the time of the animation and then calculate the position using trig.
For more complex paths you'd have to know about the path your layer was traversing. Plotting a single cubic or quadratic bezier curve over time is pretty straightforward, but a complex UIBezierPath/CGPath with a mixture of different shapes in it would be a bear to figure out.
I have a UIView whose layer has 2 sublayers, one is a CAShapeLayer, and the other a CALayer.
The CAShapeLayer has a path set using bezierPathWithOvalInRect, and is animated using CABasicAnimation. The "strokeEnd" property is animated from 0.0 to 1.0, over a certain duration. This has the effect of seeing the oval draw from beginning to end over the duration.
The CALayer simply has its contents set to a pencil image, and is animated using CAKeyframeAnimation. The "position" property is animated by setting the path property of the CAKeyframeAnimation to the same path as the CAShapeLayer, and the same duration as the CABasicAnimation. This has the effect of the pencil moving along the same path during the same duration, and it looks like the pencil is drawing the oval.
Works beautifully in iOS6. However, in iOS7, the timing is off - the position and strokeEnd animations are not in sync - they are in sync at specific moments, specifically at times 0, duration/4, duration/2, duration*3/4, and duration - but in between, the synchronization is off.
If instead of an ellipse, I use a rectangle or triangle, for example, it works great in iOS6 and iOS7. Only issue is an ellipse in iOS7.
Essentially I need to know how to synchronize 2 different animations, where each animation is animating a different layer.
Here is the code that creates the 2 layers:
self.drawingLayer = [CAShapeLayer layer];
self.drawingLayer.frame = self.view.bounds;
self.drawingLayer.bounds = drawingRect;
self.drawingLayer.path = path.CGPath;
self.drawingLayer.strokeColor = [[UIColor blackColor] CGColor];
self.drawingLayer.fillColor = nil;
self.drawingLayer.lineWidth = 10.0f;
self.drawingLayer.lineJoin = kCALineJoinRound;
self.drawingLayer.lineCap = kCALineJoinRound;
[self.view.layer addSublayer:self.drawingLayer];
UIImage *pencilImage = [UIImage imageNamed:#"pencil.png"];
self.pencilLayer = [CALayer layer];
self.pencilLayer.contents = (id)pencilImage.CGImage;
self.pencilLayer.contentsScale = [UIScreen mainScreen].scale;
self.pencilLayer.anchorPoint = CGPointMake(0.0, 1.0); //bottom left corner
self.pencilLayer.frame = CGRectMake(0.0f, 0.0f, 100.0f, 100.0f);
[self.view.layer addSublayer:self.pencilLayer];
And here is the code that creates and starts the 2 different animations (self.drawingLayer is the CAShapeLayer, and self.pencilLayer is the CALayer)
CABasicAnimation *drawingAnimation = [CABasicAnimation animationWithKeyPath:#"strokeEnd"];
drawingAnimation.duration = 10.0;
drawingAnimation.fromValue = [NSNumber numberWithFloat:0.0f];
drawingAnimation.toValue = [NSNumber numberWithFloat:1.0f];
[self.drawingLayer addAnimation:drawingAnimation forKey:#"strokeEnd"];
CAKeyframeAnimation *pencilAnimation = [CAKeyframeAnimation animationWithKeyPath:#"position"];
pencilAnimation.duration = drawingAnimation.duration;
pencilAnimation.path = self.drawingLayer.path;
pencilAnimation.calculationMode = kCAAnimationPaced;
pencilAnimation.delegate = self;
pencilAnimation.fillMode = kCAFillModeForwards;
pencilAnimation.removedOnCompletion = NO;
[self.pencilLayer addAnimation:pencilAnimation forKey:#"position"];
UPDATE
Here's a test app that illustrates the problem.
https://github.com/xjones/AnimatedPaths
Apple has informed me this is a known bug in iOS7, and they asked me to file a bug as well, to help them understand the demand for a fix. The tech support person at Apple told me he does not know of a workaround. If any of you figure out a workaround, I’m all ears...
I filed Apple bug reporter bug #15191834 on Oct 9, 2013. In my testing with iOS 8.1 and Xcode 6.1.1 the bug is still there in the Simulator and in the device. The bug was introduced in iOS 7 and has never been fixed.
Please file a bug so Apple can see others are impacted!
For certain reasons, I'm trying to avoid using a CAScrollLayer to do this. The effect I'm going after is to progressively reveal (from bottom to top) a CALayer's content (a png I previously loaded in). So I thought about doing this:
layer.anchorPoint = CGPointMake(.5, 1);
CABasicAnimation* a = [CABasicAnimation animationWithKeyPath:#"bounds.size.height"];
a.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
a.fillMode = kCAFillModeBoth;
a.removedOnCompletion = NO;
a.duration = 1;
a.fromValue = [NSNumber numberWithFloat:0.];
a.toValue = [NSNumber numberWithFloat:layer.bounds.size.height];
[layer addAnimation:a forKey:nil];
The problem with this is you can tell the layer's content is scaled with the bounds. I was trying for the bounds to change but the content to stay always the original size, so that effectively the bounds clip the image and as I increase bounds.height, the image "Reveals" itself.
Any ideas as to how to pull it off or what might I be missing?
Ok I got it to work, but I basically had to update the layer's frame too, to reflect the change in anchor point:
[CATransaction setValue:(id)kCFBooleanTrue forKey:kCATransactionDisableActions];
layer.contentsGravity = kCAGravityTop;
layer.masksToBounds = YES;
layer.anchorPoint = CGPointMake(.5, 1);
CGRect newFrame = layer.frame;
newFrame.origin.y += newFrame.size.height / 2;
layer.frame = newFrame;
[CATransaction setValue:(id)kCFBooleanFalse forKey:kCATransactionDisableActions];
a.toValue = [NSNumber numberWithFloat:layer.bounds.size.height];
[layer addAnimation:a forKey:nil];
"Dad" has the right answer.
You want to create a CAShapeLayer, and install that as the mask on your layer.
You create a CGPath that is just a rectangle and install that path into the shape layer. The contents of the path determine what areas of the masked layer show up. If the path is a triangle in the middle of the layer, then only the triangle appears.
You then create an animation that animates the path.
To reveal your image from the bottom, you'd set up a path that was a 0 height rectangle at the bottom of the layer, and then you'd create a CAAnimation where the toValue is the same rectangle with a hight of the full layer you want to reveal. The system would generate an animation that reveals the image in a sweep.
You can use this same technique to achieve all kinds of cool effects, like barn doors, venetian blinds, "iris wipes", etc.
What if you changed the clipping mask instead? (or use a mask layer).
You could put another image over the target image and move it up like a stage curtain.