Circle animation iOS UIKit behaviour with tail not completing fully - ios

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.

Related

Center of CAGradientLayer returns wrong position on rotate animation

I have a circle like in Instagram profile image which has story. I want to have an affect like circle is spinning. For that, I used CABasicAnimation. It is spinning but not in center.
As I searched, I need to give bounty for shapeLayer but when I do that, It doesn't placed where It needs to be.
How can I achieve animation like in Instagram story circle (like circle is spinning)?
EDIT I also try to add "colors" animation but because It works like It is in square, I can't get the desired result.
func addCircle() {
let circularPath = UIBezierPath(arcCenter: CGPoint(x: self.bounds.midX, y: self.bounds.midY), radius: self.bounds.width / 2, startAngle: -CGFloat.pi / 2, endAngle: 2 * CGFloat.pi, clockwise: true)
shapeLayer.strokeColor = UIColor.blue.cgColor
shapeLayer.lineWidth = 10
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.lineCap = kCALineCapRound
shapeLayer.strokeEnd = 1.0
gradient.frame = circularPath.bounds
gradient.colors = [UIColor.blue.cgColor, UIColor.red.cgColor]
gradient.startPoint = CGPoint(x: 0, y: 1)
gradient.endPoint = CGPoint(x: 1, y: 0)
shapeLayer.path = circularPath.cgPath
gradient.mask = shapeLayer
self.layer.addSublayer(gradient)
}
let rotationAnimation: CAAnimation = {
let animation = CABasicAnimation(keyPath: "transform.rotation")
animation.fromValue = 0
animation.toValue = 1
animation.duration = 4
animation.repeatCount = MAXFLOAT
return animation
}()
#objc private func handleTap() {
print("Attempting to animate stroke")
shapeLayer.add(rotationAnimation, forKey: "urSoBasic2")
}
If spinning, why not spin gradient
let rotationAnimation: CAAnimation = {
let animation = CABasicAnimation(keyPath: "transform.rotation")
animation.fromValue = 0
animation.toValue = Float.pi * 2
animation.duration = 4
animation.repeatCount = MAXFLOAT
return animation
}()
#objc func handleTap() {
print("Attempting to animate stroke")
gradient.add(rotationAnimation, forKey: "urSoBasic2")
}
As to off center problem. The correct frame for gradient should be like this :
gradient.frame = self.bounds
To verify the center, you may add a background for the view:
self.background = UIColor.black.
The reason is width and height of the view is not set well due to the constraints. Try to set the correct bounds of the view, so when you add the circularPath, it's center in the view.

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)

Incorrect position of CAShapeLayer

I have a UIView called viewProgress. It is the white box in the image. I want a circular progress bar, which is the green circle in the image. The progress bar should stay within the white box, but as you can see it is way off.
How do I make it stay inside the viewProgress?
Here you have my animation function:
func animateView() {
let circle = viewProgress // viewProgress is a UIView
var progressCircle = CAShapeLayer()
let circlePath = UIBezierPath(arcCenter: circle.center, radius: circle.bounds.midX, startAngle: -CGFloat(M_PI_2), endAngle: CGFloat(3.0 * M_PI_2), clockwise: true)
progressCircle = CAShapeLayer ()
progressCircle.path = circlePath.CGPath
progressCircle.strokeColor = UIColor.whiteColor().CGColor
progressCircle.fillColor = UIColor.clearColor().CGColor
progressCircle.lineWidth = 10.0
circle.layer.addSublayer(progressCircle)
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = 0
animation.toValue = 0.4
animation.duration = 1
animation.fillMode = kCAFillModeForwards
animation.removedOnCompletion = false
progressCircle.addAnimation(animation, forKey: "ani")
}
And I'm just calling the function inside viewDidLoad()
Basically u have few problems:
incorrect position of your layer - frame should be equal to bounds of parent layer in parentView
you animation will play only once - if u want to make it infinite add something like animation.repeatCount = MAXFLOAT
to make sure that all size correct - in layoutSubviews recreate layers, with removing previously created one
UIBezierPath(arcCenter... will start path from rightSide, u can also use simpler variant like UIBezierPath(ovalInRect. With this in mind u also need to rotate layer for M_PI_2 if u want to make start of drawing in the very top of circle
U should also take in mind that with line width 10, half of u'r circle path will be catted by clipSubviews of parentView. To prevent this use modified parentLayer rect
U can also maybe want to improve visualization of lineCap, buy setting it to "round" style
progressCircle.addAnimation(animation, forKey: "ani") key for anim not required if u dont want to get completion event from animation - i your case i see there is no delegate and no removeOnCompletion flag setted to false, so make assumption that u don`t actually need it
Keeping this all in mind code can be like :
let circle = UIView(frame: CGRectMake(100, 100, 100, 100)) // viewProgress is a UIView
circle.backgroundColor = UIColor.greenColor()
view.addSubview(circle)
var progressCircle = CAShapeLayer()
progressCircle.frame = view.bounds
let lineWidth:CGFloat = 10
let rectFofOval = CGRectMake(lineWidth / 2, lineWidth / 2, circle.bounds.width - lineWidth, circle.bounds.height - lineWidth)
let circlePath = UIBezierPath(ovalInRect: rectFofOval)
progressCircle = CAShapeLayer ()
progressCircle.path = circlePath.CGPath
progressCircle.strokeColor = UIColor.whiteColor().CGColor
progressCircle.fillColor = UIColor.clearColor().CGColor
progressCircle.lineWidth = 10.0
progressCircle.frame = view.bounds
progressCircle.lineCap = "round"
circle.layer.addSublayer(progressCircle)
circle.transform = CGAffineTransformRotate(circle.transform, CGFloat(-M_PI_2))
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = 0
animation.toValue = 1.1
animation.duration = 1
animation.repeatCount = MAXFLOAT
animation.fillMode = kCAFillModeForwards
animation.removedOnCompletion = false
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseIn)
progressCircle.addAnimation(animation, forKey: nil)
and result actually like:
Note:
If u want to animate rotation of animated layer u should also add additional animation in to anim group

Strange behaviour when animating between two circular UIBezierPaths

The Problem
I am creating an exploding rings effect. I'm using multiple CAShapeLayer's and creating a UIBezierPath for each layer. When the view is initialised, all the layers have a path with width 0. When an action is triggered, the rings animate to a larger path.
As seen in the demo below, the right hand edge of each layer is slower to animate than the remainder of each circular layer.
Demo
Code
Drawing the layers:
func draw(inView view: UIView) -> CAShapeLayer {
let shapeLayer = CAShapeLayer()
// size.width defaults to 0
let circlePath = UIBezierPath(arcCenter: view.center, radius: size.width / 2, startAngle: 0, endAngle: CGFloat(360.0).toRadians(), clockwise: true)
shapeLayer.path = circlePath.CGPath
shapeLayer.frame = view.frame
return shapeLayer
}
Updating the layers
func updateAnimated(updatedOpacity: CGFloat, updatedSize: CGSize, duration: CGFloat) {
let updatePath = UIBezierPath(arcCenter: view.center, radius: updatedSize.width / 2,
startAngle: 0, endAngle: CGFloat(360).toRadians(), clockwise: true)
let pathAnimation = CABasicAnimation(keyPath: "path")
pathAnimation.fromValue = layer.path // the current path (initially with width 0)
pathAnimation.toValue = updatePath.CGPath
let opacityAnimation = CABasicAnimation(keyPath: "opacity")
opacityAnimation.fromValue = self.opacity
opacityAnimation.toValue = updatedOpacity
let animationGroup = CAAnimationGroup()
animationGroup.animations = [pathAnimation, opacityAnimation]
animationGroup.duration = Double(duration)
animationGroup.fillMode = kCAFillModeForwards
animationGroup.removedOnCompletion = false
layer.addAnimation(animationGroup, forKey: "shape_update")
... update variables ...
}
Avoid starting with a path width of 0. It is very difficult to scale from 0 and tiny floating-point errors magnify. Values very close to zero are not very continuous in floating point (hmmm.... I guess that actually is a little like the real universe). Try starting with larger values and see how close to zero you can go safely.

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