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)
}
There is nothing special here. It is almost exactly the same as your initial code but with a small tweak for the rotation angle.
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
.
bounds.width / 2
(r).2 * Theta * r = L
But L is also equal to S * P, so some substituting around and we get,
theta = 2S (in Radians)
So, with that out of the way. The solution is the following changes to your code.
startOffset
.startOffset
to set the toValue
of the strokeStart
anim.startOffset
to set the fromValue
of the strokeEnd
anim.to
value of rotationAnimation
to 2 * theta
.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.