Strange behaviour when animating between two circular UIBezierPaths - ios

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.

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.

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.

How to make a gradient circular path in Swift

I want to create a gradient circular path like the following image:
and I have the following code:
circularPath = UIBezierPath(arcCenter: .zero, radius: radius, startAngle: 0, endAngle: .pi * 2, clockwise: true)
outerTrackShapeLayer = CAShapeLayer()
outerTrackShapeLayer.path = circularPath.cgPath
outerTrackShapeLayer.position = position
outerTrackShapeLayer.strokeColor = outerTrackColor.cgColor
outerTrackShapeLayer.fillColor = UIColor.clear.cgColor
outerTrackShapeLayer.lineWidth = lineWidth
outerTrackShapeLayer.strokeEnd = 1
outerTrackShapeLayer.lineCap = CAShapeLayerLineCap.round
outerTrackShapeLayer.transform = rotateTransformation
addSublayer(outerTrackShapeLayer)
innerTrackShapeLayer = CAShapeLayer()
innerTrackShapeLayer.strokeColor = innerTrackColor.cgColor
innerTrackShapeLayer.position = position
innerTrackShapeLayer.strokeEnd = progress
innerTrackShapeLayer.lineWidth = lineWidth
innerTrackShapeLayer.lineCap = CAShapeLayerLineCap.round
innerTrackShapeLayer.fillColor = UIColor.clear.cgColor
innerTrackShapeLayer.path = circularPath.cgPath
innerTrackShapeLayer.transform = rotateTransformation
let gradient = CAGradientLayer()
gradient.frame = circularPath.bounds
gradient.colors = [UIColor.magenta.cgColor, UIColor.cyan.cgColor]
gradient.position = innerTrackShapeLayer.position
gradient.mask = innerTrackShapeLayer
addSublayer(gradient)
but it doesn't work correctly, you can see the result in the following image:
I would appreciate if someone help me, thanks.
It looks like the gradient layer frame is set equal to the path frame which doesn't include the thickness of the stroke of the CAShapeLayer which is why it is cut off in a square. I can't see from the code whether you have the circular path on a subview but if you set the gradient frame to the same as the subview frame that should sort the cut off out as well as the misalignment of the progress view on the track.
Hope that helps.
I also face this issue and fixed it by changing the radius value as my radius value was larger than the expected
In circularPath, you need to make sure that your radius value should not be larger than the following
let radius = (min(width, height) - lineWidth) / 2
circularPath = UIBezierPath(arcCenter: .zero, radius: radius, startAngle: 0, endAngle: .pi * 2, clockwise: true)

animating path in swift 3

following code creates a red 270 degree ring
let result = Measurement(value: 270, unit: UnitAngle.degrees)
.converted(to: .radians).value
let circlePath = UIBezierPath(arcCenter: CGPoint(x: 200,y: 200), radius: CGFloat(90), startAngle: CGFloat(0), endAngle:CGFloat(result), clockwise: true)
let shapeLayer = CAShapeLayer()
shapeLayer.path = circlePath.cgPath
//change the fill color
shapeLayer.fillColor = UIColor.clear.cgColor
//you can change the stroke color
shapeLayer.strokeColor = UIColor.red.cgColor
//you can change the line width
shapeLayer.lineWidth = 30.0
view.layer.addSublayer(shapeLayer)
How would i go about and animate the path being drawn using swift 3
thanks
CAShapeLayer has properties strokeStart and strokeEnd that define what part of the path you want to draw. Their default values are 0.0 and 1.0 respectively, since you usually want to draw the entire path.
You can animate the change of these values using a CABasicAnimation by specifying the "strokeEnd" or "strokeStart" value key, like this:
let pathAnimation = CABasicAnimation(keyPath: "strokeEnd")
pathAnimation.fromValue = 0.0
pathAnimation.toValue = 1.0
pathAnimation.duration = 1.0 // time in seconds.
shapeLayer.add(pathAnimation, forKey: "strokeEnd")

CAShapeLayer with gradient

I have a problem with adding gradient to CAShapeLayer. I've created circle layer and I want to add a gradient to it. So, I created a CAGradientLayer and set it frames to layer bounds and mask to layer and it doesn't appear. What can I do?
private func setupCircle() -> CAShapeLayer {
let circlePath = UIBezierPath(arcCenter: CGPoint(x: progressView.frame.size.width / 2.0, y: progressView.frame.size.height / 2.0), radius: (progressView.frame.size.width - 10)/2, startAngle: 0.0, endAngle: CGFloat(M_PI * 2.0), clockwise: true)
let circleLayer = CAShapeLayer()
circleLayer.path = circlePath.CGPath
circleLayer.fillColor = nil
circleLayer.strokeColor = UIColor.yellowColor().CGColor
circleLayer.backgroundColor = UIColor.yellowColor().CGColor
circleLayer.lineWidth = configurator.lineWidth
circleLayer.strokeEnd = 1.0
return circleLayer
}
private func setupGradient() -> CAGradientLayer {
let gradient = CAGradientLayer()
gradient.colors = [UIColor.clearColor().CGColor, UIColor.yellowColor().CGColor, UIColor.greenColor().CGColor]
gradient.locations = [0.0, 0.25, 1.0]
gradient.startPoint = CGPointMake(0, 0)
gradient.endPoint = CGPointMake(1, 1)
return gradient
}
let circle = setupCircle()
let gradient = setupGradient()
gradient.frame = circle.bounds
gradient.mask = circle
progressView.layer.addSublayer(circle)
progressView.layer.insertSublayer(gradient, atIndex: 0)
EDIT:
I've changed setupCircle method to:
private func setupCircle() -> CAShapeLayer {
let circleLayer = CAShapeLayer()
circleLayer.frame = progressView.bounds.insetBy(dx: 5, dy: 5)
circleLayer.path = UIBezierPath(ovalInRect: CGRectMake(progressView.center.x, progressView.center.y, progressView.bounds.size.width, progressView.bounds.size.height)).CGPath
circleLayer.fillColor = nil
circleLayer.strokeColor = UIColor.yellowColor().CGColor
circleLayer.backgroundColor = UIColor.yellowColor().CGColor
circleLayer.lineWidth = configurator.lineWidth
circleLayer.strokeEnd = 1.0
return circleLayer
}
And here the effect, did I do it properly?:
EDIT 2:
I've changed circleLayer.path = UIBezierPath(ovalInRect: CGRectMake(progressView.center.x, progressView.center.y, progressView.bounds.size.width, progressView.bounds.size.height)).CGPath to circleLayer.path = UIBezierPath(ovalInRect: circleLayer.bounds).CGPath and it's better but still not what I want:
It's ok that you create layers and add them to layer hierarchy in viewDidLoad, but you shouldn't do any frame logic there since layout is not updated at that moment.
Try to move all the code which is doing any frame calculations to viewDidLayoutSubviews.
This should also be done every time layout is updated, so move it from setupCircle to viewDidLayoutSubviews:
let circlePath = UIBezierPath(...)
circleLayer.path = circlePath.cgPath
Edit:
Also I can't see where do you set circleLayer's frame? It's not enough to set it's path. If you have CGRect.zero rect for a mask, it will completely hide it's parent layer.
I would recommend to replace this:
let circlePath = UIBezierPath(arcCenter: CGPoint(x: progressView.frame.size.width / 2.0, y: progressView.frame.size.height / 2.0), radius: (progressView.frame.size.width - 10)/2, startAngle: 0.0, endAngle: CGFloat(M_PI * 2.0), clockwise: true)
with this:
circleLayer.bounds = progressView.frame.insetBy(dx: 5, dy: 5)
circleLayer.path = UIBezierPath(ovalIn: circleLayer.bounds).cgPath
This way you'll have both frame and path set. Also be careful about coordinate system. I'm not sure about where is progressView in your view hierarchy, perhaps you'll need it's bounds instead.
Edit 2:
Also you should delete a few lines of code there.
This makes your circle layer completely opaque, it makes the shape itself unvisible.
circleLayer.backgroundColor = UIColor.yellowColor().CGColor
You shouldn't add circle as a sublayer since you're going to use it as a mask:
progressView.layer.addSublayer(circle)

Resources