I'm trying to make a Gauge UIView to mimic the following image as close as possible
func gradientBezierPath(percent: CGFloat) -> UIBezierPath {
// vary this to move the start of the arc
let startAngle = CGFloat(180).toRadians()//-CGFloat.pi / 2 // This corresponds to 12 0'clock
// vary this to vary the size of the segment, in per cent
let proportion = CGFloat(50 * percent)
let centre = CGPoint (x: self.frame.size.width / 2, y: self.frame.size.height / 2)
let radius = self.frame.size.height/4//self.frame.size.width / (CGFloat(130).toRadians())
let arc = CGFloat.pi * 2 * proportion / 100 // i.e. the proportion of a full circle
// Start a mutable path
let cPath = UIBezierPath()
// Move to the centre
cPath.move(to: centre)
// Draw a line to the circumference
cPath.addLine(to: CGPoint(x: centre.x + radius * cos(startAngle), y: centre.y + radius * sin(startAngle)))
// NOW draw the arc
cPath.addArc(withCenter: centre, radius: radius, startAngle: startAngle, endAngle: arc + startAngle, clockwise: true)
// Line back to the centre, where we started (or the stroke doesn't work, though the fill does)
cPath.addLine(to: CGPoint(x: centre.x, y: centre.y))
return cPath
}
override func draw(_ rect: CGRect) {
// let endAngle = percent == 1.0 ? 0 : (percent * 180) + 180
path = UIBezierPath(arcCenter: CGPoint(x: self.frame.size.width/2, y: self.frame.size.height/2),
radius: self.frame.size.height/4,
startAngle: CGFloat(180).toRadians(),
endAngle: CGFloat(0).toRadians(),
clockwise: true)
percentPath = UIBezierPath(arcCenter: CGPoint(x: self.frame.size.width/2, y: self.frame.size.height/2),
radius: self.frame.size.height/4,
startAngle: CGFloat(180).toRadians(),
endAngle: CGFloat(0).toRadians(),
clockwise: true)
let shapeLayer = CAShapeLayer()
shapeLayer.path = self.path.cgPath
shapeLayer.strokeColor = UIColor(red: 110 / 255, green: 78 / 255, blue: 165 / 255, alpha: 1.0).cgColor
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.lineWidth = 5.0
shapeLayer.lineCap = .round
self.layer.addSublayer(shapeLayer)
percentLayer.path = self.percentPath.cgPath
percentLayer.strokeColor = UIColor(red: 255 / 255, green: 93 / 255, blue: 41 / 255, alpha: 1.0).cgColor
percentLayer.fillColor = UIColor.clear.cgColor
percentLayer.lineWidth = 8.0
// percentLayer.strokeEnd = CGFloat(percent)
percentLayer.lineCap = .round
self.layer.addSublayer(percentLayer)
// n.b. as #MartinR points out `cPath.close()` does the same!
// circle shape
circleShape.path = gradientBezierPath(percent: 1.0).cgPath//cPath.cgPath
circleShape.strokeColor = UIColor.clear.cgColor
circleShape.fillColor = UIColor.green.cgColor
self.layer.addSublayer(circleShape)
gradient.frame = frame
gradient.mask = circleShape
gradient.type = .radial
gradient.colors = [UIColor(red: 255 / 255, green: 93 / 255, blue: 41 / 255, alpha: 0.0).cgColor,
UIColor(red: 255 / 255, green: 93 / 255, blue: 41 / 255, alpha: 0.0).cgColor,
UIColor(red: 255 / 255, green: 93 / 255, blue: 41 / 255, alpha: 0.4).cgColor]
gradient.locations = [0, 0.35, 1]
gradient.startPoint = CGPoint(x: 0.49, y: 0.55) // increase Y adds more orange from top to bottom
gradient.endPoint = CGPoint(x: 0.98, y: 1) // increase x pushes orange out more to edges
self.layer.addSublayer(gradient)
//myTextLayer.string = "\(Int(percent * 100))"
myTextLayer.backgroundColor = UIColor.clear.cgColor
myTextLayer.foregroundColor = UIColor.white.cgColor
myTextLayer.fontSize = 85.0
myTextLayer.frame = CGRect(x: (self.frame.size.width / 2) - (self.frame.size.width/8), y: (self.frame.size.height / 2) - self.frame.size.height/8, width: 120, height: 120)
self.layer.addSublayer(myTextLayer)
}
This produces the following in a playground which is pretty close to what i'm aiming for:
The problem comes when trying to animate the change in the gauge value. I can animate the percentLayer pretty easy with modifying strokeEnd, but animating the circleShape.path for the gradient results in some non-smooth animations if there's a large change in the percent value of the gauge. Here's the function i use to animate both layers (it's called on a timer every 2 seconds right now to simulate gauge value changes).
func randomPercent() {
let random = CGFloat.random(in: 0.0...1.0)
// Animate the percent layer
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = percentLayer.strokeEnd
animation.toValue = random
animation.duration = 1.5
percentLayer.strokeEnd = random
percentLayer.add(animation, forKey: nil)
// Animate the gradient layer
let newShapePath = gradientBezierPath(percent: random)
let gradientAnimation = CABasicAnimation(keyPath: "path")
gradientAnimation.duration = 1.5
gradientAnimation.toValue = newShapePath
gradientAnimation.fromValue = circleShape.path
circleShape.path = newShapePath.cgPath
self.circleShape.add(gradientAnimation, forKey: nil)
myTextLayer.string = "\(Int(random * 100))"
}
Notice how when the animation is done with small changes in the value, the animation looks good. However when there's a large change the gradient animation doesn't look natural at all. Any ideas on how to improve this? Or maybe is it possible to animate a different keyPath for better performance? Any help would be greatly appreciated!
You can't use the Bezier path addArc() function to animate an arc and change the arc distance.
The problem is control points. In order for an animation to work smoothly, the starting and ending shape must have the same number and type of control points. Under the covers, the UIBezierPath (and CGPath) objects create arcs approximating a circle by combining Bezier curves (I don't remember if it uses Quadratic or Cubic Bezier curves.) The entire circle is made up of multiple connected Bezier curves ("Bezier" the mathematical spline function, not UIBeizerPath, which is a UIKit function that creates shapes that can include Bezier paths.) I seem to remember a Bezier approximation of a circle is made up of 4 linked cubic Bezier curves. (See this SO answer for a discussion of what that looks like, if you're interested.)
Here is my understanding of how it works. (I might have the details wrong, but it illustrates the problem in any case.) As you move from <= 1/4 of a full circle to > 1/4 of a full circle, the arc function will use first 1 cubic Bezier section, then 2. At the transition from <= 1/2 of a circle to > 1/2 of a circle, it will shift to 3 Bezier curves, and at the transition from <= 3/4 of a circle to > 3/4 of a circle, it will switch to 4 Bezier curves.
The solution:
You are on the right track with using strokeEnd. Always create your shape as the full circle, and set strokeEnd to something less than 1. That will give you a part of a circle, but in a way that you can animate smoothly. (You can animate strokeStart as well.)
I've animated circles just like you describe using CAShapeLayer and strokeEnd (It was a number of years ago, so it was in Objective-C.) I wrote an article here on OS on using the approach to animate a mask on a UIImageView and create a "clock wipe" animation. If you have an image of your full shaded circle you could use that exact approach here. (You should be able to add a mask layer to any UIView's content layer or other layer, and animate that layer as in my clock wipe demo. Let me know if you need help deciphering the Objective-C.
Here is the sample clock wipe animation I created:
Note that you can use this effect to mask any layer, not just an image view.
EDIT: I posted an update to my clock wipe animation question and answer with a Swift version of the project.
You can get to the new repo directly at https://github.com/DuncanMC/ClockWipeSwift.
For your application I would set up the parts of your gauge that you need to animate as a composite of layers. You'd then attach a CAShapeLayer based mask layer to that composite layer and add a circle arc path to that shape layer and animate the strokeEnd as shown in my sample project. My clock wipe animation reveals the image like the sweep of a clock hand from the center of the layer. In your case you'd center the arc on the bottom center of your layer, and only use a half-circle arc in your shape layer. Using a mask that way would give you a sharp-edged crop to your composited layer. you'd lose the round end caps on your red arc. To fix that you'd have to animate the red arc as it's own shape layer (using strokeEnd) and animate the gradient fill's arc strokeEnd separately.
Related
I've done a bit of research but unfortunately only found some libraries that allowed coloured circular loaders and not using an image.
So here's my issue, I've got a circular countdown which in the design I've got to implement uses a complex glow that I find really hard to reproduce as shown. The progress of this countdown is shown by this glow progressing over 3 seconds around a circle.
My initial thought was to try to modify UIView+Glow so that the glow wouldn't vary but even then I would come at a stop when it came to making my UIView radially hidden.
So I'm now thinking of simply exporting the outer glow that makes the progress bar as an image and radially hiding that (which would be faster and simpler to do in the end, rather than trying to make the exact same glow manually).
Does anyone have any idea where I should start looking or what I should be doing to hide part of a circular image using angles/rads ?
EDIT: here's what the glow looks like overlayed with the circular label (the black circle) that is used to show the countdown value.
First I would like to thank #Carpsen90 for helping me out, although his answer was not what I was looking for it helped me understand how to use CABasicAnimation and in combination with inspiration from this answer I found a solution to my problem using an image and a CAShapeLayer
for those wondering what I did here's the code I used
// countdownGlow is the glow (white and red part of my image)
func animateMask() {
let arcPath = CGMutablePath()
arcPath.move(to: CGPoint(x: self.countdownGlow.frame.width / 2, y: 0))
arcPath.addArc(center: CGPoint(x: self.countdownGlow.frame.width / 2, y: self.countdownGlow.frame.width / 2), radius: self.countdownGlow.frame.width / 2, startAngle: CGFloat(-1 * Double.pi / 2), endAngle: CGFloat(-3 * Double.pi / 2), clockwise: false)
arcPath.addArc(center: CGPoint(x: self.countdownGlow.frame.width / 2, y: self.countdownGlow.frame.width / 2), radius: self.countdownGlow.frame.width / 2, startAngle: CGFloat(-3 * Double.pi / 2), endAngle: CGFloat(-5 * Double.pi / 2), clockwise: false)
let ringLayer = CAShapeLayer(layer: self.countdownGlow.layer)
ringLayer.path = arcPath
ringLayer.strokeEnd = 0.0
ringLayer.fillColor = UIColor.clear.cgColor
ringLayer.strokeColor = UIColor.black.cgColor
ringLayer.lineWidth = self.countdownGlow.frame.width / 2
self.countdownGlow.layer.mask = ringLayer
self.countdownGlow.layer.mask?.frame = self.countdownGlow.layer.bounds
let swipe = CABasicAnimation(keyPath: "strokeEnd")
swipe.duration = 3
swipe.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
swipe.fillMode = kCAFillModeForwards
swipe.isRemovedOnCompletion = true
swipe.toValue = 1.0
ringLayer.add(swipe, forKey: "strokeEnd")
}
I couldn't seem to simply tell my arc to be a full circle so I went with half a circle once and then another half circle which worked out
If you have any improvements to offer I would gladly try it out to see, because I am sure my solution is not optimised at all
let centerPointX = colorSizeGuide.bounds.midX / 2
let centerPointY = colorSizeGuide.bounds.midY / 2
let circleWidth: CGFloat = 10
let circleHeight: CGFloat = 10
shape.path = UIBezierPath(ovalIn: CGRect(x: centerPointX + circleWidth / 4, y: centerPointY + circleHeight / 4, width: circleWidth, height: circleHeight)).cgPath
shape.strokeColor = UIColor(r: 160, g: 150, b: 180).cgColor
shape.fillColor = UIColor(r: 160, g: 150, b: 180).cgColor
shape.anchorPoint = CGPoint(x: 0.5, y: 0.5)
shape.lineWidth = 0.1
shape.transform = CATransform3DMakeScale(4.0, 4.0, 1.0)
colorSizeGuide.layer.addSublayer(shape)
Here's what's happening. I need the CAShapeLayer to stay in the middle of the small gray area:
I struggle with affine transforms a little myself, but here's what I think is going on:
The scale takes place centered around 0,0, so it will grow out from that point. That means it will "push away" from the origin.
In order to grow from the center, you should shift the origin to the center point of your shape, scale, and then shift the origin back, by the now-scaled amount:
var transform = CATransform3DMakeTranslation(centerPointX, centerPointY, 0)
transform = CATransformScale(transform, 4.0, 4.0, 1.0)
var transform = CATransform3DTranslate(
transform,
-4.0 * centerPointX,
-4.0 * centerPointY,
0)
shape.transform = transform
BTW, I can't make any sense of the image you posted with your question. You say "I need the CAShapeLayer to stay in the middle of the small gray area" I gather your shape layer is one of the circles, but it isn't clear what you mean by "the small gray area." It looks like there might be an outline that got cropped somehow.
I have a relatively straight forward implementation of a progress view set up with CALayer objects. The progress view itself is a subview of UIView.
Here is the code that sets up the progress ring:
self.progressRingLayer = CAShapeLayer()
let innerRect = CGRectInset(bounds, CGFloat(self.lineWidth) / 2, CGFloat(self.lineWidth) / 2)
let innerPath = UIBezierPath(ovalInRect: innerRect)
self.progressRingLayer.path = innerPath.CGPath
self.progressRingLayer.fillColor = UIColor.clearColor().CGColor
self.progressRingLayer.strokeColor = kProgressColor.CGColor
self.progressRingLayer.anchorPoint = CGPointMake(0.5, 0.5)
self.progressRingLayer.transform = CATransform3DRotate(self.progressRingLayer.transform, (CGFloat(M_PI))*1, 0, 0, 1)
self.progressRingLayer.lineCap = kCALineCapRound
self.progressRingLayer.lineWidth = CGFloat(self.lineWidth)
self.layer.addSublayer(self.progressRingLayer)
What I am trying to do now is add a gradient to the progressRingLayer that follows (or bends with) the path. I have been successful in adding a linear gradient to the fill, but not to just the path.
Here is an example of what effect I want:
So far everything I have found requires a bunch of additional steps with CoreGraphics and CGContext that don't quite fit with my implementation. Any help would be great, thanks!
What I would do is draw a gradient layer, then draw on top of that a layer that is black with the arc erased.
Here's my attempt at roughly the image you provided (I omitted the white label in the center, but that's trivial):
And here's the code that generated it:
let r = CGRectMake(100,100,130,100)
let g = CAGradientLayer()
g.frame = r
let c1 = UIColor(
red: 151.0/255.0, green: 81.0/255.0, blue: 227.0/255.0, alpha: 1)
let c2 = UIColor(
red: 36.0/255.0, green: 176.0/255.0, blue: 233.0/255.0, alpha: 1)
g.colors = [c1.CGColor as AnyObject, c2.CGColor as AnyObject];
self.view.layer.addSublayer(g)
let percent = CGFloat(0.64) // percentage of circle
UIGraphicsBeginImageContextWithOptions(r.size, false, 0)
let con = UIGraphicsGetCurrentContext()
CGContextFillRect(con, CGRect(origin: CGPoint(), size: r.size))
CGContextSetLineWidth(con, 5)
CGContextSetLineCap(con, kCGLineCapRound)
CGContextSetBlendMode(con, kCGBlendModeClear)
let pi = CGFloat(M_PI)
CGContextAddArc(con, r.size.width/2.0, r.size.height/2.0, 30,
-pi/2.0, -pi/2.0 + percent*pi*2.0, 0)
CGContextStrokePath(con)
let im = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
let b = CALayer()
b.frame = r
b.contents = im.CGImage
self.view.layer.addSublayer(b)
The gradient layer (the first part of the code) is just a "serving suggestion". If that is not the gradient you want, you can design your own. You could draw it in Photoshop and use an image as the content of the gradient layer. Or you could make an "angular" layer in code, using third-party code such as https://github.com/paiv/AngleGradientLayer. The point of the example is merely to show how it is possible to "erase" an arc in a black layer so as to reveal the gradient concealed behind it, and thus appear to paint with a gradient.
I want to make a nice graphic pie with 8 equal slices, that can be individually scaled or resized depending on an Int or something like this. This would look something like below just that all the slices should be equally cut:
I have tried this in Objective-C but it makes just one slice:
-(CAShapeLayer *)createPieSlice {
CAShapeLayer *slice = [CAShapeLayer layer];
slice.fillColor = [UIColor redColor].CGColor;
slice.strokeColor = [UIColor blackColor].CGColor;
slice.lineWidth = 3.0;
CGFloat angle = DEG2RAD(-60.0);
CGPoint center = CGPointMake(100.0, 100.0);
CGFloat radius = 100.0;
UIBezierPath *piePath = [UIBezierPath bezierPath];
[piePath moveToPoint:center];
[piePath addLineToPoint:CGPointMake(center.x + radius * cosf(angle), center.y + radius * sinf(angle))];
[piePath addArcWithCenter:center radius:radius startAngle:angle endAngle:DEG2RAD(60.0) clockwise:YES];
// [piePath addLineToPoint:center];
[piePath closePath]; // this will automatically add a straight line to the center
slice.path = piePath.CGPath;
return slice;
}
How can I achieve that graph in swift?
Break the problem into logical pieces.
You have wedges of different arc widths. All those radii need to add up to a full circle. I assume they represent fractions of something that adds up to 100%. Do you want a specific order? If so, map your fractions in the order you want, such that they all add up to 100%.
Then write code that starts at an angle of zero, and creates arcs that are the specified fraction of 2π. Each one would start at the end of the previous one. Assign a radius that's appropriate based on the data you need.
Now write code that creates closed path segments in a UIBezierPath.
EDIT
You've clarified, and told us that you always want 8 slices of the same width but with different radii.
So you need to write code that takes 8 input values and plots it as 8 arcs with different radius values.
Let's say your input value is an array of floats ranging from 0 to 1. At zero, the wedge is zero-sized. At 1.0, it's the largest circle size that will fit in your view (half the width of a square view.
So you would create an array of 8 floats:
var fractions = [0.5, 0.7, 0.3, 0.1, 1.0 .6, .2, .9]
The code to create a bezier curve with 8 arcs might look something like this:
let pi = 3.1415826
let largestRadius = myView.width/2
let piePath = UIBezierPath()
for (index, afloat) in fractions
{
let startAngle = Double(index) / fractions.count * 2 * pi
let endAngle = Double(index+1) / fractions.count * 2 * pi
let thisRadius = largestRadius * afloat
let center = CGPointMake( myView.width/2, myView.height/2)
piePath.moveToPoint(center)
piePath.addArcWithCenter(center,
radius: thisRadius,
startAngle: startAngle,
endAngle: endAngle,
clockwise: true)
piePath.lineToPoint(center)
piePath.closePath()
}
I think the code above would create 8 closed pie-slice paths, but I'm not positive. It might be necessary to add a lineToPoint call between the first moveToPoint call and the arc call.
Edit #2:
Since I am learning Swift, I decided to take this as an exercise and wrote a sample project that generates pie charts using a shape layer and a a custom path created from a UIBezierPath, as outlined above. You can find the sample project on github: PieCharts project on Github
I have managed to solve my problem using Core Graphics! Thanks #duncan-c for your interest.
EDIT:
I have dropped my first solution in the favour of #duncan-c's solution, that works better for my needs!
import UIKit
class Pie: UIView {
// In range of 0.0 to 1.0
var endArc:CGFloat = 0.0 {
didSet {
setNeedsDisplay()
}
}
var arcWidth:CGFloat = 5.0
var arcColor = UIColor()
var arcBackgroundColor = UIColor.clearColor()
var arcStrokeColor = UIColor()
var startFloat:CGFloat = 0.0
var radius:CGFloat = 0.0
var radiusSize: CGFloat = 0.0
override func drawRect(rect: CGRect) {
// Important constants for circle
let fullCircle = 2.0 * CGFloat(M_PI)
let start:CGFloat = startFloat * fullCircle
let end:CGFloat = endArc * fullCircle + start
// Find the centerpoint of the rect
var centerPoint = CGPointMake(CGRectGetMidX(rect), CGRectGetMidY(rect))
// Set the radius
radius = (radiusSize - arcWidth) / 2.0
// Starting point for all drawing code is getting the context.
let context = UIGraphicsGetCurrentContext()
// Set colorspace
let colorspace = CGColorSpaceCreateDeviceRGB()
// Set line attributes
CGContextSetLineWidth(context, arcWidth)
// Draw the pie
CGContextSetStrokeColorWithColor(context, arcStrokeColor.CGColor)
CGContextSetFillColorWithColor(context, arcColor.CGColor)
CGContextMoveToPoint(context, centerPoint.x, centerPoint.y)
CGContextAddArc(context, centerPoint.x, centerPoint.y, radius, start, end, 0)
CGContextFillPath(context)
}
}
And then subclassd the UIView where I needed using:
#IBOutlet weak var graph: Pie!
override func awakeFromNib() {
super.awakeFromNib()
let backgroundTrackColor = UIColor.clearColor()
let strokeColor = UIColor(white: 0.15, alpha: 1.0)
graph.radiusSize = 50.0
graph.arcBackgroundColor = backgroundTrackColor
graph.arcStrokeColor = strokeColor
graph.arcColor = UIColor.redColor()
graph.startFloat = 0.750
graph.arcWidth = 5.0
graph.endArc = 0.125
}
I have a relatively straight forward implementation of a progress view set up with CALayer objects. The progress view itself is a subview of UIView.
Here is the code that sets up the progress ring:
self.progressRingLayer = CAShapeLayer()
let innerRect = CGRectInset(bounds, CGFloat(self.lineWidth) / 2, CGFloat(self.lineWidth) / 2)
let innerPath = UIBezierPath(ovalInRect: innerRect)
self.progressRingLayer.path = innerPath.CGPath
self.progressRingLayer.fillColor = UIColor.clearColor().CGColor
self.progressRingLayer.strokeColor = kProgressColor.CGColor
self.progressRingLayer.anchorPoint = CGPointMake(0.5, 0.5)
self.progressRingLayer.transform = CATransform3DRotate(self.progressRingLayer.transform, (CGFloat(M_PI))*1, 0, 0, 1)
self.progressRingLayer.lineCap = kCALineCapRound
self.progressRingLayer.lineWidth = CGFloat(self.lineWidth)
self.layer.addSublayer(self.progressRingLayer)
What I am trying to do now is add a gradient to the progressRingLayer that follows (or bends with) the path. I have been successful in adding a linear gradient to the fill, but not to just the path.
Here is an example of what effect I want:
So far everything I have found requires a bunch of additional steps with CoreGraphics and CGContext that don't quite fit with my implementation. Any help would be great, thanks!
What I would do is draw a gradient layer, then draw on top of that a layer that is black with the arc erased.
Here's my attempt at roughly the image you provided (I omitted the white label in the center, but that's trivial):
And here's the code that generated it:
let r = CGRectMake(100,100,130,100)
let g = CAGradientLayer()
g.frame = r
let c1 = UIColor(
red: 151.0/255.0, green: 81.0/255.0, blue: 227.0/255.0, alpha: 1)
let c2 = UIColor(
red: 36.0/255.0, green: 176.0/255.0, blue: 233.0/255.0, alpha: 1)
g.colors = [c1.CGColor as AnyObject, c2.CGColor as AnyObject];
self.view.layer.addSublayer(g)
let percent = CGFloat(0.64) // percentage of circle
UIGraphicsBeginImageContextWithOptions(r.size, false, 0)
let con = UIGraphicsGetCurrentContext()
CGContextFillRect(con, CGRect(origin: CGPoint(), size: r.size))
CGContextSetLineWidth(con, 5)
CGContextSetLineCap(con, kCGLineCapRound)
CGContextSetBlendMode(con, kCGBlendModeClear)
let pi = CGFloat(M_PI)
CGContextAddArc(con, r.size.width/2.0, r.size.height/2.0, 30,
-pi/2.0, -pi/2.0 + percent*pi*2.0, 0)
CGContextStrokePath(con)
let im = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
let b = CALayer()
b.frame = r
b.contents = im.CGImage
self.view.layer.addSublayer(b)
The gradient layer (the first part of the code) is just a "serving suggestion". If that is not the gradient you want, you can design your own. You could draw it in Photoshop and use an image as the content of the gradient layer. Or you could make an "angular" layer in code, using third-party code such as https://github.com/paiv/AngleGradientLayer. The point of the example is merely to show how it is possible to "erase" an arc in a black layer so as to reveal the gradient concealed behind it, and thus appear to paint with a gradient.