How to make a filling progress circle in iOS? - ios

I need to make a progress circle like this:
I have created a CAShapeLayer for the circle, and animate its strokeEnd property like this:
pieShape = CAShapeLayer()
pieShape.path = UIBezierPath(arcCenter: CGPointMake(29, 29), radius: 27.0, startAngle: CGFloat(startAngle), endAngle: CGFloat(endAngle), clockwise: true).CGPath
pieShape.fillColor = UIColor.clearColor().CGColor
pieShape.strokeColor = UIColor.blackColor().CGColor
pieShape.lineWidth = 4.0
self.layer.addSublayer(pieShape)
let countDownAnimation = CABasicAnimation(keyPath: "strokeEnd")
countDownAnimation.duration = durationInSeconds
countDownAnimation.removedOnCompletion = false
countDownAnimation.fromValue = NSNumber(float: 0.0)
countDownAnimation.toValue = NSNumber(float: 1.0)
countDownAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
self.pieShape.strokeStart = 1.0
self.pieShape.addAnimation(countDownAnimation, forKey: "drawCircleAnimation")
But, this not what I want. I need to animate the circle filling, not only the border. Thank you.

The easiest way is to cheat, and animate just the stroke, but make it appear to be animating the fill of the circle. For example, if you wanted a circle of radius 30, use a radius of 15 instead, but a lineWidth of 30, so that the stroke effectively covers from the center to the desired radius of 30.
There are more complicated methods using CADisplayLink, and updating it yourself, but the above is pretty easy way to achieve the desired effect.

Related

Swift: How to set CAShapeLayer() progress value

I just create an IOS app where I implement a round progress bar with CAShapeLayer().
And know I want to set in each second the progress value to +1 is this posible?
I would just like to create a progress bar like this one that leaps forward a value every second:
Like this
Best Regards!
CAShapeLayer has animatable strokeStart and strokeEnd properties. You can directly set these and they will be animated automatically, or you can control the animation yourself like any other animatable CALayer property:
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = 0
animation.toValue = 1
animation.duration = 1
shape.add(animation, forKey: nil)
This will fill the progress indicator in 1 second.
For a stroke end animation to work, you can't use a CGPath which is just a circle - you need an arc with a start and an end, like this:
let path = CGMutablePath()
path.addArc(
center: CGPoint(x: 150, y: 150),
radius: 100,
startAngle: -0.5 * .pi,
endAngle: 1.5 * .pi,
clockwise: false
)

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.

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)

How to get a circle shape layer with one edge as a gradient and the other one as a full color

I have done this circle gradient layer:
What I would like to have is only one gradient (the one on the left) while the gradient on the bottom would be removed to show a clear separation between red and yellow.
As I will need to make an animation out of it (like a loading view), I thought about having an image in the background and having a circle shape layer with a color (like white) on top and change the stroke of this layer as I need.
Another solution I thought about was having tow circle shape layers, one with the gradient, the other one without.
But both those solutions feels more like a hack and I was wondering if there was a proper one using just
Here is the code I used:
fileprivate func createProgressLayer() {
let startAngle = CGFloat(M_PI_2)
let endAngle = CGFloat(M_PI * 2 + M_PI_2)
let centerPoint = CGPoint(x: frame.width / 2 , y: frame.height / 2)
progressLayer.path = UIBezierPath(arcCenter:centerPoint, radius: frame.width / 2 - 30.0, startAngle: startAngle, endAngle: endAngle, clockwise: true).cgPath
progressLayer.backgroundColor = UIColor.clear.cgColor
progressLayer.fillColor = nil
progressLayer.strokeColor = UIColor.black.cgColor
progressLayer.lineWidth = 4.0
progressLayer.strokeStart = 0.0
progressLayer.strokeEnd = 1.0
let gradientMaskLayer = gradientMask()
gradientMaskLayer.mask = progressLayer
layer.addSublayer(gradientMaskLayer)
}
fileprivate func gradientMask() -> CAGradientLayer {
let gradientLayer = CAGradientLayer()
gradientLayer.frame = bounds
gradientLayer.locations = [0.0, 0.1]
gradientLayer.startPoint = CGPoint(x: 0.0, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 0.5, y: 0)
let arrayOfColors: [AnyObject] = [UIColor.red.cgColor, UIColor.yellow.cgColor]
gradientLayer.colors = arrayOfColors
return gradientLayer
}
Shape layers only support a single color.
In order to get the effect that you're after I think you're going to need to use a shape layer as a mask on your gradient like you're doing.
Having a second white shape layer on top of the gradient+mask layer and changing strokeStart and/or strokeEnd also sounds like a reasonable way to do what you're trying to do.
Doing tricks ("hacks" as you call them) with layers is quite common and a reasonable way to do what you're trying to do.

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.

Resources