Reverse the rotation of UIImageView using CAKeyframeAnimation - ios

I'm using the following code to rotate an image infinitely, the rotation is clockwise but I also need it to reverse rotation to counter clockwise every 1-2 rotations and then back to clockwise, how to get this to work?
/// Image Rotation - CAKeyframeAnimation
func rotate(imageView: UIImageView, rotationSpeed: Double) {
let animation = CAKeyframeAnimation(keyPath: "transform.rotation.z")
animation.duration = rotationSpeed
animation.fillMode = CAMediaTimingFillMode.forwards
animation.repeatCount = .infinity
animation.values = [0, Double.pi/2, Double.pi, Double.pi*3/2, Double.pi*2]
/// Percentage of each key frame
let moments = [NSNumber(value: 0.0), NSNumber(value: 0.1),
NSNumber(value: 0.3), NSNumber(value: 0.8), NSNumber(value: 1.0)]
animation.keyTimes = moments
imageView.layer.add(animation, forKey: "rotate")
}

Set animation.autoreverses = true, will reverse an animation.

for counter clockwise rotation, you can do this:
self.imageView?.transform = CGAffineTransform(rotationAngle: -2*CGFloat.pi)

Related

Circle animation iOS UIKit behaviour with tail not completing fully

So I'm trying to learn how to draw circles in UIKit and I've got them pretty much figured it out but I'm just trying to implement one more thing. In the video below when the tail of the circle reaches the end I would like for the tail to not reach the head fully, meaning I would like the size of the circle to not shrink completely.
I sort of have it in the video below but there is still the snap were the tails goes away and the animation starts again at the head. So I would like the disappearance of the tail to not go away.
Video Demo: https://github.com/DJSimonSays93/CircleAnimation/blob/main/README.md
Here is the code:
class SpinningView: UIView {
let circleLayer = CAShapeLayer()
let rotationAnimation: CAAnimation = {
let animation = CABasicAnimation(keyPath: "transform.rotation.z")
animation.fromValue = 0
animation.toValue = Double.pi * 2
animation.duration = 3 // increase this duration to slow down the circle animation effect
animation.repeatCount = MAXFLOAT
return animation
}()
override func awakeFromNib() {
super.awakeFromNib()
setup()
}
func setup() {
circleLayer.lineWidth = 10.0
circleLayer.fillColor = nil
//circleLayer.strokeColor = UIColor(red: 0.8078, green: 0.2549, blue: 0.2392, alpha: 1.0).cgColor
circleLayer.strokeColor = UIColor.systemBlue.cgColor
circleLayer.lineCap = .round
layer.addSublayer(circleLayer)
updateAnimation()
}
override func layoutSubviews() {
super.layoutSubviews()
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let radius = min(bounds.width, bounds.height) / 2 - circleLayer.lineWidth / 2
let startAngle: CGFloat = -90.0
let endAngle: CGFloat = startAngle + 360.0
circleLayer.position = center
circleLayer.path = createCircle(startAngle: startAngle, endAngle: endAngle, radius: radius).cgPath
}
private func updateAnimation() {
//The strokeStartAnimation beginTime + duration value need to add up to the strokeAnimationGroup.duration value
let strokeStartAnimation: CABasicAnimation = CABasicAnimation(keyPath: "strokeStart")
strokeStartAnimation.beginTime = 0.5
strokeStartAnimation.fromValue = 0
strokeStartAnimation.toValue = 0.93 //change this to 0.93 for cool effect
strokeStartAnimation.duration = 3.0
strokeStartAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
let strokeEndAnimation: CABasicAnimation = CABasicAnimation(keyPath: "strokeEnd")
strokeEndAnimation.fromValue = 0
strokeEndAnimation.toValue = 1.0
strokeEndAnimation.duration = 2.0
strokeEndAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
let colorAnimation = CABasicAnimation(keyPath: "strokeColor")
colorAnimation.fromValue = UIColor.systemBlue.cgColor
colorAnimation.toValue = UIColor.systemRed.cgColor
let strokeAnimationGroup: CAAnimationGroup = CAAnimationGroup()
strokeAnimationGroup.duration = 3.5
strokeAnimationGroup.repeatCount = Float.infinity
strokeAnimationGroup.fillMode = .forwards
strokeAnimationGroup.isRemovedOnCompletion = false
strokeAnimationGroup.animations = [strokeStartAnimation, strokeEndAnimation, colorAnimation]
circleLayer.add(strokeAnimationGroup, forKey: nil)
circleLayer.add(rotationAnimation, forKey: "rotation")
}
private func createCircle(startAngle: CGFloat, endAngle: CGFloat, radius: CGFloat) -> UIBezierPath {
return UIBezierPath(arcCenter: CGPoint.zero,
radius: radius,
startAngle: startAngle.toRadians(),
endAngle: endAngle.toRadians(),
clockwise: true)
}
Something like this?
There is nothing special here. It is almost exactly the same as your initial code but with a small tweak for the rotation angle.
Approach
Your initial animation looks great to start with! Like you said, the "snap" where the animation restarts from 0% of the strokeEnd is what gives it off.
As #MadProgrammer pointed out, theoretically you can get rid of the "snap" by never starting or ending the stroke at 0%. This ensures there is always some portion of the stroke visible.
This is a great start, but unfortunately strokeStart and strokeEnd do not allow values outside of the [0.0, 1.0] range. So you can't exactly create an animation (without many keyframes) so that the stroke positions overlap in each animation loop (because you would need to use values out of range to cover the full circle).
So, what I have done is use the above method anyway and ended up with the animation shown below. The arc length of the stroke at the start and end of the animation are equal - very important.
Then, using your existing rotation animation I very slightly rotate the entire drawing during the stroke animation so that the start and end arcs seem to land on top of each other. The rotation angle was calculated as follows.
0.07 was selected by subtracting your initial value for strokeStartAnimation.toValue by 1.0.
The scalar length of the arc would then be, 0.07 (S).
The radius of the circle would bounds.width / 2 (r).
To obtain the arc length (L), we need to multiply scalar length by the Perimeter (P).
The relationship between arc length (L) and the rotation angle (theta) is,
2 * Theta * r = L
But L is also equal to S * P, so some substituting around and we get,
theta = 2S (in Radians)
The Solution
So, with that out of the way. The solution is the following changes to your code.
Define the scalar arc length as a class variable, startOffset.
Use startOffset to set the toValue of the strokeStart anim.
Use startOffset to set the fromValue of the strokeEnd anim.
Set the to value of rotationAnimation to 2 * theta.
Match Rotation animation duration with stroke animation duration.
The final rotation animation looks like this:
var rotationAnimation: CAAnimation{
get{
let radius = Double(bounds.width) / 2.0
let perimeter = 2 * Double.pi * radius
let theta = perimeter * startOffset / (2 * radius)
let animation = CABasicAnimation(keyPath: "transform.rotation.z")
animation.fromValue = 0
animation.toValue = theta * 2 + Double.pi * 2
animation.duration = 3.5
animation.repeatCount = MAXFLOAT
return animation
}
}
And the strokes:
let strokeStartAnimation: CABasicAnimation = CABasicAnimation(keyPath: "strokeStart")
strokeStartAnimation.beginTime = 0.5
strokeStartAnimation.fromValue = 0
strokeStartAnimation.toValue = 1.0 - startOffset
strokeStartAnimation.duration = 3.0
strokeStartAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
let strokeEndAnimation: CABasicAnimation = CABasicAnimation(keyPath: "strokeEnd")
strokeEndAnimation.fromValue = startOffset
strokeEndAnimation.toValue = 1.0
strokeEndAnimation.duration = 2.0
strokeEndAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
I made a pull request to your existing code. Try it out and let me know how it goes.

CAKeyFrameAnimation not Linear for values greater than PI

I am having some trouble to understand why an animation isn't working like expected.
What I am doing is this:
Create a UIBezierPath with an arc to move a Label along this path and animate the paths stroke.
//Start Point is -.pi /2 to let the Arc start at the top.
//self.progress = Value between 0.0 and 1.0
let path : UIBezierPath = UIBezierPath.init(arcCenter: CGPoint.init(x: self.bounds.width * 0.5, y: self.bounds.height * 0.5),
radius: self.bounds.width * 0.5, startAngle: -.pi / 2, endAngle: (2 * self.progress * .pi) - (.pi / 2), clockwise: true)
return path
Add this path to a CAShapeLayer
circlePathLayer.frame = bounds
circlePathLayer.path = self.path.cgPath
circlePathLayer.strokeStart = 0
circlePathLayer.strokeEnd = 1
Animate the strokeEnd property with a CABasicAnimation
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.repeatCount = HUGE
animation.fromValue = 0.0
animation.toValue = 1.0
animation.duration = self.animationDuration
animation.isRemovedOnCompletion = false
animation.fillMode = kCAFillModeBoth
Animate the position property of my label with a CAKeyFrameAnimation
let animationScore = CAKeyframeAnimation(keyPath: "position")
//some things I tried to fix
//animationScore.timingFunctions = [CAMediaTimingFunction(controlPoints: 0.250, 0.250, 0.750, 0.750)]
//animationScore.timingFunction = CAMediaTimingFunction.init(name: kCAMediaTimingFunctionLinear)
animationScore.path = self.path.cgPath
animationScore.duration = self.animationDuration
animationScore.isRemovedOnCompletion = false
animationScore.fillMode = kCAFillModeBoth
animationScore.repeatCount = HUGE
Add my animations to layer and label
self.circlePathLayer.add(animation, forKey: nil)
self.scoreLabel.layer.add(animationScore, forKey: nil)
My Problem: For ProgressValues greater than 0.75 my label is not moving in linear speed. Values greater than 0.75 mean that my arc is greater than PI.
For values less than 0.75 my animation works fine and label and strokeend have the same speed and are on top of each other.
GIF :
Please ignore the 100% in the Label in this gif my progress was at a value of 0.76.
You see my Label slows down after three quarters of my circle.
I hope someone can help me.
Many thanks
The keyframe animation introduces an unnecessary complication. Simply rotate the label around the center with the same duration as the shape layer's stroke animation:
(I apologize that my animation starts at the bottom, not the top, but I wasn't looking at your question when I wrote the code and now I'm too lazy to change it!)
So, how is that done? It's three animations, all with the same duration:
The shape layer's strokeEnd, like your animation.
An "arm" running thru the center of the circle, with the label as a sublayer at one end (so that the label appears at the radius of the circle). The arm does a rotation transform animation.
The label does a rotation transform animation in the opposite direction. If it didn't, it would rotate along with its superlayer. (Think of how a Ferris wheel works; your chair is on the end of the arm, but it remains upright with respect to the earth.)
This is the entire animation code:
let anim = CABasicAnimation(keyPath: "transform.rotation.z")
anim.fromValue = 0
anim.toValue = 5
anim.duration = 10
self.arm.layer.add(anim, forKey:nil)
let anim2 = CABasicAnimation(keyPath: "transform.rotation.z")
anim2.fromValue = 0
anim2.toValue = -5
anim2.duration = 10
self.lab.layer.add(anim2, forKey:nil)
let anim3 = CABasicAnimation(keyPath: "strokeEnd")
anim3.fromValue = 0
anim3.toValue = 1
anim3.duration = 10
self.shape.add(anim3, forKey:nil)

CALayer resizing back to original size after transform animation

I'm trying to implement two consecutive transformation animations. When the first animation ends, the second animation is called through the completion handler. Because this is a transformation animation, my issue is that when the first animation finishes, the layer resizes back to the original size, and then the second animation begins. I'd like for the second animation to begin with the new layer size after the first transformation animation. This post Objective-C - CABasicAnimation applying changes after animation? says I have to resize/transform the layer before beginning the first animation, so that when the first animation ends, the layer is actually the new size. I've tried to do that by changing the bounds or actually applying the transform to the layer but its still not working.
override func viewDidAppear(_ animated: Bool) {
buildBar()
}
func buildBar(){
progressBar1.bounds = CGRect(x: 0, y: 0, width: 20, height: 5)
progressBar1.position = CGPoint(x: 0, y: 600)
progressBar1.backgroundColor = UIColor.white.cgColor
view.layer.addSublayer(progressBar1)
extendBar1()
}
func extendBar1(){
CATransaction.begin()
let transform1 = CATransform3DMakeScale(10, 1, 1)
let anim = CABasicAnimation(keyPath: "transform")
// self.progressBar1.bounds = CGRect(x: 0, y: 0, width: 200, height: 5)
// self.progressBar1.transform = transform1
anim.isRemovedOnCompletion = false
anim.fillMode = kCAFillModeForwards
anim.toValue = NSValue(caTransform3D:transform1)
anim.duration = 5.00
CATransaction.setCompletionBlock {
self.extendBar2()
}
progressBar1.add(anim, forKey: "transform")
CATransaction.commit()
}
func extendBar2(){
let transform1 = CATransform3DMakeScale(2, 1, 1)
let anim = CABasicAnimation(keyPath: "transform")
anim.isRemovedOnCompletion = false
anim.fillMode = kCAFillModeForwards
anim.toValue = NSValue(caTransform3D:transform1)
anim.duration = 5.00
progressBar1.add(anim, forKey: "transform")
}
Because you are modifying the transform property of the layer in both animation, it will be easier here to use CAKeyframeAnimation, which will handle the chaining of the animations for you.
func extendBar(){
let transform1 = CATransform3DMakeScale(10, 1, 1)
let transform2 = CATransform3DMakeScale(2, 1, 1)
let anim = CAKeyframeAnimation()
anim.keyPath = "transform"
anim.values = [progressBar1.transform, transform1, transform2] // the stages of the animation
anim.keyTimes = [0, 0.5, 1] // when they occurs, 0 being the very begining, 1 the end
anim.duration = 10.00
progressBar1.add(anim, forKey: "transform")
progressBar1.transform = transform2 // we set the transform property to the final animation's value
}
A word about the content of values and keyTimes:
We set the first value to be the current transform of progressBar1. This will make sure we start in the current layer's state.
In keyTimes, we say that at the begining, the first value in the values array should be used. We then say at animation's half time, the layer should be transformed in values second value. Hence, the animation between the initial state and the second one will occurs during that time. Then, between 0.5 to 1, we'll go from tranform1 to transform2.
You can read more about animations in this very nice article from objc.io.
If you really need two distinct animations (because maybe you want to add subviews to progressBar1 in between, here's the code that will do it
func extendBar1() {
CATransaction.begin()
CATransaction.setCompletionBlock {
print("side effects")
extendBar2()
}
let transform1 = CATransform3DMakeScale(5, 1, 1)
let anim = CABasicAnimation(keyPath: "transform")
anim.fromValue = progressBar1.transform
anim.toValue = transform1
anim.duration = 2.00
progressBar1.add(anim, forKey: "transform")
CATransaction.setDisableActions(true)
progressBar1.transform = transform1
CATransaction.commit()
}
func extendBar2() {
CATransaction.begin()
let transform2 = CATransform3DMakeScale(2, 1, 1)
let anim = CABasicAnimation(keyPath: "transform")
anim.fromValue = progressBar1.transform
anim.toValue = transform2
anim.duration = 2.00
progressBar1.add(anim, forKey: "transform")
CATransaction.setDisableActions(true)
progressBar1.transform = transform2
CATransaction.commit()
}
What happens here? Basically, we setup a first "normal animation". So we create the animation, that will modify the presentation layer and set the actual layer to the final first animation's transform.
Then, when the first animation completes, we call extendBar2 which will in turn queue a normal animation.
You also want to call CATransaction.setDisableActions(true) before explicitly updating the transform, otherwise, core animation will create an implicit one, which will override the one created just before.

CABasicAnimation autoreverse twice as fast

I'm using this code to add pulsing circle with autoreverse:
let scaleAnimation = CABasicAnimation(keyPath: "transform.scale")
scaleAnimation.duration = 6
scaleAnimation.repeatCount = 200
scaleAnimation.autoreverses = true
scaleAnimation.fromValue = 0.1
scaleAnimation.toValue = 0.8
scaleAnimation.timingFunction = CAMediaTimingFunction(controlPoints: 0.42, 0.0, 0.58, 1.0)
animationView.layer.add(scaleAnimation, forKey: "scale")
What I would like to do here is to:
Run animation fromValue = 0.1 toValue = 0.8 at 2x speed,
and go backwards animating it fromValue = 0.8 toValue = 0.1 at 1x speed.
Is there an easy way to achieve this?
You have two ways of doing this:
CAKeyframeAnimation (best choice for you):
Designed specifically for animating a single keyPath with multiple keyframes, with custom timeFunctions on each interval. Just what you need
let scaleAnimation = CAKeyframeAnimation(keyPath: "transform.scale")
scaleAnimation.duration = 18 // 6 seconds for the first part, 12 for the second
scaleAnimation.repeatCount = 200
scaleAnimation.values = [0.1, 0.8, 0.1] // make sure first and last values are equal in order to get seamless animation
scaleAnimation.keyTimes = [0, 0.333, 1] // keyframes scaled to [0; 1] interval
scaleAnimation.timingFunctions = [
CAMediaTimingFunction(controlPoints: 0.42, 0.0, 0.58, 1.0), //first interval
CAMediaTimingFunction(controlPoints: 0.58, 0.0, 0.42, 1.0) //second interval (reversed)
]
layer.add(scaleAnimation, forKey: nil)
CAAnimationGroup (kind of workaround)
Designed to group animations (perhaps with different keyPaths) for a single layer
let scaleUpAnimation = CAKeyframeAnimation(keyPath: "transform.scale")
//setup first animation as you did
let scaleDownAnimation = CAKeyframeAnimation(keyPath: "transform.scale")
//setup second animation
let groupAnimation = CAAnimationGroup()
groupAnimation.animations = [scaleUpAnimation, scaleDownAnimation]
//setup group if needed
layer.add(groupAnimation, forKey: nil)

How to change start point of a circular animation in Swift by using CABasicAnimation?

I have a function to draw an animated circle as a processing bar. I can get it animated from the bottom of the circle. However, I would like to change the start point to the top of the circle.
I attached my code below:
func animateProgressView(_finishPoint:CGFloat, _stringShowAtEnd: String) {
progressLabel.text = "Rating..."
progressLayer.strokeEnd = 0.0
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = CGFloat(0.0)
animation.toValue = CGFloat(_finishPoint)
animation.duration = 2.0
animation.delegate = self
animation.removedOnCompletion = false
animation.additive = true
animation.fillMode = kCAFillModeForwards
progressLayer.addAnimation(animation, forKey: "strokeEnd")
stringShowAtEnd=_stringShowAtEnd
}
What I have quickly tried:
changed fromValue and toValue to 0.5 and 1.0. //didn't work
changed the fillMode to kCAFillModeBackwards. //didn't work
changed the keyPath and forKey to "strokeStart" //didn't work
I haven't spent too much time on reading the official documents.
Anyone can provide a quick answer how can I change the start point to the top like the picture shown below?
Solution:
Thank "rob mayoff". He point me out I should post the path creation function, which I didn't notice and recognize. I solved the problem by modifying the angles of both startAngle and endAngle.
I attached the code below. I hope it will be helpful for everyone who has the similar issue.
private func createProgressLayer() {
//let startAngle = CGFloat(M_PI_2) //old angle, Pi/2=90 degrees ,started from the bottom
//let endAngle = CGFloat(M_PI * 2 + M_PI_2) //old angle,2*Pi+Pi/2=360 + 90 degrees ,end at the bottom
let startAngle = CGFloat(M_PI_2*3) //new angle, (Pi/2)*3=90*3 degrees ,starts from the top
let endAngle = CGFloat(M_PI * 2 + M_PI_2*3) //new angle, 2*Pi+(Pi/2)*3=360 + 90*3 degrees ,ends at the top
let centerPoint = CGPointMake(CGRectGetWidth(frame)/2 , CGRectGetHeight(frame)/2)
var gradientMaskLayer = gradientMask()
progressLayer.path = UIBezierPath(arcCenter:centerPoint, radius: CGRectGetWidth(frame)/2 - 30.0, startAngle:startAngle, endAngle:endAngle, clockwise: true).CGPath
progressLayer.backgroundColor = UIColor.clearColor().CGColor
progressLayer.fillColor = nil
progressLayer.strokeColor = UIColor.blackColor().CGColor
progressLayer.lineWidth = 10.0
progressLayer.strokeStart = 0.0
progressLayer.strokeEnd = 0.0
gradientMaskLayer.mask = progressLayer
layer.addSublayer(gradientMaskLayer)
}
You're creating the path so that the start point is at the bottom. You need to create the path so that the start point is at the top.

Resources