I have a circular CAShapeLayer where I'd like to on tap, change the stroke and fill colors. To do so, I added a CABasicAnimation, but to my confusion, the animation doesn't end to the set toValue. Instead, the animation, resets back to the original color.
I tried using the animationDidStop delegate method in setting the stroke and fill colors to the desired end color, which worked, but a flashing glitch of the original colors appeared.
Any guidance would be appreciated.
func createCircleShapeLayer() {
let layer = CAShapeLayer()
let circularPath = UIBezierPath(arcCenter: view.center, radius: 50, startAngle: 0, endAngle: 2 * CGFloat.pi, clockwise: true)
layer.path = circularPath.cgPath
layer.strokeColor = UIColor.red.cgColor
layer.lineWidth = 10
layer.fillColor = UIColor.clear.cgColor
layer.lineCap = CAShapeLayerLineCap.round
layer.frame = view.bounds
view.layer.addSublayer(layer)
}
func animateLayerOnTap() {
let strokeAnim = CABasicAnimation(keyPath: "strokeColor")
strokeAnim.toValue = UIColor.red.cgColor
strokeAnim.duration = 0.8
strokeAnim.repeatCount = 0
strokeAnim.autoreverses = false
let fillAnim = CABasicAnimation(keyPath: "fillColor")
fillAnim.toValue = UIColor.lightGray.cgColor
fillAnim.duration = 0.8
fillAnim.repeatCount = 0
fillAnim.autoreverses = false
layer.add(fillAnim, forKey: "fillColor")
layer.add(strokeAnim, forKey: "strokeColor")
}
UIView animation is pretty clean and simple to use. When you submit a block-based UIView animation, the system creates an animation that animates your view's properties to their end values, and they stay there. (Under the covers UIView animation adds one or more CAAnimations to the view's layer, and manages updating the view's properties to the final values once the animation is complete, but you don't need to care about those details.)
None of that is true with Core Animation. It's confusing, non-intuitive, and poorly documented. With Core animation, the animation adds a temporary "presentation layer" on top of your layer that creates the illusion that your properties are animating from their start to the their end state, but it is only an illusion.
By default, the presentation layer is removed once the animation is complete, and your layer seems to snap back to it's pre-animation state, as you've found. There are settings you can apply that cause the animation layer to stick around in it's final state once the animation is complete, but resist the urge to do that.
Instead, what you want to do is to set your layer's properties to their end state explicitly right after the animation starts. Making that more complicated, though, is the fact that a fair number of layer properties are "implicitly animated", meaning that if you change them, the system creates an animation that makes that change for you. (In that case, the change "sticks" once the implicit animation is complete.)
You are animating strokeColor and FillColor, which are both animatable. It would be easier to take advantage of the implicit animation of those layer properties. The tricky part there is that if you want to change any of the default values (like duration) for implicit animations, you have to enclose your changes to animatable properties between a call to CATransaction.begin() and CATransaction.commit(), and use calls to CATransaction to change those values.
Here is what your code to animate your layer's strokeColor and fillColor might look like using implicit animation and a CATransaction:
CATransaction.begin()
CATransaction.setAnimationDuration(0.8)
layer.strokeColor = UIColor.red.cgColor // Your strokeColor was already red?
layer.fillColor = UIColor.lightGray.cgColor
CATransaction.commit()
Related
I have a button in my iOS app and I want to apply a checkmark animation when the user taps it, but I don't have any idea how would I achieve this. If anybody has an idea please help me out.
Below is a sample gif image of the animation I want to create.
To create that animation you can either animate a mask revealing the check mark from left to right, as Matt says, or you can use a CAShapeLayer to draw the check-mark, and then animate the strokeEnd property of the shape layer.
(The sliding mask approach would always reveal the view's contents from left to right¹, whether the view contained a checkmark or a picture of a kitten. The shape layer stroke approach is specific to drawing shapes using UIBezierPath (or the Core Foundation equivalent, CGPath), but you can use it to draw complex shapes from beginning to end, even shapes that loop back on themselves. It's not limited to always revealing the view content from left to right.)
As it happens, the image you used in your question is the product of a demo app I created last weekend that uses the CAShapeLayer approach.
You can find the complete demo app here:
https://github.com/DuncanMC/AnimateCheckMark.git
In order to do animate the stroke, you'll need to create your check mark as a shape layer. Sample code to create a check mark looks like this:
private func checkmarkPath() -> CGPath {
let path = UIBezierPath()
path.move(to: CGPoint(x: 5, y: bounds.midY))
path.addLine(to: CGPoint(x: 25, y: bounds.midY + 20))
path.addLine(to: CGPoint(x: 45, y: bounds.midY - 20))
return path.cgPath
}
(That code isn't very general-purpose. It creates a fixed sized check mark. You might need to modify it to scale the check-mark to fit the view it belongs to.)
Once you have created a shape layer and installed your checkmark in it you need to create an animation that animates the shape layer's strokeEnd property. That code might look like this:
This function animates showing or hiding the checkmark view's layer by animating the layer's strokeEnd property.
- Parameter show: If true, show the checkmark. If false, hide it.
- Parameter animationDuration: the duration of the animation, in seconds.
- Parameter animationCurve: Indicates the animation timing curve to use, .linear or .easeInEaseOut
*/
public func animateCheckmarkStrokeEnd(_ show: Bool,
animationDuration: Double = 0.3,
animationCurve: AnimationCurve = .linear) {
guard let layer = layer as? CAShapeLayer else { return }
let newStrokeEnd: CGFloat = show ? 1.0 : 0.0
let oldStrokeEnd: CGFloat = show ? 0.0 : 1.0
let keyPath = "strokeEnd"
let animation = CABasicAnimation(keyPath: keyPath)
animation.fromValue = oldStrokeEnd
animation.toValue = newStrokeEnd
animation.duration = animationDuration
let timingFunction: CAMediaTimingFunction
if animationCurve == .linear {
timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear)
} else {
timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
}
animation.timingFunction = timingFunction
layer.add(animation, forKey: nil)
DispatchQueue.main.async {
layer.strokeEnd = newStrokeEnd
}
self.checked = show
}
The entire demo app lets you create the checkmark animation as either a stroke animation or a cross-fade, and allows you to use either linear timing or ease-in/ease-out timing. The window for the demo app looks like this:
¹: You could create other mask animations that reveal your view's contents from right to left, top to bottom, from the center out, etc. For the checkmark animation in your question, though, the mask animation you would need would be a left-to-right "wipe" animation.
func makeACircle(circle: UIView, stokeStart: Double, duration: Double){
var progressCircle = CAShapeLayer();
let centerPoint = CGPoint (x: circle.bounds.width / 2, y: circle.bounds.width / 2);
let circleRadius : CGFloat = circle.bounds.width / 2
var circlePath = UIBezierPath(arcCenter: centerPoint, radius: circleRadius, startAngle: CGFloat(-0.5 * M_PI), endAngle: CGFloat(1.5 * M_PI), clockwise: true );
progressCircle = CAShapeLayer ();
progressCircle.path = circlePath.cgPath;
progressCircle.strokeColor = UIColor.white.cgColor;
progressCircle.fillColor = UIColor.clear.cgColor;
progressCircle.lineWidth = 10;
progressCircle.strokeStart = 0;
progressCircle.strokeEnd = 1;
progressCircle.borderWidth = 1
progressCircle.borderColor = UIColor.black.cgColor
circle.layer.addSublayer(progressCircle);
// progressCircle.clipsToBounds = false
self.view.addSubview(circle)
let animation = CABasicAnimation(keyPath: "strokeEnd")
// Set the animation duration appropriately
animation.duration = duration
// Animate from 0 (no circle) to 1 (full circle)
animation.fromValue = stokeStart
animation.toValue = 1
// Do a linear animation (i.e. the speed of the animation stays the same)
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
// Set the circleLayer's strokeEnd property to 1.0 now so that it's the
// right value when the animation ends.
progressCircle.strokeEnd = 1.0
// Do the actual animation
progressCircle.add(animation, forKey: "animateCircle")
}
I'm drawing circles in with the above code. It draws a circle around buttons on the screen that represent time that's passed since the creation of the button. This code works, but when I go to the homescreen and come back, all of the circles on the screen are completely filled in regardless of how much time was left. If within the app I then switch to another page and then come back, it fixes it.
I'm calling makeACircle from within this firebase query
currentUserRef?.child("invitedToPosts").queryOrdered(byChild: "timestamp").queryStarting(atValue: cutoff).observe(.childAdded) { (snapshot: FIRDataSnapshot) in
Once I have enough information about a button to be made, I make the button, then call the makeACircle.
Any ideas on how to prevent the circles from not appearing as if they've reached the strokeEnd when I load in from the homepage?
When you pause the animation,
Look at the presentation layer and save its strokeEnd.
You need to stop the animation, do so. You may want to set the strokeEnd from the presentation layer's value to avoid any jarring changes.
See how much time has elapsed since you started the animation (e.g. compare CACurrentMediaTime() when you paused vs the value when you started the animation) in order to figure out how much time is remaining in the animation.
Then when you re-present the layer, you now have the fromValue for the strokeEnd and what the remaining duration is.
Another thing that can be useful is to set the delegate of the animation and then use animationDidStop to nil your "in progress" state variables if the animation successfully finishes (i.e. if finished is true).
I'm trying to make very basic animation for UIButton. The goal is rotate layer 180 degrees. Here is my animation code which is called from beginTrackingWithTouch:
private func rotateButton() {
let rotationAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotationAnimation.timingFunction = CAMediaTimingFunction(name: "easeIn")
rotationAnimation.toValue = M_PI
rotationAnimation.duration = CollapseButton.kCollapseButtonAnimationDuration
rotationAnimation.repeatCount = 1
rotationAnimation.cumulative = true
layer.addAnimation(rotationAnimation, forKey: "rotationAnimation")
}
Now I'd like to add collapsing view animation when tapping this button. In my VC:
__weak __typeof(self) weakSelf = self;
[UIView animateWithDuration:CollapseButton.kCollapseButtonAnimationDuration animations:^{
CGRect currentFrame = weakSelf.frame;
currentFrame.size.height = 20;
weakSelf.frame = currentFrame;
}];
I have 2 questions:
After button finishes its animation it resets layer position. So, if arrow were showing top, it animated to showing down and finally resets to top. How can I preserve layer orientation?
As you can see animation duration and timing functions are the same. For the reason I cannot understand UIView animates much slower. Any ideas?
Core animation is strange. The animation creates a "presentation layer" that generates the appearance of the change you are animating, but does not actually change the property in question.
In order to get your animation to finish with the object at the end state, you should set both a fromValue (at the starting setting) and a toValue, and then set the property to it's ending value after submitting the animation:
private func rotateButton() {
let rotationAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotationAnimation.timingFunction = CAMediaTimingFunction(name: "easeIn")
rotationAnimation.fromValue = 0.0 //<<--- NEW CODE
rotationAnimation.toValue = M_PI
rotationAnimation.duration = CollapseButton.kCollapseButtonAnimationDuration
rotationAnimation.repeatCount = 1
rotationAnimation.cumulative = true
layer.addAnimation(rotationAnimation, forKey: "rotationAnimation")
layer.transform = CATransformMakeRotation(M_PI) //<<--- NEW CODE
}
You can also set the animation's removeWhenFinished property to true, but that has other complications.
BTW, you should not try to manipulate the frame of a view that has a non-identity transform. Instead, set the scale on the view's transform.
I'm not sure why the 2 animations are taking different amounts of time. I do notice that you are setting the CAAnimation's timing function to easeIn, but leaving the UIView's timing function as the default (ease-in, ease-out.) That will create animations that don't look the same. You should probably set your view animation to use easeIn timing as well. (To do that you'll need to use the longer form animateWithDuration:delay:options:animations:completion:)
In one of my ViewControllers, I have addStatus(), which is pasted below, in my viewDidLoad. I expect the lineWidth of this circle to animate, but it doesn't seem to be doing so. In my search for why this might be, I found this part of Apple's documentation on Animating Layer Content. The part that I thought to be important noted here:
If you want to use Core Animation classes to initiate animations, you must issue all of your Core Animation calls from inside a view-based animation block. The UIView class disables layer animations by default but reenables them inside animation blocks. So any changes you make outside of an animation block are not animated. Listing 3-5 shows an example of how to change a layer’s opacity implicitly and its position explicitly. In this example, the myNewPosition variable is calculated beforehand and captured by the block. Both animations start at the same time but the opacity animation runs with the default timing while the position animation runs with the timing specified in its animation object.
When I looked up why this might not be animating, I read this piece and assumed that it meant I should place my CAAnimation inside of a UIView animation block. This function worked fine in a blank application without the animation block when placed in the rootViewController, but does not seem to animate when in my secondary viewController in this app. Any tips would be wonderfully helpful. Thanks!
func addStatus() {
UIView.animateWithDuration( NSTimeInterval.infinity, animations: { () -> Void in
let patientZeroIndicator = CAShapeLayer()
let radius:CGFloat = 20.0
let center:CGPoint = CGPointMake(self.view.frame.width - radius - 10, radius + 10)
let startAngle = 0.0
let endAngle = 2.0 * Double(M_PI)
patientZeroIndicator.lineWidth = 10.0
patientZeroIndicator.fillColor = UIColor(netHex: cs_red).CGColor
patientZeroIndicator.strokeColor = UIColor.whiteColor().CGColor
patientZeroIndicator.path = UIBezierPath(arcCenter: center, radius: radius, startAngle: CGFloat(startAngle), endAngle: CGFloat(endAngle), clockwise: true).CGPath
self.view.layer.addSublayer(patientZeroIndicator)
// Create a blank animation using the keyPath "cornerRadius", the property we want to animate
let pZeroAnimation = CABasicAnimation(keyPath: "lineWidth")
// Define the parameters for the tween
pZeroAnimation.fromValue = 10.0
pZeroAnimation.toValue = 5.0
pZeroAnimation.autoreverses = true
pZeroAnimation.duration = 3.0
pZeroAnimation.repeatDuration = CFTimeInterval.infinity
pZeroAnimation.timingFunction = CAMediaTimingFunction(controlPoints: 0.25, 0, 0.75, 1)
// Finally, add the animation to the layer
patientZeroIndicator.addAnimation(pZeroAnimation, forKey: "lineWidth")
})
}
in my viewDidLoad.
That is your problem. The viewDidLoad method runs before the view has been added to a window. You can't add animations to a view unless it's in a window. Call addStatus in viewDidAppear: instead.
Also, don't create new layers in your animation block. Create the layer in viewDidLoad.
Using the code below I've been trying to get my CALayer to scale around its center - but it scales by left/top (0,0). I saw this question: Anchor Point in CALayer and have tried setting the anchorPoint - but it was [.5, .5] before I set it and setting makes no difference. Also tried setting contentsGravity.
The set up is I have a CAShapeLayer added to self.view.layer I do some drawing in it and then add the CABasicAnimation. The animation runs fine - but it scales toward the top/left - not the center of the view.
Have been tinkering with this for a few hours - it must be something simple but I'm not getting it.
shapeLayer.anchorPoint = CGPointMake(.5,.5);
shapeLayer.contentsGravity = #"center";
printf("anchorPoint %f %f\n", shapeLayer.anchorPoint.x, shapeLayer.anchorPoint.y);
CABasicAnimation *animation =
[CABasicAnimation animationWithKeyPath:#"transform.scale"];
animation.fromValue = [NSValue valueWithCATransform3D:CATransform3DIdentity];
animation.toValue = [NSValue valueWithCATransform3D:CATransform3DMakeScale(0.1, 0.1, 1.0)];
[animation setDuration:1.0];
animation.fillMode=kCAFillModeForwards;
animation.removedOnCompletion=NO;
[shapeLayer addAnimation:animation forKey:#"zoom"];
What is the bounds of your layer?
I bet it has zero size; try setting its size to the same size as your shape.
CAShapeLayer draws the shape as kind of an overlay, independent of the contents (and bounds and contentsGravity and etc.) that you'd use in an ordinary CALayer. It can be a bit confusing.
Also make sure that you're adding the animation after the view is created. If you're trying to add animation in the viewDidLoad, it probably won't work. Try adding it to viewDidAppear instead.
I only know Swift, but here's an example:
override func viewDidAppear (animated: Bool) {
addPulsation(yourButton)
}
func addPulsation (button: UIButton) {
let scaleAnimation:CABasicAnimation = CABasicAnimation(keyPath: "transform.scale")
scaleAnimation.duration = 1.0
scaleAnimation.repeatCount = 100
scaleAnimation.autoreverses = true
scaleAnimation.fromValue = 1.05;
scaleAnimation.toValue = 0.95;
button.layer.addAnimation(scaleAnimation, forKey: "scale")
}
It's an old thread but in case this helps anyone.
This ia a coordinate space problem. You just need to set the drawing space and the space of the the path. This code works for me...
...just put this at the start of the animation creation method (inside a method on the parent view)...
// add animation to remap (without affecting other elements)
let animationLayer = CALayer()
layer?.addSublayer(animationLayer). // (don't use optional for iOS)
// set the layer origin to the center of the view (or any center point for the animation)
animationLayer.bounds.origin .x = -self.bounds.size.width / 2.0
animationLayer.bounds.origin .y = -self.bounds.height / 2.0
// make image layer with circle around the zero point
let imageLayer = CAShapeLayer()
animationLayer.addSublayer(imageLayer)
let shapeBounds = CGRect(x: -bounds.size.width/2.0,
y:-bounds.size.height/2.0,
width: bounds.size.width,
height: bounds.size.height)
imageLayer.path = CGPath(ellipseIn: shapeBounds, transform: nil)
imageLayer.fillColor = fillColour
// **** apply your animations to imageLayer here ****
this code works unchanged on iOS and MacOS - apart from the optional needed on layer for MacOS.
(BTW you need to remove the animation layer after the animation finishes!)
I spent hours to find out how to do it according the center but couldn't find any solutions for OSX. I decided to to move layer using CATransform3DMakeTranslation and combine both transforms.
My code:
NSView *viewToTransform = self.view;
[viewToTransform setWantsLayer:YES];
viewToTransform.layer.sublayerTransform = CATransform3DConcat(CATransform3DMakeScale(-1.0f, -1.0f, 1.0f), CATransform3DMakeTranslation(viewToTransform.bounds.size.width, viewToTransform.bounds.size.height, 0));