I have a UIView, with view.layer.mask set to an instance of CAShapeLayer. The shape layer contains a path, and now I want to add a hole to this shape by adding a second shape with even/odd rule, and fade-in the appearance of this hole.
The problem is that adding to path doesn't seem to be animatable:
[UIView animateWithDuration:2 animations:^() {
CGPathAddPath(parentPath, nil, holePath);
[v.layer.mask didChangeValueForKey:#"path"];
}];
How would I animate this?
After some fiddling, found a workaround:
Create a layer with two sublayers with two desired shapes, and use it as a mask
Animate opacity of the first sublayer (without a hole) from 1 to 0.
This works because child CAShapeLayer instances appear to be used as a union. When you hide the first sublayer without a hole, only the hole will be uncovered, the shared area will not change.
CGMutablePathRef p = CGPathCreateMutable();
// First path
CGPathAddPath(p, nil, outerPath);
CAShapeLayer *mask1 = [CAShapeLayer layer];
mask1.path = p;
// 2nd path with a hole
CGPathAddPath(p, nil, innerPath);
CAShapeLayer *mask2 = [CAShapeLayer layer];
mask2.path = p;
mask2.fillRule = kCAFillRuleEvenOdd;
CGPathRelease(p);
// Create actual mask that hosts two submasks
CALayer *mask = [CALayer layer];
[mask addSublayer:mask1];
[mask addSublayer:mask2];
myView.layer.mask = mask;
mask.frame = v.layer.bounds;
mask1.frame = mask.bounds;
mask2.frame = mask.bounds;
// ...
// Hide mask #1
CABasicAnimation *a = [CABasicAnimation animationWithKeyPath:#"opacity"];
a.fromValue = #1.0;
a.toValue = #0.0;
a.duration = 1.0;
a.fillMode = kCAFillModeForwards; // Don't reset back to original state
a.removedOnCompletion = NO;
[mask1 addAnimation:a forKey:#"hideMask1"];
You can't use UIView animation to animate CALayers.
Most layer property changes do animation by default (implicit animation). As I recall, shape layer path changes are an exception to that.
You'll need to create a CAAnimation object where the property you are animating is the path on your mask layer.
However, that probably won't give the effect you want. The reason is that when you change a path on a shape layer, Core Animation tries to animate the change in shape of the path. Furthermore, path changes only work properly when the starting and ending paths have the same number and type of control points.
I'm not sure how you'd achieve a cross-fade between 2 different masks without a lot of work.
Off the top of my head, the only way I can think of to do this would be to create a snapshot of the new view appearance with the changed mask (probably using Core Image filters) and then do a cross-fade of a layer that displays that snapshot. Once the crossfade is complete, you would install the new path in your mask layer without animation and then remove the snapshot bitmap, revealing the real view underneath.
There might be a simpler way to achieve what you're after but I don't know what that would be. Maybe one of the CA experts that contributes to SO could chime in here.
Related
I am running into an issue when I create an explicit animation to change the value of a CAShapeLayer's path from an ellipse to a rect.
In my canvas controller I setup a basic CAShapeLayer and add it to the root view's layer:
CAShapeLayer *aLayer;
aLayer = [CAShapeLayer layer];
aLayer.frame = CGRectMake(100, 100, 100, 100);
aLayer.path = CGPathCreateWithEllipseInRect(aLayer.frame, nil);
aLayer.lineWidth = 10.0f;
aLayer.strokeColor = [UIColor blackColor].CGColor;
aLayer.fillColor = [UIColor clearColor].CGColor;
[self.view.layer addSublayer:aLayer];
Then, when I animate the path I get a strange glitch / flicker in the last few frames of the animation when the shape becomes a rect, and in the first few frames when it animates away from being a rect. The animation is set up as follows:
CGPathRef newPath = CGPathCreateWithRect(aLayer.frame, nil);
[CATransaction lock];
[CATransaction begin];
[CATransaction setAnimationDuration:5.0f];
CABasicAnimation *ba = [CABasicAnimation animationWithKeyPath:#"path"];
ba.autoreverses = YES;
ba.fillMode = kCAFillModeForwards;
ba.repeatCount = HUGE_VALF;
ba.fromValue = (id)aLayer.path;
ba.toValue = (__bridge id)newPath;
[aLayer addAnimation:ba forKey:#"animatePath"];
[CATransaction commit];
[CATransaction unlock];
I have tried many different things like locking / unlocking the CATransaction, playing with various fill modes, etc...
Here's an image of the glitch:
http://www.postfl.com/outgoing/renderingglitch.png
A video of what I am experiencing can be found here:
http://vimeo.com/37720876
I received this feedback from the quartz-dev list:
David Duncan wrote:
Animating the path of a shape layer is only guaranteed to work when
you are animating from like to like. A rectangle is a sequence of
lines, while an ellipse is a sequence of arcs (you can see the
sequence generated by using CGPathApply), and as such the animation
between them isn't guaranteed to look very good, or work well at all.
To do this, you basically have to create an analog of a rectangle by
using the same curves that you would use to create an ellipse, but
with parameters that would cause the rendering to look like a
rectangle. This shouldn't be too difficult (and again, you can use
what you get from CGPathApply on the path created with
CGPathAddEllipseInRect as a guide), but will likely require some
tweaking to get right.
Unfortunately this is a limitation of the otherwise awesome animatable path property of CAShapeLayers.
Basically it tries to interpolate between the two paths. It hits trouble when the destination path and start path have a different number of control points - and curves and straight edges will have this problem.
You can try to minimise the effect by drawing your ellipse as 4 curves instead of a single ellipse, but it still isn't quite right. I haven't found a way to go smoothly from curves to polygons.
You may be able to get most of the way there, then transfer to a fade animation for the last part - this won't look as nice, though.
In simple words only part of my icon must change color at some event.
I drawing an icon with curves inside an UIView. The drawing boilds
down to combining several paths together like this (swift code):
let combinedPath = CGPathCreateMutableCopy(bezierPath.CGPath)
CGPathAddPath(combinedPath, nil, bezier2Path.CGPath);
CGPathAddPath(combinedPath, nil, bezier3Path.CGPath);
Then I'm adding the combined path to the UIView like this (obj-c, sorry for
the mix):
CAShapeLayer *shapeLayer = [CAShapeLayer layer];
shapeLayer.frame = self.bounds;
shapeLayer.path = combinedPath;
self.layer.mask = shapeLayer;
And now at some point in time I want to change the fill color of
only one bezierPath with animation. How can I do this?
You can clean the current mask, and redraw any of the bezierpath with stroke/fill color and combine them together, and set it again. And for drawing animation, you can refer to How to Animate CoreGraphics Drawing of Shape Using CAKeyframeAnimation
I have two separate images.
CurvedPath image (Attached Below)
PersonImage
As you can see this path is not having perfect arc for single angle.
I have another PersonImage that I want to animate exactly above the center line for this arc image with zoom-in effect.
This animation should be start from bottom-left point to top-right point.
How to achieve this kind of animation?
I have read about QuartzCore and BeizerPath animation, but as I am having less knowledge about those, it will quiet difficult to me to achieve this quickly.
Moving along a path
As long as you can get the exact path that you want to animate the image align you can do it using Core Animation and CAKeyframeAnimation.
Create a key frame animation for the position property and set it do animate along your path
CAKeyframeAnimation *moveAlongPath = [CAKeyframeAnimation animationWithKeyPath:#"position"];
[moveAlongPath setPath:myPath]; // As a CGPath
If you created your path as a UIBezierPath then you can easily get the CGPath by calling CGPath on the bezier path.
Next you configure the animation with duration etc.
[moveAlongPath setDuration:5.0]; // 5 seconds
// some other configurations here maybe...
Now you add the animation to your imageView's layer and it will animate along the path.
[[myPersonImageView layer] addAnimation:moveAlongPath forKey:#"movePersonAlongPath"];
If you've never used Core Animation before, you need to add QuartzCore.framework to your project and add #import <QuartzCore/QuartzCore.h> at the top of your implementation.
Creating a UIBezierPath
If you don't know what a bezier path is, look at the Wikipedia site. Once you know your control points you can create a simple bezier path like this (where all the points are normal CGPoints):
UIBezierPath *arcingPath = [UIBezierPath bezierPath];
[arcingPath moveToPoint:startPoint];
[arcingPath addCurveToPoint:endPoint
controlPoint1:controlPoint1
controlPoint2:controlPoint2];
CGPathRef animationPath = [arcingPath CGPath]; // The path you animate along
Zooming up
To achieve the zoom effect you can apply a similar animation using a CABasicAnimation for the transform of the layer. Simply animate from a 0-scaled transform (infinitely small) to a 1-scaled transform (normal size).
CABasicAnimation *zoom = [CABasicAnimation animationWithKeyPath:#"transform"];
[zoom setFromValue:[NSValue valueWithCATransform3D:CATransform3DMakeScale(0.0, 0.0, 1.0);];
[zoom setToValue:[NSValue valueWithCATransform3D:CATransform3DIdentity];
[zoom setDuration:5.0]; // 5 seconds
// some other configurations here maybe...
[[myPersonImageView layer] addAnimation:zoom forKey:#"zoomPersonToNormalSize"];
Both at the same time
To have both animations run at the same time you add them to an animation group and add that to the person image view instead. If you do this then you configure the animation group (with duration and such instead of the individual animations).
CAAnimationGroup *zoomAndMove = [CAAnimationGroup animation];
[zoomAndMove setDuration:5.0]; // 5 seconds
// some other configurations here maybe...
[zoomAndMove setAnimations:[NSArray arrayWithObjects:zoom, moveAlongPath, nil]];
[[myPersonImageView layer] addAnimation:zoomAndMove forKey:#"bothZoomAndMove"];
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.
I am using the TapkuLibrary's otherwise excellent TKCalendarDayEventView and trying to selectively round one of the corners in the view as StuDev demonstrates here. Unfortunately, applying StuDev's code snippet results in the EventView disappearing entirely from its containing TKCalendarDayTimelineView. I am adding this code snippet underneath the current code in the
+ (id)eventViewWithFrame:(CGRect)frame id:(NSNumber *)id startDate:(NSDate *)startDate endDate:(NSDate *)endDate title:(NSString *)title location:(NSString *)location;
method. I have commented out code that otherwise sets the border width, color, or radius in the code. I have made sure that TKCalendarDayEventView doesn't have any superlayers, since the
apple docs warn against adding masks to layers with superlayers:
When setting the mask to a new layer, the new layer’s superlayer must first be set to nil, otherwise the behavior is undefined.
I have also tried playing around with the backgroundColor and fillColor properties of the maskLayer. I don't see anything in the TKCalendarDayEventView that might stop this mask from being correctly applied. What could I be doing wrong?
If you put a breakpoint in your eventViewWithFrame:id:startDate:endDate:title:location: method, you will see that when you create your event view you are setting the frame to CGRectZero. The code snippet that then sets the rounded corner mask is using CGRectZero as the mask layer's frame.
Probably the simplest way to deal with this would be to override TKCalendarDayEventView's setFrame: method like so:
- (void)setFrame:(CGRect)newFrame
{
if (!CGRectEqualToRect([super frame], newFrame)) {
[super setFrame:newFrame];
// Change the view's mask layer to fit the new frame.
UIBezierPath *maskPath = [UIBezierPath bezierPathWithRoundedRect:self.bounds
byRoundingCorners:UIRectCornerTopLeft
cornerRadii:CGSizeMake(15.0, 15.0)];
CAShapeLayer *maskLayer = [CAShapeLayer layer];
maskLayer.frame = self.bounds;
maskLayer.path = maskPath.CGPath;
self.layer.mask = maskLayer;
}
}
This way, every time you change the frame of the view the mask automatically adjusts.