Create a custom animatable property - iphone

On UIView you can change the backgroundColour animated. And on a UISlideView you can change the value animated.
Can you add a custom property to your own UIView subclass so that it can be animated?
If I have a CGPath within my UIView then I can animate the drawing of it by changing the percentage drawn of the path.
Can I encapsulate that animation into the subclass.
i.e. I have a UIView with a CGPath that creates a circle.
If the circle is not there it represents 0%. If the circle is full it represents 100%. I can draw any value by changing the percentage drawn of the path. I can also animate the change (within the UIView subclass) by animating the percentage of the CGPath and redrawing the path.
Can I set some property (i.e. percentage) on the UIView so that I can stick the change into a UIView animateWithDuration block and it animate the change of the percentage of the path?
I hope I have explained what I would like to do well.
Essentially, all I want to do is something like...
[UIView animateWithDuration:1.0
animations:^{
myCircleView.percentage = 0.7;
}
completion:nil];
and the circle path animate to the given percentage.

If you extend CALayer and implement your custom
- (void) drawInContext:(CGContextRef) context
You can make an animatable property by overriding needsDisplayForKey (in your custom CALayer class) like this:
+ (BOOL) needsDisplayForKey:(NSString *) key {
if ([key isEqualToString:#"percentage"]) {
return YES;
}
return [super needsDisplayForKey:key];
}
Of course, you also need to have a #property called percentage. From now on you can animate the percentage property using core animation. I did not check whether it works using the [UIView animateWithDuration...] call as well. It might work. But this worked for me:
CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:#"percentage"];
animation.duration = 1.0;
animation.fromValue = [NSNumber numberWithDouble:0];
animation.toValue = [NSNumber numberWithDouble:100];
[myCustomLayer addAnimation:animation forKey:#"animatePercentage"];
Oh and to use yourCustomLayer with myCircleView, do this:
[myCircleView.layer addSublayer:myCustomLayer];

Complete Swift 3 example:
public class CircularProgressView: UIView {
public dynamic var progress: CGFloat = 0 {
didSet {
progressLayer.progress = progress
}
}
fileprivate var progressLayer: CircularProgressLayer {
return layer as! CircularProgressLayer
}
override public class var layerClass: AnyClass {
return CircularProgressLayer.self
}
override public func action(for layer: CALayer, forKey event: String) -> CAAction? {
if event == #keyPath(CircularProgressLayer.progress),
let action = action(for: layer, forKey: #keyPath(backgroundColor)) as? CAAnimation,
let animation: CABasicAnimation = (action.copy() as? CABasicAnimation) {
animation.keyPath = #keyPath(CircularProgressLayer.progress)
animation.fromValue = progressLayer.progress
animation.toValue = progress
self.layer.add(animation, forKey: #keyPath(CircularProgressLayer.progress))
return animation
}
return super.action(for: layer, forKey: event)
}
}
/*
* Concepts taken from:
* https://stackoverflow.com/a/37470079
*/
fileprivate class CircularProgressLayer: CALayer {
#NSManaged var progress: CGFloat
let startAngle: CGFloat = 1.5 * .pi
let twoPi: CGFloat = 2 * .pi
let halfPi: CGFloat = .pi / 2
override class func needsDisplay(forKey key: String) -> Bool {
if key == #keyPath(progress) {
return true
}
return super.needsDisplay(forKey: key)
}
override func draw(in ctx: CGContext) {
super.draw(in: ctx)
UIGraphicsPushContext(ctx)
//Light Grey
UIColor.lightGray.setStroke()
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let strokeWidth: CGFloat = 4
let radius = (bounds.size.width / 2) - strokeWidth
let path = UIBezierPath(arcCenter: center, radius: radius, startAngle: 0, endAngle: twoPi, clockwise: true)
path.lineWidth = strokeWidth
path.stroke()
//Red
UIColor.red.setStroke()
let endAngle = (twoPi * progress) - halfPi
let pathProgress = UIBezierPath(arcCenter: center, radius: radius, startAngle: startAngle, endAngle: endAngle , clockwise: true)
pathProgress.lineWidth = strokeWidth
pathProgress.lineCapStyle = .round
pathProgress.stroke()
UIGraphicsPopContext()
}
}
let circularProgress = CircularProgressView(frame: CGRect(x: 0, y: 0, width: 80, height: 80))
UIView.animate(withDuration: 2, delay: 0, options: .curveEaseInOut, animations: {
circularProgress.progress = 0.76
}, completion: nil)
There is a great objc article here, which goes into details about how this works
As well as a objc project that uses the same concepts here:
Essentially action(for layer:) will be called when an object is being animated from an animation block, we can start our own animations with the same properties (stolen from the backgroundColor property) and animate the changes.

For the ones who needs more details on that like I did:
there is a cool example from Apple covering this question.
E.g. thanks to it I found that you don't actually need to add your custom layer as sublayer (as #Tom van Zummeren suggests). Instead it's enough to add a class method to your View class:
+ (Class)layerClass
{
return [CustomLayer class];
}
Hope it helps somebody.

you will have to implement the percentage part yourself. you can override layer drawing code to draw your cgpath accroding to the set percentage value. checkout the core animation programming guide and animation types and timing guide

#David Rees answer get me on the right track, but there is one issue. In my case
completion of animation always returns false, right after animation has began.
UIView.animate(withDuration: 2, delay: 0, options: .curveEaseInOut, animations: {
circularProgress.progress = 0.76
}, completion: { finished in
// finished - always false
})
This is the way it've worked for me - action of animation is handled inside of CALayer.
I have also included small example how to make layer with additional properties like "color".
In this case, without initializer that copies the values, changing the color would take affect only on non-animating view. During animation it would be visble with "default setting".
public class CircularProgressView: UIView {
#objc public dynamic var progress: CGFloat {
get {
return progressLayer.progress
}
set {
progressLayer.progress = newValue
}
}
fileprivate var progressLayer: CircularProgressLayer {
return layer as! CircularProgressLayer
}
override public class var layerClass: AnyClass {
return CircularProgressLayer.self
}
}
/*
* Concepts taken from:
* https://stackoverflow.com/a/37470079
*/
fileprivate class CircularProgressLayer: CALayer {
#NSManaged var progress: CGFloat
let startAngle: CGFloat = 1.5 * .pi
let twoPi: CGFloat = 2 * .pi
let halfPi: CGFloat = .pi / 2
var color: UIColor = .red
// preserve layer properties
// without this specyfic init, if color was changed to sth else
// animation would still use .red
override init(layer: Any) {
super.init(layer: layer)
if let layer = layer as? CircularProgressLayer {
self.color = layer.color
self.progress = layer.progress
}
}
override init() {
super.init()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
override class func needsDisplay(forKey key: String) -> Bool {
if key == #keyPath(progress) {
return true
}
return super.needsDisplay(forKey: key)
}
override func action(forKey event: String) -> CAAction? {
if event == #keyPath(CircularProgressLayer.progress) {
guard let animation = action(forKey: #keyPath(backgroundColor)) as? CABasicAnimation else {
setNeedsDisplay()
return nil
}
if let presentation = presentation() {
animation.keyPath = event
animation.fromValue = presentation.value(forKeyPath: event)
animation.toValue = nil
} else {
return nil
}
return animation
}
return super.action(forKey: event)
}
override func draw(in ctx: CGContext) {
super.draw(in: ctx)
UIGraphicsPushContext(ctx)
//Light Gray
UIColor.lightGray.setStroke()
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let strokeWidth: CGFloat = 4
let radius = (bounds.size.width / 2) - strokeWidth
let path = UIBezierPath(arcCenter: center, radius: radius, startAngle: 0, endAngle: twoPi, clockwise: true)
path.lineWidth = strokeWidth
path.stroke()
// Red - default
self.color.setStroke()
let endAngle = (twoPi * progress) - halfPi
let pathProgress = UIBezierPath(arcCenter: center, radius: radius, startAngle: startAngle, endAngle: endAngle , clockwise: true)
pathProgress.lineWidth = strokeWidth
pathProgress.lineCapStyle = .round
pathProgress.stroke()
UIGraphicsPopContext()
}
}
The way to handle animations differently and copy layer properties I have found in this article:
https://medium.com/better-programming/make-apis-like-apple-animatable-view-properties-in-swift-4349b2244cea

Related

Why are these CAShapeLayers not going where expected?

I'm working on a custom loading indicator and am having a lot of issues with CAShapeLayers.
The loading indicator will be contained within a custom UIView so that any viewController can use it.
First issue:
The frame of the subview is not matching the bounds.
When using this code to display a circle in each corner of the frame the circles are placed in a square shape but it is no where near the view.
import UIKit
View Controller:
class MergingCicles: UIViewController, HolderViewDelegate {
func animateLabel() {
}
var holderView = HolderView(frame: CGRect.zero)
override func viewDidLoad() {
super.viewDidLoad()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
addHolderView()
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
}
func addHolderView() {
let boxSize: CGFloat = 100.0
holderView.frame = CGRect(x: view.bounds.width / 2 - boxSize / 2,
y: view.bounds.height / 2 - boxSize / 2,
width: boxSize,
height: boxSize)
holderView.parentFrame = view.frame
holderView.delegate = self
holderView.center = self.view.center
view.addSubview(holderView)
holderView.addCircleLayer()
}
}
Subview:
Import UIKit
protocol HolderViewDelegate:class {
func animateLabel()
}
class HolderView: UIView {
let initalLayer = InitialLayer()
let triangleLayer = TriangleLayer()
let redRectangleLayer = RectangleLayer()
let blueRectangleLayer = RectangleLayer()
let arcLayer = ArcLayer()
var parentFrame :CGRect = CGRect.zero
weak var delegate:HolderViewDelegate?
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor.clear
}
required init(coder: NSCoder) {
super.init(coder: coder)!
}
func addCircleLayer() {
var circleLocations = [CGPoint]()
let offset = CircleLayer().maxSize / 2
circleLocations.append(CGPoint(x: self.frame.maxX - offset, y: self.frame.maxY - offset))
circleLocations.append(CGPoint(x: self.frame.minX + offset, y: self.frame.minY + offset))
circleLocations.append(CGPoint(x: self.frame.maxX - offset, y: self.frame.minY + offset))
circleLocations.append(CGPoint(x: self.frame.minX + offset, y: self.frame.maxY - offset))
circleLocations.append(layer.anchorPoint)
for point in circleLocations {
let circle = CircleLayer()
circle.updateLocation(Size: .medium, center: point)
self.layer.addSublayer(circle)
}
self.backgroundColor = .blue
// layer.anchorPoint = CGPoint(x: (self.bounds.maxX + self.bounds.maxX)/2, y: (self.bounds.maxY + self.bounds.minY)/2)
let rotationAnimation: CABasicAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotationAnimation.toValue = CGFloat(Double.pi * 2)
rotationAnimation.duration = 0.45
rotationAnimation.isCumulative = true
//rotationAnimation.repeatCount = 1000
//rotationAnimation.isRemovedOnCompletion = true
// layer.add(rotationAnimation, forKey: nil)
}
}
Circle Layer:
import Foundation
import UIKit
enum ShapeSize {
case medium
case small
case large
}
class CircleLayer: CAShapeLayer {
let animationDuration: CFTimeInterval = 0.3
let maxSize = CGFloat(50)
override init() {
super.init()
fillColor = UIColor.red.cgColor
}
func updateLocation(Size: ShapeSize, center: CGPoint){
switch Size {
case .medium:
path = UIBezierPath(ovalIn: CGRect(x: center.x, y: center.y, width: maxSize/3, height: maxSize/3)).cgPath
case .small:
path = UIBezierPath(ovalIn: CGRect(x: center.x, y: center.y, width: (2*maxSize)/3, height: (2*maxSize)/3)).cgPath
case .large:
path = UIBezierPath(ovalIn: CGRect(x: center.x, y: center.y, width: maxSize, height: maxSize)).cgPath
}
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
Result:
This really shows that the frame is no where near the uiView.
If I change addCircleLayer to use bounds instead I get something much closer:
But still the circles are not in the corners (except the bottom right one, that one is correct). It appears there is some extra space on the left and top of the view that is not captured using self.bounds.
The ultimate goal is to also rotate the circles 360 degrees around the center but as shown by the circle in the upper left corner the layer anchor is not in the center of the view, I changed the anchor to be the center of the circles but then nothing appeared on screen at all.
You're saying things like
circleLocations.append(CGPoint(x: self.frame.maxX - offset, y: self.frame.maxY - offset))
But self.frame is where the view is located in its own superview. Thus, the shape layer ends up offset from the view by as much as the view is offset from its own superview. Wherever you say frame here, you mean bounds.
I found the problem was then when drawing the circles I was using UIBezierPath(ovalIn:CGRect, width: CGFloat, height: CGFloat which was using the x value for the left side of the circle. When I changed to UIBezierPath(arcCenter: CGPoint, radius: CGFloat, startAngle: CGFloat, endAngle: CGFloat, clockwise: Bool) the point was used for the center of the circle and made it all fit where expected when using self.bounds to calculate the points.
After that I no longer had to change the anchor point as it was in the correct location by default.
I didn't figure out why the frame is in a completely different spot but it is no longer impacting the project.

Change color gradient around donut view

I am trying to make an animated donut view that when given a value between 0 and 100 it will animate round the view up to that number. I have this working fine but want to fade the color from one to another, then another on the way around. Currently, when I add my gradient it goes from left to right and not around the circumference of the donut view.
class CircleScoreView: UIView {
private let outerCircleLayer = CAShapeLayer()
private let outerCircleGradientLayer = CAGradientLayer()
private let outerCircleLineWidth: CGFloat = 5
override init(frame: CGRect) {
super.init(frame: .zero)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
buildLayers()
}
/// Value must be within 0...100 range
func setScore(_ value: Int, animated: Bool = false) {
if value != 0 {
let clampedValue: CGFloat = CGFloat(value.clamped(to: 0...100)) / 100
if !animated {
outerCircleLayer.strokeEnd = clampedValue
} else {
let outerCircleAnimation = CABasicAnimation(keyPath: "strokeEnd")
outerCircleAnimation.duration = 1.0
outerCircleAnimation.fromValue = 0
outerCircleAnimation.toValue = clampedValue
outerCircleAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
outerCircleLayer.strokeEnd = clampedValue
outerCircleLayer.add(outerCircleAnimation, forKey: "outerCircleAnimation")
}
outerCircleGradientLayer.colors = [Constant.Palette.CircleScoreView.startValue.cgColor,
Constant.Palette.CircleScoreView.middleValue.cgColor,
Constant.Palette.CircleScoreView.endValue.cgColor]
}
}
private func buildLayers() {
// Outer background circle
let arcCenter = CGPoint(x: frame.size.width / 2, y: frame.size.height / 2)
let startAngle = CGFloat(-0.5 * Double.pi)
let endAngle = CGFloat(1.5 * Double.pi)
let circlePath = UIBezierPath(arcCenter: arcCenter,
radius: (frame.size.width - outerCircleLineWidth) / 2,
startAngle: startAngle,
endAngle: endAngle,
clockwise: true)
// Outer circle
setupOuterCircle(outerCirclePath: circlePath)
}
private func setupOuterCircle(outerCirclePath: UIBezierPath) {
outerCircleLayer.path = outerCirclePath.cgPath
outerCircleLayer.fillColor = UIColor.clear.cgColor
outerCircleLayer.strokeColor = UIColor.black.cgColor
outerCircleLayer.lineWidth = outerCircleLineWidth
outerCircleLayer.lineCap = CAShapeLayerLineCap.round
outerCircleGradientLayer.startPoint = CGPoint(x: 0.0, y: 0.5)
outerCircleGradientLayer.endPoint = CGPoint(x: 1.0, y: 0.5)
outerCircleGradientLayer.frame = bounds
outerCircleGradientLayer.mask = outerCircleLayer
layer.addSublayer(outerCircleGradientLayer)
}
}
I am going for something like this but the color isn't one block but gradients around the donut view from one color to the next.
If you imported AngleGradientLayer into your project then all you should need to do is change:
private let outerCircleGradientLayer = CAGradientLayer() to
private let outerCircleGradientLayer = AngleGradientLayer()

Draw only half of circle

I want to draw only the left or right half of the circle.
I can draw the circle from 0 to 0.5 (right side) with no problems. But drawing the circle from 0.5 to 1 (left side) doesn't work.
call:
addCircleView(circle, isForeground: false, duration: 0.0, fromValue: 0.5, toValue: 1.0)
This is my code:
func addCircleView( myView : UIView, isForeground : Bool, duration : NSTimeInterval, fromValue: CGFloat, toValue : CGFloat ) -> CircleView {
let circleWidth = CGFloat(280)
let circleHeight = circleWidth
// Create a new CircleView
let circleView = CircleView(frame: CGRectMake(0, 0, circleWidth, circleHeight))
//Setting the color.
if (isForeground == true) {
circleView.setStrokeColor((UIColor(hexString: "#ffefdb")?.CGColor)!)
} else {
// circleLayer.strokeColor = UIColor.grayColor().CGColor
//Chose to use hexes because it's much easier.
circleView.setStrokeColor((UIColor(hexString: "#51acbc")?.CGColor)!)
}
myView.addSubview(circleView)
//Rotate the circle so it starts from the top.
circleView.transform = CGAffineTransformMakeRotation(-1.56)
// Animate the drawing of the circle
circleView.animateCircleTo(duration, fromValue: fromValue, toValue: toValue)
return circleView
}
Circle view class
import UIKit
extension UIColor {
/// UIColor(hexString: "#cc0000")
internal convenience init?(hexString:String) {
guard hexString.characters[hexString.startIndex] == Character("#") else {
return nil
}
guard hexString.characters.count == "#000000".characters.count else {
return nil
}
let digits = hexString.substringFromIndex(hexString.startIndex.advancedBy(1))
guard Int(digits,radix:16) != nil else{
return nil
}
let red = digits.substringToIndex(digits.startIndex.advancedBy(2))
let green = digits.substringWithRange(Range<String.Index>(digits.startIndex.advancedBy(2)..<digits.startIndex.advancedBy(4)))
let blue = digits.substringWithRange(Range<String.Index>(digits.startIndex.advancedBy(4)..<digits.startIndex.advancedBy(6)))
let redf = CGFloat(Double(Int(red, radix:16)!) / 255.0)
let greenf = CGFloat(Double(Int(green, radix:16)!) / 255.0)
let bluef = CGFloat(Double(Int(blue, radix:16)!) / 255.0)
self.init(red: redf, green: greenf, blue: bluef, alpha: CGFloat(1.0))
}
}
class CircleView: UIView {
var circleLayer: CAShapeLayer!
var from : CGFloat = 0.0;
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = UIColor.clearColor()
// Use UIBezierPath as an easy way to create the CGPath for the layer.
// The path should be the entire circle.
let circlePath = UIBezierPath(arcCenter: CGPoint(x: frame.size.width / 2.0, y: frame.size.height / 2.0), radius: (frame.size.width - 10)/2, startAngle: 0.0, endAngle: CGFloat(M_PI * 2.0), clockwise: true)
// Setup the CAShapeLayer with the path, colors, and line width
circleLayer = CAShapeLayer()
circleLayer.path = circlePath.CGPath
circleLayer.fillColor = UIColor.clearColor().CGColor
//I'm going to change this in the ViewController that uses this. Not the best way, I know but alas.
circleLayer.strokeColor = UIColor.redColor().CGColor
//You probably want to change this width
circleLayer.lineWidth = 3.0;
// Don't draw the circle initially
circleLayer.strokeEnd = 0.0
// Add the circleLayer to the view's layer's sublayers
layer.addSublayer(circleLayer)
}
func setStrokeColor(color : CGColorRef) {
circleLayer.strokeColor = color
}
// This is what you call if you want to draw a full circle.
func animateCircle(duration: NSTimeInterval) {
animateCircleTo(duration, fromValue: 0.0, toValue: 1.0)
}
// This is what you call to draw a partial circle.
func animateCircleTo(duration: NSTimeInterval, fromValue: CGFloat, toValue: CGFloat){
// We want to animate the strokeEnd property of the circleLayer
let animation = CABasicAnimation(keyPath: "strokeEnd")
// Set the animation duration appropriately
animation.duration = duration
// Animate from 0 (no circle) to 1 (full circle)
animation.fromValue = 0
animation.toValue = toValue
// Do an easeout. Don't know how to do a spring instead
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
// Set the circleLayer's strokeEnd property to 1.0 now so that it's the
// right value when the animation ends.
circleLayer.strokeEnd = toValue
// Do the actual animation
circleLayer.addAnimation(animation, forKey: "animateCircle")
}
// required function
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
You need to set:
circleLayer.strokeStart = fromValue
in the animateCircleTo(duration...) function.
You set the end of the stroke, but not the beginning. Consequently, all circle animations begin from 0.0, even if you intend them to begin at a later part of the stroke.
Like this:
// This is what you call to draw a partial circle.
func animateCircleTo(duration: NSTimeInterval, fromValue: CGFloat, toValue: CGFloat){
// We want to animate the strokeEnd property of the circleLayer
let animation = CABasicAnimation(keyPath: "strokeEnd")
// Set the animation duration appropriately
animation.duration = duration
// Animate from 0 (no circle) to 1 (full circle)
animation.fromValue = 0
animation.toValue = toValue
// Do an easeout. Don't know how to do a spring instead
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
// Set the circleLayer's strokeEnd property to 1.0 now so that it's the
// right value when the animation ends.
circleLayer.strokeStart = fromValue
circleLayer.strokeEnd = toValue
// Do the actual animation
circleLayer.addAnimation(animation, forKey: "animateCircle")
}
The easiest way is to mask or clip out the half of the circle you don't want.
class HalfCircleView : UIView {
override func draw(_ rect: CGRect) {
let p = UIBezierPath(rect: CGRect(x: 50, y: 0, width: 50, height: 100))
p.addClip()
let p2 = UIBezierPath(ovalIn: CGRect(x: 10, y: 10, width: 80, height: 80))
p2.lineWidth = 2
p2.stroke()
}
}
Of course, where you put the clipping is where the circle half will appear. And once you have the half-circle view of course you can rotate it etc.
In swift ui. Draws a clock wise arc, with a progress:
This is the implementation:
struct ArcShape: Shape {
var width: CGFloat
var height: CGFloat
var progress: Double
func path(in rect: CGRect) -> Path {
let bezierPath = UIBezierPath()
let endAngle = 360.0 * progress - 90.0
bezierPath.addArc(withCenter: CGPoint(x: width / 2, y: height / 2),
radius: width / 3,
startAngle: CGFloat(-90 * Double.pi / 180),
endAngle: CGFloat(endAngle * Double.pi / 180),
clockwise: true)
return Path(bezierPath.cgPath)
}
}
Usage:
ArcShape(width: geometry.size.width, height: geometry.size.height, progress: 0.75)
.stroke(lineWidth: 5)
.fill(Color(R.color.colorBlue.name))
.frame(width: geometry.size.width , height: geometry.size.height)

Continuous Rotation of NSImageView (so it appears to be animated)

SWIFT - OSX
I have a bunch of imageViews set in my Main.storyboard. I am trying to make them spin when the app starts and i would like them to indefinitely. I came across the roateByAngle(angle: CGFloat), but this doesn't animate it, instead it just jumps to the new angle.
I would like to make two functions, spinClockwise() and spinAntiClockwise() so I can just call them in the viewDidLoad and they will just keep turning.
Ive been playing with CATransform3DMakeRotation but cannot seem to get my desired results
let width = myImg.frame.width / 2
let height = myImg.frame.height / 2
myImg.layer?.transform = CATransform3DMakeRotation(180, width, height, 1)
Let me know if i can be more specific.
Thanks
You could add an extension of UIView or UIImageView like this:
extension UIView {
///The less is the timeToRotate, the more fast the animation is !
func spinClockwise(timeToRotate: Double) {
startRotate(CGFloat(M_PI_2), timeToRotate: timeToRotate)
}
///The less is the timeToRotate, the more fast the animation is !
func spinAntiClockwise(timeToRotate: Double) {
startRotate(CGFloat(-M_PI_2), timeToRotate: timeToRotate)
}
func startRotate(angle: CGFloat, timeToRotate: Double) {
UIView.animateWithDuration(timeToRotate, delay: 0.0, options:[UIViewAnimationOptions.CurveLinear, UIViewAnimationOptions.Repeat], animations: {
self.transform = CGAffineTransformMakeRotation(angle)
}, completion: nil)
print("Start rotating")
}
func stopAnimations() {
self.layer.removeAllAnimations()
print("Stop rotating")
}
}
So when you want to rotate your myImg, you just have to call:
myImg.spinClockwise(3)
And when you want to stop it:
myImg.stopAnimations()
NOTE:
I added a playground just so you can test it out ;)
Cheers!
EDIT:
My bad, Here is the example for NSView:
extension NSView {
///The less is the timeToRotate, the more fast the animation is !
func spinClockwise(timeToRotate: Double) {
startRotate(CGFloat(-1 * M_PI * 2.0), timeToRotate: timeToRotate)
}
///The less is the timeToRotate, the more fast the animation is !
func spinAntiClockwise(timeToRotate: Double) {
startRotate(CGFloat(M_PI * 2.0), timeToRotate: timeToRotate)
}
func startRotate(angle: CGFloat, timeToRotate: Double) {
let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation")
rotateAnimation.fromValue = 0.0
rotateAnimation.toValue = angle
rotateAnimation.duration = timeToRotate
rotateAnimation.repeatCount = .infinity
self.layer?.addAnimation(rotateAnimation, forKey: nil)
Swift.print("Start rotating")
}
func stopAnimations() {
self.layer?.removeAllAnimations()
Swift.print("Stop rotating")
}
}
Important note: Now, after my tests, I noticed that you must set the anchor point of your NSView in the middle so that it can rotate around its center:
view.layer?.anchorPoint = CGPointMake(0.5, 0.5)
I added a new playground with the OSX example
For me, I could not change the anchor point. It was spinning around (0,0) which is bottom left. I moved the anchor point to (0.5, 0.5), but still no luck. Then I came accross with this answer. I modified my code like below, and it begins to rotate around itself. I observed a drawback though, the place of the view somehow shifted, but it can be fixed by trial and error, trying to get it to the right place.
extension NSView {
func startRotating(duration: Double = 1) {
let kAnimationKey = "rotation"
//self.wantsLayer = true
if layer?.animation(forKey: kAnimationKey) == nil {
var oldFrame = self.frame
self.layer?.anchorPoint = CGPoint(x: 1, y: 1)
self.frame = oldFrame
let animate = CABasicAnimation(keyPath: "transform.rotation")
animate.duration = duration
animate.repeatCount = Float.infinity
animate.fromValue = 0.0
animate.toValue = Double.pi * 2.0
self.layer?.add(animate, forKey: kAnimationKey)
oldFrame = self.frame
self.layer?.anchorPoint = CGPoint(x: 0.5, y: 0.5)
self.frame = oldFrame
}
}
}

Animate drawing of a circle

I'm looking for a way to animate the drawing of a circle. I have been able to create the circle, but it draws it all together.
Here is my CircleView class:
import UIKit
class CircleView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = UIColor.clearColor()
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func drawRect(rect: CGRect) {
// Get the Graphics Context
var context = UIGraphicsGetCurrentContext();
// Set the circle outerline-width
CGContextSetLineWidth(context, 5.0);
// Set the circle outerline-colour
UIColor.redColor().set()
// Create Circle
CGContextAddArc(context, (frame.size.width)/2, frame.size.height/2, (frame.size.width - 10)/2, 0.0, CGFloat(M_PI * 2.0), 1)
// Draw
CGContextStrokePath(context);
}
}
And here is how I add it to the view hierarchy in my view controller:
func addCircleView() {
let diceRoll = CGFloat(Int(arc4random_uniform(7))*50)
var circleWidth = CGFloat(200)
var circleHeight = circleWidth
// Create a new CircleView
var circleView = CircleView(frame: CGRectMake(diceRoll, 0, circleWidth, circleHeight))
view.addSubview(circleView)
}
Is there a way to animate the drawing of the circle over 1 second?
Example, part way through the animation it would look something like the blue line in this image:
The easiest way to do this is to use the power of core animation to do most of the work for you. To do that, we'll have to move your circle drawing code from your drawRect function to a CAShapeLayer. Then, we can use a CABasicAnimation to animate CAShapeLayer's strokeEnd property from 0.0 to 1.0. strokeEnd is a big part of the magic here; from the docs:
Combined with the strokeStart property, this property defines the
subregion of the path to stroke. The value in this property indicates
the relative point along the path at which to finish stroking while
the strokeStart property defines the starting point. A value of 0.0
represents the beginning of the path while a value of 1.0 represents
the end of the path. Values in between are interpreted linearly along
the path length.
If we set strokeEnd to 0.0, it won't draw anything. If we set it to 1.0, it'll draw a full circle. If we set it to 0.5, it'll draw a half circle. etc.
So, to start, lets create a CAShapeLayer in your CircleView's init function and add that layer to the view's sublayers (also be sure to remove the drawRect function since the layer will be drawing the circle now):
let circleLayer: CAShapeLayer!
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = UIColor.clearColor()
// Use UIBezierPath as an easy way to create the CGPath for the layer.
// The path should be the entire circle.
let circlePath = UIBezierPath(arcCenter: CGPoint(x: frame.size.width / 2.0, y: frame.size.height / 2.0), radius: (frame.size.width - 10)/2, startAngle: 0.0, endAngle: CGFloat(Double.pi * 2.0), clockwise: true)
// Setup the CAShapeLayer with the path, colors, and line width
circleLayer = CAShapeLayer()
circleLayer.path = circlePath.CGPath
circleLayer.fillColor = UIColor.clearColor().CGColor
circleLayer.strokeColor = UIColor.redColor().CGColor
circleLayer.lineWidth = 5.0;
// Don't draw the circle initially
circleLayer.strokeEnd = 0.0
// Add the circleLayer to the view's layer's sublayers
layer.addSublayer(circleLayer)
}
Note: We're setting circleLayer.strokeEnd = 0.0 so that the circle isn't drawn right away.
Now, lets add a function that we can call to trigger the circle animation:
func animateCircle(duration: NSTimeInterval) {
// We want to animate the strokeEnd property of the circleLayer
let animation = CABasicAnimation(keyPath: #keyPath(CAShapeLayer.strokeEnd))
// Set the animation duration appropriately
animation.duration = duration
// Animate from 0 (no circle) to 1 (full circle)
animation.fromValue = 0
animation.toValue = 1
// Do a linear animation (i.e. the speed of the animation stays the same)
animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear)
// Set the circleLayer's strokeEnd property to 1.0 now so that it's the
// right value when the animation ends.
circleLayer.strokeEnd = 1.0
// Do the actual animation
circleLayer.add(animation, forKey: "animateCircle")
}
Then, all we need to do is change your addCircleView function so that it triggers the animation when you add the CircleView to its superview:
func addCircleView() {
let diceRoll = CGFloat(Int(arc4random_uniform(7))*50)
var circleWidth = CGFloat(200)
var circleHeight = circleWidth
// Create a new CircleView
var circleView = CircleView(frame: CGRectMake(diceRoll, 0, circleWidth, circleHeight))
view.addSubview(circleView)
// Animate the drawing of the circle over the course of 1 second
circleView.animateCircle(1.0)
}
All that put together should look something like this:
Note: It won't repeat like that, it'll stay a full circle after it animates.
Mikes answer updated for Swift 3.0
var circleLayer: CAShapeLayer!
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = UIColor.clear
// Use UIBezierPath as an easy way to create the CGPath for the layer.
// The path should be the entire circle.
let circlePath = UIBezierPath(arcCenter: CGPoint(x: frame.size.width / 2.0, y: frame.size.height / 2.0), radius: (frame.size.width - 10)/2, startAngle: 0.0, endAngle: CGFloat(M_PI * 2.0), clockwise: true)
// Setup the CAShapeLayer with the path, colors, and line width
circleLayer = CAShapeLayer()
circleLayer.path = circlePath.cgPath
circleLayer.fillColor = UIColor.clear.cgColor
circleLayer.strokeColor = UIColor.red.cgColor
circleLayer.lineWidth = 5.0;
// Don't draw the circle initially
circleLayer.strokeEnd = 0.0
// Add the circleLayer to the view's layer's sublayers
layer.addSublayer(circleLayer)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func animateCircle(duration: TimeInterval) {
// We want to animate the strokeEnd property of the circleLayer
let animation = CABasicAnimation(keyPath: "strokeEnd")
// Set the animation duration appropriately
animation.duration = duration
// Animate from 0 (no circle) to 1 (full circle)
animation.fromValue = 0
animation.toValue = 1
// Do a linear animation (i.e The speed of the animation stays the same)
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
// Set the circleLayer's strokeEnd property to 1.0 now so that it's the
// Right value when the animation ends
circleLayer.strokeEnd = 1.0
// Do the actual animation
circleLayer.add(animation, forKey: "animateCircle")
}
To call the function:
func addCircleView() {
let diceRoll = CGFloat(Int(arc4random_uniform(7))*50)
var circleWidth = CGFloat(200)
var circleHeight = circleWidth
// Create a new CircleView
let circleView = CircleView(frame: CGRect(x: diceRoll, y: 0, width: circleWidth, height: circleHeight))
//let test = CircleView(frame: CGRect(x: diceRoll, y: 0, width: circleWidth, height: circleHeight))
view.addSubview(circleView)
// Animate the drawing of the circle over the course of 1 second
circleView.animateCircle(duration: 1.0)
}
Mike's answer is great! Another nice and simple way to do it is use drawRect combined with setNeedsDisplay(). It seems laggy, but its not :-)
We want to draw a circle starting from the top, which is -90° and ends at 270°. The circle's center is (centerX, centerY), with a given radius. CurrentAngle is the current angle of the end-point of the circle, going from minAngle (-90) to maxAngle (270).
// MARK: Properties
let centerX:CGFloat = 55
let centerY:CGFloat = 55
let radius:CGFloat = 50
var currentAngle:Float = -90
let minAngle:Float = -90
let maxAngle:Float = 270
In drawRect, we specify how the circle is supposed to display :
override func drawRect(rect: CGRect) {
let context = UIGraphicsGetCurrentContext()
let path = CGPathCreateMutable()
CGPathAddArc(path, nil, centerX, centerY, radius, CGFloat(GLKMathDegreesToRadians(minAngle)), CGFloat(GLKMathDegreesToRadians(currentAngle)), false)
CGContextAddPath(context, path)
CGContextSetStrokeColorWithColor(context, UIColor.blueColor().CGColor)
CGContextSetLineWidth(context, 3)
CGContextStrokePath(context)
}
The problem is that right now, as currentAngle is not changing, the circle is static, and doesn't even show, as currentAngle = minAngle.
We then create a timer, and whenever that timer fires, we increase currentAngle. At the top of your class, add the timing between two fires :
let timeBetweenDraw:CFTimeInterval = 0.01
In your init, add the timer :
NSTimer.scheduledTimerWithTimeInterval(timeBetweenDraw, target: self, selector: #selector(updateTimer), userInfo: nil, repeats: true)
We can add the function that will be called when the timer fires :
func updateTimer() {
if currentAngle < maxAngle {
currentAngle += 1
}
}
Sadly, when running the app, nothing displays because we did not specify the system that it should draw again. This is done by calling setNeedsDisplay(). Here is the updated timer function :
func updateTimer() {
if currentAngle < maxAngle {
currentAngle += 1
setNeedsDisplay()
}
}
_
_
_
All the code you need is summed-up here :
import UIKit
import GLKit
class CircleClosing: UIView {
// MARK: Properties
let centerX:CGFloat = 55
let centerY:CGFloat = 55
let radius:CGFloat = 50
var currentAngle:Float = -90
let timeBetweenDraw:CFTimeInterval = 0.01
// MARK: Init
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setup()
}
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
func setup() {
self.backgroundColor = UIColor.clearColor()
NSTimer.scheduledTimerWithTimeInterval(timeBetweenDraw, target: self, selector: #selector(updateTimer), userInfo: nil, repeats: true)
}
// MARK: Drawing
func updateTimer() {
if currentAngle < 270 {
currentAngle += 1
setNeedsDisplay()
}
}
override func drawRect(rect: CGRect) {
let context = UIGraphicsGetCurrentContext()
let path = CGPathCreateMutable()
CGPathAddArc(path, nil, centerX, centerY, radius, -CGFloat(M_PI/2), CGFloat(GLKMathDegreesToRadians(currentAngle)), false)
CGContextAddPath(context, path)
CGContextSetStrokeColorWithColor(context, UIColor.blueColor().CGColor)
CGContextSetLineWidth(context, 3)
CGContextStrokePath(context)
}
}
If you want to change the speed, just modify the updateTimer function, or the rate at which this function is called. Also, you might want to invalidate the timer once the circle is complete, which I forgot to do :-)
NB: To add the circle in your storyboard, just add a view, select it, go to its Identity Inspector, and as Class, specify CircleClosing.
Cheers! bRo
If you want a completion handler, this is another solution similar to the one by Mike S, done in Swift 3.0
func animateCircleFull(duration: TimeInterval) {
CATransaction.begin()
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.duration = duration
animation.fromValue = 0
animation.toValue = 1
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
circleLayer.strokeEnd = 1.0
CATransaction.setCompletionBlock {
print("animation complete")
}
// Do the actual animation
circleLayer.add(animation, forKey: "animateCircle")
CATransaction.commit()
}
With the completion handler, you can run the animation again either by recursively calling the same function to do the animation over again (which won't look very nice), or you can have a reversed function that will continuously chain until a condition is met, for example:
func animate(duration: TimeInterval){
self.isAnimating = true
self.animateCircleFull(duration: 1)
}
func endAnimate(){
self.isAnimating = false
}
func animateCircleFull(duration: TimeInterval) {
if self.isAnimating{
CATransaction.begin()
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.duration = duration
animation.fromValue = 0
animation.toValue = 1
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
circleLayer.strokeEnd = 1.0
CATransaction.setCompletionBlock {
self.animateCircleEmpty(duration: duration)
}
// Do the actual animation
circleLayer.add(animation, forKey: "animateCircle")
CATransaction.commit()
}
}
func animateCircleEmpty(duration: TimeInterval){
if self.isAnimating{
CATransaction.begin()
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.duration = duration
animation.fromValue = 1
animation.toValue = 0
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
circleLayer.strokeEnd = 0
CATransaction.setCompletionBlock {
self.animateCircleFull(duration: duration)
}
// Do the actual animation
circleLayer.add(animation, forKey: "animateCircle")
CATransaction.commit()
}
}
To make it even fancier, you can change the direction of the animation like this:
func setCircleClockwise(){
let circlePath = UIBezierPath(arcCenter: CGPoint(x: frame.size.width / 2.0, y: frame.size.height / 2.0), radius: (frame.size.width - 10)/2, startAngle: 0.0, endAngle: CGFloat(M_PI * 2.0), clockwise: true)
self.circleLayer.removeFromSuperlayer()
self.circleLayer = formatCirle(circlePath: circlePath)
self.layer.addSublayer(self.circleLayer)
}
func setCircleCounterClockwise(){
let circlePath = UIBezierPath(arcCenter: CGPoint(x: frame.size.width / 2.0, y: frame.size.height / 2.0), radius: (frame.size.width - 10)/2, startAngle: 0.0, endAngle: CGFloat(M_PI * 2.0), clockwise: false)
self.circleLayer.removeFromSuperlayer()
self.circleLayer = formatCirle(circlePath: circlePath)
self.layer.addSublayer(self.circleLayer)
}
func formatCirle(circlePath: UIBezierPath) -> CAShapeLayer{
let circleShape = CAShapeLayer()
circleShape.path = circlePath.cgPath
circleShape.fillColor = UIColor.clear.cgColor
circleShape.strokeColor = UIColor.red.cgColor
circleShape.lineWidth = 10.0;
circleShape.strokeEnd = 0.0
return circleShape
}
func animate(duration: TimeInterval){
self.isAnimating = true
self.animateCircleFull(duration: 1)
}
func endAnimate(){
self.isAnimating = false
}
func animateCircleFull(duration: TimeInterval) {
if self.isAnimating{
CATransaction.begin()
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.duration = duration
animation.fromValue = 0
animation.toValue = 1
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
circleLayer.strokeEnd = 1.0
CATransaction.setCompletionBlock {
self.setCircleCounterClockwise()
self.animateCircleEmpty(duration: duration)
}
// Do the actual animation
circleLayer.add(animation, forKey: "animateCircle")
CATransaction.commit()
}
}
func animateCircleEmpty(duration: TimeInterval){
if self.isAnimating{
CATransaction.begin()
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.duration = duration
animation.fromValue = 1
animation.toValue = 0
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
circleLayer.strokeEnd = 0
CATransaction.setCompletionBlock {
self.setCircleClockwise()
self.animateCircleFull(duration: duration)
}
// Do the actual animation
circleLayer.add(animation, forKey: "animateCircle")
CATransaction.commit()
}
}
updating #Mike S's answer for Swift 5
works for frame manually、 storyboard setup、 autolayout setup
class CircleView: UIView {
let circleLayer: CAShapeLayer = {
// Setup the CAShapeLayer with the path, colors, and line width
let circle = CAShapeLayer()
circle.fillColor = UIColor.clear.cgColor
circle.strokeColor = UIColor.red.cgColor
circle.lineWidth = 5.0
// Don't draw the circle initially
circle.strokeEnd = 0.0
return circle
}()
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setup()
}
func setup(){
backgroundColor = UIColor.clear
// Add the circleLayer to the view's layer's sublayers
layer.addSublayer(circleLayer)
}
override func layoutSubviews() {
super.layoutSubviews()
// Use UIBezierPath as an easy way to create the CGPath for the layer.
// The path should be the entire circle.
let circlePath = UIBezierPath(arcCenter: CGPoint(x: frame.size.width / 2.0, y: frame.size.height / 2.0), radius: (frame.size.width - 10)/2, startAngle: 0.0, endAngle: CGFloat(Double.pi * 2.0), clockwise: true)
circleLayer.path = circlePath.cgPath
}
func animateCircle(duration t: TimeInterval) {
// We want to animate the strokeEnd property of the circleLayer
let animation = CABasicAnimation(keyPath: "strokeEnd")
// Set the animation duration appropriately
animation.duration = t
// Animate from 0 (no circle) to 1 (full circle)
animation.fromValue = 0
animation.toValue = 1
// Do a linear animation (i.e. the speed of the animation stays the same)
animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear)
// Set the circleLayer's strokeEnd property to 1.0 now so that it's the
// right value when the animation ends.
circleLayer.strokeEnd = 1.0
// Do the actual animation
circleLayer.add(animation, forKey: "animateCircle")
}
}
Usage :
sample code for frame manually、 storyboard setup、 autolayout setup
class ViewController: UIViewController {
#IBOutlet weak var circleV: CircleView!
override func viewDidLoad() {
super.viewDidLoad()
}
#IBAction func animateFrame(_ sender: UIButton) {
let diceRoll = CGFloat(Int(arc4random_uniform(7))*30)
let circleEdge = CGFloat(200)
// Create a new CircleView
let circleView = CircleView(frame: CGRect(x: 50, y: diceRoll, width: circleEdge, height: circleEdge))
view.addSubview(circleView)
// Animate the drawing of the circle over the course of 1 second
circleView.animateCircle(duration: 1.0)
}
#IBAction func animateAutolayout(_ sender: UIButton) {
let circleView = CircleView(frame: CGRect.zero)
circleView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(circleView)
circleView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
circleView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
circleView.widthAnchor.constraint(equalToConstant: 250).isActive = true
circleView.heightAnchor.constraint(equalToConstant: 250).isActive = true
// Animate the drawing of the circle over the course of 1 second
circleView.animateCircle(duration: 1.0)
}
#IBAction func animateStoryboard(_ sender: UIButton) {
// Animate the drawing of the circle over the course of 1 second
circleV.animateCircle(duration: 1.0)
}
}
No only you can subclass an UIView, you can also go slightly deeper, subclass an CALayer
In other words, CoreAnimation's strokeEnd is OK. To call CALayer's draw(in ctx:) frequently is also OK
and the round line cap is nice
The key point is to override CALayer's method action(forKey:)
Actions define dynamic behaviors for a layer. For example, the animatable properties of a layer typically have corresponding action objects to initiate the actual animations. When that property changes, the layer looks for the action object associated with the property name and executes it.
The internal subclass for CAShapeLayer
/**
The internal subclass for CAShapeLayer.
This is the class that handles all the drawing and animation.
This class is not interacted with, instead
properties are set in UICircularRing
*/
class UICircularRingLayer: CAShapeLayer {
// MARK: Properties
#NSManaged var val: CGFloat
let ringWidth: CGFloat = 20
let startAngle = CGFloat(-90).rads
// MARK: Init
override init() {
super.init()
}
override init(layer: Any) {
guard let layer = layer as? UICircularRingLayer else { fatalError("unable to copy layer") }
super.init(layer: layer)
}
required init?(coder aDecoder: NSCoder) { return nil }
// MARK: Draw
/**
Override for custom drawing.
Draws the ring
*/
override func draw(in ctx: CGContext) {
super.draw(in: ctx)
UIGraphicsPushContext(ctx)
// Draw the rings
drawRing(in: ctx)
UIGraphicsPopContext()
}
// MARK: Animation methods
/**
Watches for changes in the val property, and setNeedsDisplay accordingly
*/
override class func needsDisplay(forKey key: String) -> Bool {
if key == "val" {
return true
} else {
return super.needsDisplay(forKey: key)
}
}
/**
Creates animation when val property is changed
*/
override func action(forKey event: String) -> CAAction? {
if event == "val"{
let animation = CABasicAnimation(keyPath: "val")
animation.fromValue = presentation()?.value(forKey: "val")
animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
animation.duration = 2
return animation
} else {
return super.action(forKey: event)
}
}
/**
Draws the ring for the view.
Sets path properties according to how the user has decided to customize the view.
*/
private func drawRing(in ctx: CGContext) {
let center: CGPoint = CGPoint(x: bounds.midX, y: bounds.midY)
let radiusIn: CGFloat = (min(bounds.width, bounds.height) - ringWidth)/2
// Start drawing
let innerPath: UIBezierPath = UIBezierPath(arcCenter: center,
radius: radiusIn,
startAngle: startAngle,
endAngle: toEndAngle,
clockwise: true)
// Draw path
ctx.setLineWidth(ringWidth)
ctx.setLineJoin(.round)
ctx.setLineCap(CGLineCap.round)
ctx.setStrokeColor(UIColor.red.cgColor)
ctx.addPath(innerPath.cgPath)
ctx.drawPath(using: .stroke)
}
var toEndAngle: CGFloat {
return (val * 360.0).rads + startAngle
}
}
helper methods
/**
A private extension to CGFloat in order to provide simple
conversion from degrees to radians, used when drawing the rings.
*/
extension CGFloat {
var rads: CGFloat { return self * CGFloat.pi / 180 }
}
use an UIView subclass, with the internal custom CALayer
#IBDesignable open class UICircularRing: UIView {
/**
Set the ring layer to the default layer, casted as custom layer
*/
var ringLayer: UICircularRingLayer {
return layer as! UICircularRingLayer
}
/**
Overrides the default layer with the custom UICircularRingLayer class
*/
override open class var layerClass: AnyClass {
return UICircularRingLayer.self
}
/**
Override public init to setup() the layer and view
*/
override public init(frame: CGRect) {
super.init(frame: frame)
// Call the internal initializer
setup()
}
/**
Override public init to setup() the layer and view
*/
required public init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
// Call the internal initializer
setup()
}
/**
This method initializes the custom CALayer to the default values
*/
func setup(){
// Helps with pixelation and blurriness on retina devices
ringLayer.contentsScale = UIScreen.main.scale
ringLayer.shouldRasterize = true
ringLayer.rasterizationScale = UIScreen.main.scale * 2
ringLayer.masksToBounds = false
backgroundColor = UIColor.clear
ringLayer.backgroundColor = UIColor.clear.cgColor
ringLayer.val = 0
}
func startAnimation() {
ringLayer.val = 1
}
}
Usage:
class ViewController: UIViewController {
let progressRing = UICircularRing(frame: CGRect(x: 100, y: 100, width: 250, height: 250))
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(progressRing)
}
#IBAction func animate(_ sender: UIButton) {
progressRing.startAnimation()
}
}
with an indicator image to set up the angle