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.
Related
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()
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.
I have a UISlider that I need to draw on top of on at arbitrary points along the slider.
I can draw the tick marks in viewDidLoad, but since I can't yet get the correct bounds of the UISlider at this point they are drawn in the wrong places. If I draw them in viewDidLayoutSubviews I do get the correct bounds, but the tick marks don't get displayed.
I'm trying to draw these marks as follows:
override func viewDidLayoutSubviews(){
super.viewDidLayoutSubviews()
writeTickMarks()
}
func writeTickMarks(){
// create a vertical line
let path = UIBezierPath()
path.move(to: CGPoint(x: calculatedXValue, y: calculatedYValue))
path.addLine(to: CGPoint(x: calculatedXValue, y: calculatedYValue + 5))
// draw the vertical line in blue with a thickness of 2
let shapeLayer = CAShapeLayer()
shapeLayer.path = path.cgPath
shapeLayer.strokeColor = UIColor.blue.cgColor
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.opacity = 0.5
shapeLayer.lineWidth = 2
// add the shape to the slider's view
self.slider.superview?.layer.addSublayer(shapeLayer)
}
I've tried calling setNeedsDisplay and layoutIfNeeded on self.view as well as self.slider.superview immediately following the call to writeTickMarks, but these calls don't seem to have any affect. :-/
How can I programmatically draw these new layers to my UISlider?
Disclaimer - I can't answer why the ticks aren't displayed at all when you call your method from viewDidLayoutSubviews, that's pretty odd. But...
I have had numerous headaches with the same concept - The view controller lifecycle and when it actually understands the view's true geometry. The trouble with viewDidLayoutSubviews is that it can often get called multiple times when a UI loads, and that can cause CALayers to be added multiple times.
I also have a CAShapeLayer which I use with a UIBezierPath. I have a hacky solution that works for me.
I do call the CAShapeLayer init method in viewDidLayoutSubviews (equivalent to your writeTickMarks() method).
I made my CAShapeLayer a class-level property, and check whether it's non-nil on every method call:
if self.shapeLayer != nil {
self.shapeLayer!.removeFromSuperView()
self.shapeLayer = nil
}
I remove and de-allocate it if it is non-nil to avoid it being added on every invocation of viewDidLayoutSubviews.
It's not very efficient, but gets around the multiple calls of viewDidLayoutSubviews and the unknown geometry in viewDidLoad.
The code should work as it is (and without the setNeedsDisplay/layoutIfNeeded calls). Check to make sure you aren't just drawing things off the screen. I've done something similar myself where my calculated y values were putting my points 900px below the bottom edge of the screen.
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 want to make rotation of CAShapeLayer with spring effect (like in UIView.animateWithDuration(_:delay:usingSpringWithDamping:initialSpringVelocity:options:animations:completion:)) but on layer not on a view.
When the button is tapped its sublayer of main layer should rotate to 3*PI/4 and spring should bounce to 2*PI/3. Then, when button is tapped again, layer rotation should be done in reversed order than before: first bounce to 2*PI/3, then rotation to the initial position (before first rotation).
How I could do that? I cannot achieve it by UIView.animateWithDuration(_:delay:usingSpringWithDamping:initialSpringVelocity:options:animations:completion:) because layer's transform property is animatable by default.
I've tried changing CATransaction but it rotates only by one angle (without taking into consideration other rotation):
let rotation1 = CGAffineTransformRotate(CGAffineTransformIdentity, angle1)
let rotation2 = CGAffineTransformRotate(CGAffineTransformIdentity, angle2)
let transform = CGAffineTransformConcat(rotation1, rotation2)
CATransaction.begin()
CATransaction.setAnimationDuration(0.6)
self.plusLayer.setAffineTransform(transform)
CATransaction.commit()
Update
According to Duncan C post I try to use CASpringAnimation and I achive animation in one direction:
myLayer.setAffineTransform(CGAffineTransformMakeRotation(angle))
let spring = CASpringAnimation(keyPath: "transform.rotation")
spring.damping = 12.0
spring.fromValue = 2.0 * CGFloat(M_PI)
spring.toValue = 3.0 * CGFloat(M_PI_4)
spring.duration = 0.5
myLayer.addAnimation(spring, forKey: "rotation")
But how to reverse that animation on button tapped?
Thanks in advance for your help.
UIView block animation is for animating view properties. You could animate the button's transform (of type CGAffineTransform, a 2D transform) using UIView.animateWithDuration(_:delay:usingSpringWithDamping:initialSpringVelocity:options:animations:completion:
If you need to animate layer properties, though, you'll need to use Core Animation.
It seems Apple added (or made public) a spring CAAnimation in iOS 9. It doesn't seem to be in the Xcode docs however.
Check out this thread:
SpringWithDamping for CALayer animations?