Making Pie Chart with Slice space(Space between each color in Pie Chart) - swift

I'm working on a project in which i need to implement the pie chart like above. I need to add a space between the slices as picture attached. Need to have a space in slices with arc and values with percentage.
I've tried to implement it but i couldn't succeed. I use get arc shapes wrong.
Please help me. Thanks.
import UIKit
private extension CGFloat {
/// Formats the CGFloat to a maximum of 1 decimal place.
var formattedToOneDecimalPlace : String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 1
return formatter.string(from: NSNumber(value: self.native)) ?? "\(self)"
}
}
/// Defines a segment of the pie chart
struct Segment {
/// The color of the segment
var color : UIColor
/// The name of the segment
var name : String
/// The value of the segment
var value : CGFloat
}
class PieChartView: UIView {
/// An array of structs representing the segments of the pie chart
var segments = [Segment]() {
didSet { setNeedsDisplay() } // re-draw view when the values get set
}
/// Defines whether the segment labels should be shown when drawing the pie chart
var showSegmentLabels = true {
didSet { setNeedsDisplay() }
}
/// Defines whether the segment labels will show the value of the segment in brackets
var showSegmentValueInLabel = false {
didSet { setNeedsDisplay() }
}
/// The font to be used on the segment labels
var segmentLabelFont = UIFont.systemFont(ofSize: 14) {
didSet {
textAttributes[NSAttributedStringKey.font] = segmentLabelFont
setNeedsDisplay()
}
}
private let paragraphStyle : NSParagraphStyle = {
var p = NSMutableParagraphStyle()
p.alignment = .center
return p.copy() as! NSParagraphStyle
}()
private lazy var textAttributes : [NSAttributedStringKey : Any] = {
return [NSAttributedStringKey.paragraphStyle : self.paragraphStyle, NSAttributedStringKey.font : self.segmentLabelFont]
}()
override init(frame: CGRect) {
super.init(frame: frame)
isOpaque = false // when overriding drawRect, you must specify this to maintain transparency.
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
override func draw(_ rect: CGRect) {
// get current context
let ctx = UIGraphicsGetCurrentContext()
// radius is the half the frame's width or height (whichever is smallest)
let radius = min(frame.width, frame.height) * 0.5
// center of the view
let viewCenter = CGPoint(x: bounds.size.width * 0.5, y: bounds.size.height * 0.5)
// enumerate the total value of the segments by using reduce to sum them
let valueCount = segments.reduce(0, {($0 + $1.value)})
// the starting angle is -90 degrees (top of the circle, as the context is flipped). By default, 0 is the right hand side of the circle, with the positive angle being in an anti-clockwise direction (same as a unit circle in maths).
var startAngle = -CGFloat.pi * 0.5
// loop through the values array
for segment in segments {
// set fill color to the segment color
ctx?.setFillColor(segment.color.cgColor)
// update the end angle of the segment
let endAngle = startAngle + .pi * 2 * (segment.value / valueCount)
// move to the center of the pie chart
ctx?.move(to: viewCenter)
// add arc from the center for each segment (anticlockwise is specified for the arc, but as the view flips the context, it will produce a clockwise arc)
ctx?.addArc(center: viewCenter, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: false)
// fill segment
ctx?.fillPath()
// ctx?.setStrokeColor(UIColor.white.cgColor)
// ctx?.strokePath()
if showSegmentLabels { // do text rendering
// get the angle midpoint
let halfAngle = startAngle + (endAngle - startAngle) * 0.5;
// the ratio of how far away from the center of the pie chart the text will appear
let textPositionValue : CGFloat = 0.65
// get the 'center' of the segment. It's slightly biased to the outer edge, as it's wider.
let segmentCenter = CGPoint(x: viewCenter.x + radius * textPositionValue * cos(halfAngle), y: viewCenter.y + radius * textPositionValue * sin(halfAngle))
// text to render – the segment value is formatted to 1dp if needed to be displayed.
// Previous
//let textToRender = showSegmentValueInLabel ? "\(segment.name) \(segment.value.formattedToOneDecimalPlace))" : segment.name
// Change
let textToRender = showSegmentValueInLabel ? "\(segment.value.formattedToOneDecimalPlace)%" : segment.name
// get the color components of the segement color
guard let colorComponents = segment.color.cgColor.components else { return }
// get the average brightness of the color
let averageRGB = (colorComponents[0] + colorComponents[1] + colorComponents[2]) / 3
// if too light, use black. If too dark, use white
textAttributes[NSAttributedStringKey.foregroundColor] = (averageRGB > 0.7) ? UIColor.black : UIColor.white
// the bounds that the text will occupy
var renderRect = CGRect(origin: .zero, size: textToRender.size(withAttributes: textAttributes))
// center the origin of the rect
renderRect.origin = CGPoint(x: segmentCenter.x - renderRect.size.width * 0.5, y: segmentCenter.y - renderRect.size.height * 0.5)
// draw text in the rect, with the given attributes
textToRender.draw(in: renderRect, withAttributes: textAttributes)
}
// update starting angle of the next segment to the ending angle of this segment
startAngle = endAngle
}
}
}

In your draw(_:), implement this.
override func draw(_ rect: CGRect) {
let anglePI2 = (CGFloat.pi * 2)
let center = CGPoint.init(x: bounds.size.width / 2, y: bounds.size.height / 2)
let radius = min(bounds.size.width, bounds.size.height) / 2;
let lineWidth: CGFloat = 1;
let ctx = UIGraphicsGetCurrentContext()
ctx?.setLineWidth(lineWidth)
var currentAngle: CGFloat = 0
var totalValue: CGFloat = segments.reduce(0) { $0 + $1.value }
if totalValue <= 0 {
totalValue = 1
}
let iRange = 0 ..< segments.count
for i in iRange {
let segment = segments[i]
// calculate percent
let percent = segment.value / totalValue
let angle = anglePI2 * percent
ctx?.beginPath()
ctx?.move(to: center)
ctx?.addArc(center: center, radius: radius - lineWidth, startAngle: currentAngle, endAngle: currentAngle + angle, clockwise: false)
ctx?.closePath()
ctx?.setFillColor(segment.color.cgColor)
ctx?.fillPath()
ctx?.beginPath()
ctx?.move(to: center)
ctx?.addArc(center: center, radius: radius - (lineWidth / 2), startAngle: currentAngle, endAngle: currentAngle + angle, clockwise: false)
ctx?.closePath()
ctx?.setStrokeColor(UIColor.white.cgColor)
ctx?.strokePath()
currentAngle += angle
}
}
Explanation
- Calculate pi equivalent to 360 deg.
- Calculate center.
- Calculate radius of the blocks.
- Calculate totalValue of [Segment]. This is used when calculating the percent of the slice in the circle.
- Loop through segment, calculate percentage of the current segment, make arc for filling (radius - lineWidth), remake arc for stroking path(radius - lineWidth / 2).
Here is the result
In my view controller, I added the data (For your info). Like this
scv.segments = [
Segment.init(color: UIColor.init(red: 0xfe/0xff, green: 0x4a/0xff, blue: 0x49/0xff, alpha: 1), value: 20),
Segment.init(color: UIColor.init(red: 0x2a/0xff, green: 0xb7/0xff, blue: 0xca/0xff, alpha: 1), value: 15),
Segment.init(color: UIColor.init(red: 0xfe/0xff, green: 0xd7/0xff, blue: 0x66/0xff, alpha: 1), value: 15),
Segment.init(color: UIColor.init(red: 0x4a/0xff, green: 0x4e/0xff, blue: 0x4d/0xff, alpha: 1), value: 50)
]
Updated
Here is a complete code for your question and additional requirement as requested.
struct Segment {
// the color of a given segment
var color: UIColor
// the value of a given segment – will be used to automatically calculate a ratio
var value: CGFloat
}
class SliceView: UIView {
/// An array of structs representing the segments of the pie chart
var segments = [Segment]() {
didSet {
totalValue = segments.reduce(0) { $0 + $1.value }
setupLabels()
setNeedsDisplay() // re-draw view when the values get set
layoutLabels();
}
}
private var totalValue: CGFloat = 1;
private var labels: [UILabel] = []
override init(frame: CGRect) {
super.init(frame: frame)
isOpaque = false // when overriding drawRect, you must specify this to maintain transparency.
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
override func draw(_ rect: CGRect) {
let anglePI2 = (CGFloat.pi * 2)
let center = CGPoint.init(x: bounds.size.width / 2, y: bounds.size.height / 2)
let radius = min(bounds.size.width, bounds.size.height) / 2;
let lineWidth: CGFloat = 1;
let ctx = UIGraphicsGetCurrentContext()
ctx?.setLineWidth(lineWidth)
var currentAngle: CGFloat = 0
if totalValue <= 0 {
totalValue = 1
}
let iRange = 0 ..< segments.count
for i in iRange {
let segment = segments[i]
// calculate percent
let percent = segment.value / totalValue
let angle = anglePI2 * percent
ctx?.beginPath()
ctx?.move(to: center)
ctx?.addArc(center: center, radius: radius - lineWidth, startAngle: currentAngle, endAngle: currentAngle + angle, clockwise: false)
ctx?.closePath()
ctx?.setFillColor(segment.color.cgColor)
ctx?.fillPath()
ctx?.beginPath()
ctx?.move(to: center)
ctx?.addArc(center: center, radius: radius - (lineWidth / 2), startAngle: currentAngle, endAngle: currentAngle + angle, clockwise: false)
ctx?.closePath()
ctx?.setStrokeColor(UIColor.white.cgColor)
ctx?.strokePath()
currentAngle += angle
}
}
override func layoutSubviews() {
super.layoutSubviews()
self.layoutLabels()
}
private func setupLabels() {
var diff = segments.count - labels.count;
if diff >= 0 {
for _ in 0 ..< diff {
let lbl = UILabel()
self.addSubview(lbl)
labels.append(lbl)
}
} else { // diff < 0 (minus values)
// loop until diff is 0
//
while diff != 0 {
var lbl: UILabel!
// if there is no more labels to remove
// break the loop
if labels.count <= 0 {
break;
}
// get the last label
lbl = labels.removeLast()
if lbl.superview != nil {
lbl.removeFromSuperview()
}
// increment the minus value by 1
diff += 1;
}
}
for i in 0 ..< segments.count {
let lbl = labels[i]
lbl.textColor = UIColor.white
// Change here for your text display
// I currently display percent of each pies
lbl.text = String.init(format: "%0.1f", segments[i].value)
lbl.font = UIFont.init(name: "ArialRoundedMT-Bold", size: 12)
}
}
func layoutLabels() {
let anglePI2 = CGFloat.pi * 2
let center = CGPoint.init(x: bounds.size.width / 2, y: bounds.size.height / 2)
let radius = min(bounds.size.width / 2, bounds.size.height / 2) / 2
var currentAngle: CGFloat = 0;
let iRange = 0 ..< labels.count
for i in iRange {
let lbl = labels[i]
let percent = segments[i].value / totalValue
let intervalAngle = anglePI2 * percent;
lbl.frame = .zero;
lbl.sizeToFit()
let x = center.x + radius * cos(currentAngle + (intervalAngle / 2))
let y = center.y + radius * sin(currentAngle + (intervalAngle / 2))
lbl.center = CGPoint.init(x: x, y: y)
currentAngle += intervalAngle
}
}
}
The result is

Related

Can I use UIBezier to draw an oval progress bar

How to draw oval with start and end angle in swift?
Just like the method that I use init(arcCenter:radius:startAngle:endAngle:clockwise:)
to draw a circle with a gap.
I tried to use init(ovalln:) and the relation of the bezier Curve and ellipse to draw an oval with a gap.
However, it only came out with a perfect oval eventually.
How can I draw an oval with a gap like the image below? thanks!
May or may not work for you, one approach is to draw an arc leaving a gap and then scaling the path on the y-axis.
Arcs begin with Zero-degrees (or radians) at the 3 o'clock position. Since your gap is at the top, we can make things easier by "translating" degrees by -90, so we can think in terms of Zero-degrees being at 12 o'clock... that is, if we want to start at 20-degrees (with Zero at the top) and end at 340-degrees, our starting angle for the arc will be (20 - 90) and the ending arc will be (340 - 90).
So, we begin by making a circle with a bezier path - startAngle == 0, endAngle == 360:
Next, we'll adjust the start and end angles to give us a 40-degree "gap" at the top:
Then we can scale transform that path to look like this:
and, how it will look without the "inner" lines:
Then, we overlay another bezier path, using the same arc radius, startAngle and scaling, but we'll set the endAngle as a percentage of the full arc.
In the case of a 40-degree gap, the full arc will be (360 - 40).
Now, we get this as a "progress bar":
Here's a complete example:
class EllipseProgressView: UIView {
public var gapAngle: CGFloat = 40 {
didSet {
setNeedsLayout()
layoutIfNeeded()
}
}
public var progress: CGFloat = 0.0 {
didSet {
setNeedsLayout()
layoutIfNeeded()
}
}
public var baseColor: UIColor = .lightGray {
didSet {
ellipseBaseLayer.strokeColor = baseColor.cgColor
setNeedsLayout()
layoutIfNeeded()
}
}
public var progressColor: UIColor = .red {
didSet {
ellipseProgressLayer.strokeColor = progressColor.cgColor
setNeedsLayout()
layoutIfNeeded()
}
}
private let ellipseBaseLayer = CAShapeLayer()
private let ellipseProgressLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() -> Void {
backgroundColor = .black
layer.addSublayer(ellipseBaseLayer)
layer.addSublayer(ellipseProgressLayer)
ellipseBaseLayer.lineWidth = 3.0
ellipseBaseLayer.fillColor = UIColor.clear.cgColor
ellipseBaseLayer.strokeColor = baseColor.cgColor
ellipseBaseLayer.lineCap = .round
ellipseProgressLayer.lineWidth = 5.0
ellipseProgressLayer.fillColor = UIColor.clear.cgColor
ellipseProgressLayer.strokeColor = progressColor.cgColor
ellipseProgressLayer.lineCap = .round
}
override func layoutSubviews() {
var startAngle: CGFloat = 0
var endAngle: CGFloat = 0
var startRadians: CGFloat = 0
var endRadians: CGFloat = 0
var pth: UIBezierPath!
startAngle = gapAngle * 0.5
endAngle = 360 - gapAngle * 0.5
// totalAngle is (360-degrees minus the gapAngle)
let totalAngle: CGFloat = 360 - gapAngle
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let radius = bounds.width * 0.5
let yScale: CGFloat = bounds.height / bounds.width
let origHeight = radius * 2.0
let ovalHeight = origHeight * yScale
let y = (origHeight - ovalHeight) * 0.5
// degrees start with Zero at 3 o'clock, so
// translate them to start at 12 o'clock
startRadians = (startAngle - 90).degreesToRadians
endRadians = (endAngle - 90).degreesToRadians
// new bezier path
pth = UIBezierPath()
// arc with "gap" at the top
pth.addArc(withCenter: center, radius: radius, startAngle: startRadians, endAngle: endRadians, clockwise: true)
// translate on the y-axis
pth.apply(CGAffineTransform(translationX: 0.0, y: y))
// scale the y-axis
pth.apply(CGAffineTransform(scaleX: 1.0, y: yScale))
ellipseBaseLayer.path = pth.cgPath
// new endAngle is startAngle plus the percentage of the total angle
endAngle = startAngle + totalAngle * progress
// degrees start with Zero at 3 o'clock, so
// translate them to start at 12 o'clock
startRadians = (startAngle - 90).degreesToRadians
endRadians = (endAngle - 90).degreesToRadians
// new bezier path
pth = UIBezierPath()
pth.addArc(withCenter: center, radius: radius, startAngle: startRadians, endAngle: endRadians, clockwise: true)
// translate on the y-axis
pth.apply(CGAffineTransform(translationX: 0.0, y: y))
// scale the y-axis
pth.apply(CGAffineTransform(scaleX: 1.0, y: yScale))
ellipseProgressLayer.path = pth.cgPath
}
}
And an example view controller to try it out -- each tap will increase the "progress" by 5% until we reach 100%, and then we start over at Zero:
class EllipseVC: UIViewController {
var progress: CGFloat = 0.0
let ellipseProgressView = EllipseProgressView()
let percentLabel = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
ellipseProgressView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(ellipseProgressView)
// respect safe area
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
// constrain 300-pts wide
ellipseProgressView.widthAnchor.constraint(equalToConstant: 300.0),
// height is 1 / 3rd of width
ellipseProgressView.heightAnchor.constraint(equalTo: ellipseProgressView.widthAnchor, multiplier: 1.0 / 3.0),
// center in view safe area
ellipseProgressView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
ellipseProgressView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
])
// base line color is lightGray
// progress line color is red
// we can change those, if desired
// for example:
//ellipseProgressView.baseColor = .green
//ellipseProgressView.progressColor = .yellow
// "gap" angle default is 40-degrees
// we can change that, if desired
// for example:
//ellipseProgressView.gapAngle = 40
// add a label to show the current progress
percentLabel.translatesAutoresizingMaskIntoConstraints = false
percentLabel.textColor = .white
view.addSubview(percentLabel)
NSLayoutConstraint.activate([
percentLabel.topAnchor.constraint(equalTo: ellipseProgressView.bottomAnchor, constant: 8.0),
percentLabel.centerXAnchor.constraint(equalTo: ellipseProgressView.centerXAnchor),
])
updatePercentLabel()
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// increment progress by 5% on each tap
// reset to Zero when we get past 100%
progress += 5
if progress.rounded() > 100.0 {
progress = 0.0
}
ellipseProgressView.progress = (progress / 100.0)
updatePercentLabel()
}
func updatePercentLabel() -> Void {
percentLabel.text = String(format: "%0.2f %%", progress)
}
}
Please note: this is Example Code Only!!!

Custom shape to UISlider and update progress in Swift 5

I have checked some of the StackOverflow answers regarding custom UIView slider but using them I unable to make the slider like this. This makes a circle or half circle. I have figured out some library that makes circle slider using UIView but its not helpful to me so could anyone please help me out. How can I make slider like in below UIImage? Thanks!
You will probably just roll your own. (You obviously could search for third party implementations, but that would be out of scope for StackOverflow.) There are a lot of ways of tackling this, but the basic elements here are:
The pink arc for the overall path. Personally, I'd use a CAShapeLayer for that.
The white arc from the start to the current progress (measured from 0 to 1). Again, a CAShapeLayer would be logical.
The white dot placed at the spot of the current progress. Below I create a CALayer with white background and then apply a CAGradientLayer as a mask to that. You could also just create a UIImage for this.
In terms of how to set the progress, you would set the paths of the pink and white arcs to the same path, but just update the strokeEnd of the white arc. You would also adjust the position of the white dot layer accordingly.
The only complicated thing here is figuring out the center of the arc. In my example below, I calculate it with some trigonometry based upon the bounds of the view so that arc goes from lower left corner, to the top, and back down the the lower right corner. Or you might instead pass the center of the arc as a parameter.
Anyway, it might look like:
#IBDesignable
class ArcView: UIView {
#IBInspectable
var lineWidth: CGFloat = 7 { didSet { updatePaths() } }
#IBInspectable
var progress: CGFloat = 0 { didSet { updatePaths() } }
override func prepareForInterfaceBuilder() {
super.prepareForInterfaceBuilder()
progress = 0.35
}
lazy var currentPositionDotLayer: CALayer = {
let layer = CALayer()
layer.backgroundColor = UIColor.white.cgColor
let rect = CGRect(x: 0, y: 0, width: lineWidth * 3, height: lineWidth * 3)
layer.frame = rect
let gradientLayer = CAGradientLayer()
gradientLayer.colors = [UIColor.white.cgColor, UIColor.clear.cgColor]
gradientLayer.type = .radial
gradientLayer.frame = rect
gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1, y: 1)
gradientLayer.locations = [0.5, 1]
layer.mask = gradientLayer
return layer
}()
lazy var progressShapeLayer: CAShapeLayer = {
let shapeLayer = CAShapeLayer()
shapeLayer.lineCap = .round
shapeLayer.lineWidth = lineWidth
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.strokeColor = #colorLiteral(red: 1, green: 1, blue: 1, alpha: 1)
return shapeLayer
}()
lazy var totalShapeLayer: CAShapeLayer = {
let shapeLayer = CAShapeLayer()
shapeLayer.lineCap = .round
shapeLayer.lineWidth = lineWidth
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.strokeColor = #colorLiteral(red: 0.9439327121, green: 0.5454334617, blue: 0.6426400542, alpha: 1)
return shapeLayer
}()
override init(frame: CGRect) {
super.init(frame: frame)
configure()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
configure()
}
override func layoutSubviews() {
super.layoutSubviews()
updatePaths()
}
}
// MARK: - Private utility methods
private extension ArcView {
func configure() {
layer.addSublayer(totalShapeLayer)
layer.addSublayer(progressShapeLayer)
layer.addSublayer(currentPositionDotLayer)
}
func updatePaths() {
let rect = bounds.insetBy(dx: lineWidth / 2, dy: lineWidth / 2)
let halfWidth = rect.width / 2
let height = rect.height
let theta = atan(halfWidth / height)
let radius = sqrt(halfWidth * halfWidth + height * height) / 2 / cos(theta)
let center = CGPoint(x: rect.midX, y: rect.minY + radius)
let delta = (.pi / 2 - theta) * 2
let startAngle = .pi * 3 / 2 - delta
let endAngle = .pi * 3 / 2 + delta
let path = UIBezierPath(arcCenter: center,
radius: radius,
startAngle: startAngle,
endAngle: endAngle,
clockwise: true)
progressShapeLayer.path = path.cgPath // white arc
totalShapeLayer.path = path.cgPath // pink arc
progressShapeLayer.strokeEnd = progress
let currentAngle = (endAngle - startAngle) * progress + startAngle
let dotCenter = CGPoint(x: center.x + radius * cos(currentAngle),
y: center.y + radius * sin(currentAngle))
currentPositionDotLayer.position = dotCenter
}
}
Above, I set the background color of the ArcView so you could see its bounds, but you would obviously set the background color to be transparent.
Now there are tons of additional features you might add (e.g. add user interaction so you could “scrub” it, etc.). See https://github.com/robertmryan/ArcView for example. But the key when designing this sort of stuff is to just break it down into its constituent elements, layering one on top of the other.
You can programmatically set the progress of the arcView to get it to change the current value between values of 0 and 1:
func startUpdating() {
arcView.progress = 0
var current = 0
Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] timer in
current += 1
guard let self = self, current <= 10 else {
timer.invalidate()
return
}
self.arcView.progress = CGFloat(current) / 10
}
}
Resulting in:

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()

Color animation

I've written simple animations for drawing rectangles in lines, we can treat them as a bars.
Each bar is one shape layer which has a path which animates ( size change and fill color change ).
#IBDesignable final class BarView: UIView {
lazy var pathAnimation: CABasicAnimation = {
let animation = CABasicAnimation(keyPath: "path")
animation.duration = 1
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
animation.fillMode = kCAFillModeBoth
animation.isRemovedOnCompletion = false
return animation
}()
let red = UIColor(red: 249/255, green: 26/255, blue: 26/255, alpha: 1)
let orange = UIColor(red: 1, green: 167/255, blue: 463/255, alpha: 1)
let green = UIColor(red: 106/255, green: 239/255, blue: 47/255, alpha: 1)
lazy var backgroundColorAnimation: CABasicAnimation = {
let animation = CABasicAnimation(keyPath: "fillColor")
animation.duration = 1
animation.fromValue = red.cgColor
animation.byValue = orange.cgColor
animation.toValue = green.cgColor
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
animation.fillMode = kCAFillModeBoth
animation.isRemovedOnCompletion = false
return animation
}()
#IBInspectable var spaceBetweenBars: CGFloat = 10
var numberOfBars: Int = 5
let data: [CGFloat] = [5.5, 9.0, 9.5, 3.0, 8.0]
override func awakeFromNib() {
super.awakeFromNib()
initSublayers()
}
override func layoutSubviews() {
super.layoutSubviews()
setupLayers()
}
func setupLayers() {
let width = bounds.width - (spaceBetweenBars * CGFloat(numberOfBars + 1)) // There is n + 1 spaces between bars.
let barWidth: CGFloat = width / CGFloat(numberOfBars)
let scalePoint: CGFloat = bounds.height / 10.0 // 10.0 - 10 points is max
guard let sublayers = layer.sublayers as? [CAShapeLayer] else { return }
for i in 0...numberOfBars - 1 {
let barHeight: CGFloat = scalePoint * data[i]
let shapeLayer = CAShapeLayer()
layer.addSublayer(shapeLayer)
var xPos: CGFloat!
if i == 0 {
xPos = spaceBetweenBars
} else if i == numberOfBars - 1 {
xPos = bounds.width - (barWidth + spaceBetweenBars)
} else {
xPos = barWidth * CGFloat(i) + spaceBetweenBars * CGFloat(i) + spaceBetweenBars
}
let startPath = UIBezierPath(rect: CGRect(x: xPos, y: bounds.height, width: barWidth, height: 0)).cgPath
let endPath = UIBezierPath(rect: CGRect(x: xPos, y: bounds.height, width: barWidth, height: -barHeight)).cgPath
sublayers[i].path = startPath
pathAnimation.toValue = endPath
sublayers[i].removeAllAnimations()
sublayers[i].add(pathAnimation, forKey: "path")
sublayers[i].add(backgroundColorAnimation, forKey: "backgroundColor")
}
}
func initSublayers() {
for _ in 1...numberOfBars {
let shapeLayer = CAShapeLayer()
layer.addSublayer(shapeLayer)
}
}
}
The size ( height ) of bar depends of the data array, each sublayers has a different height. Based on this data I've crated a scale.
PathAnimation is changing height of the bars.
BackgroundColorAnimation is changing the collors of the path. It starts from red one, goes through the orange and finish at green.
My goal is to connect backgroundColorAnimation with data array as well as it's connected with pathAnimation.
Ex. When in data array is going to be value 1.0 then the bar going to be animate only to the red color which is a derivated from a base red color which is declared as a global variable. If the value in the data array going to be ex. 4.5 then the color animation will stop close to the delcared orange color, the 5.0 limit going to be this orange color or color close to this. Value closer to 10 going to be green.
How could I connect these conditions with animation properties fromValue, byValue, toValue. Is it an algorithm for that ? Any ideas ?
You have several problems.
You're setting fillMode and isRemovedOnCompletion. This tells me, to be blunt, that you don't understand Core Animation. You need to watch WWDC 2011 Session 421: Core Animation Essentials.
You're adding more layers every time layoutSubviews is called, but not doing anything with them.
You're adding animation every time layoutSubviews runs. Do you really want to re-animate the bars when the double-height “in-call” status bar appears or disappears, or on an interface rotation? It's probably better to have a separate animateBars() method, and call it from your view controller's viewDidAppear method.
You seem to think byValue means “go through this value on the way from fromValue to toValue”, but that's not what it means. byValue is ignored in your case, because you're setting fromValue and toValue. The effects of byValue are explained in Setting Interpolation Values.
If you want to interpolate between colors, it's best to use a hue-based color space, but I believe Core Animation uses an RGB color space. So you should use a keyframe animation to specify intermediate colors that you calculate by interpolating in a hue-based color space.
Here's a rewrite of BarView that fixes all these problems:
#IBDesignable final class BarView: UIView {
#IBInspectable var spaceBetweenBars: CGFloat = 10
var data: [CGFloat] = [5.5, 9.0, 9.5, 3.0, 8.0]
var maxDatum = CGFloat(10)
func animateBars() {
guard window != nil else { return }
let bounds = self.bounds
var flatteningTransform = CGAffineTransform.identity.translatedBy(x: 0, y: bounds.size.height).scaledBy(x: 1, y: 0.001)
let duration: CFTimeInterval = 1
let frames = Int((duration * 60.0).rounded(.awayFromZero))
for (datum, barLayer) in zip(data, barLayers) {
let t = datum / maxDatum
if let path = barLayer.path {
let path0 = path.copy(using: &flatteningTransform)
let pathAnimation = CABasicAnimation(keyPath: "path")
pathAnimation.duration = 1
pathAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
pathAnimation.fromValue = path0
barLayer.add(pathAnimation, forKey: pathAnimation.keyPath)
let colors = gradient.colors(from: 0, to: t, count: frames).map({ $0.cgColor })
let colorAnimation = CAKeyframeAnimation(keyPath: "fillColor")
colorAnimation.timingFunction = pathAnimation.timingFunction
colorAnimation.duration = duration
colorAnimation.values = colors
barLayer.add(colorAnimation, forKey: colorAnimation.keyPath)
}
}
}
override func layoutSubviews() {
super.layoutSubviews()
createOrDestroyBarLayers()
let bounds = self.bounds
let barSpacing = (bounds.size.width - spaceBetweenBars) / CGFloat(data.count)
let barWidth = barSpacing - spaceBetweenBars
for ((offset: i, element: datum), barLayer) in zip(data.enumerated(), barLayers) {
let t = datum / maxDatum
let barHeight = t * bounds.size.height
barLayer.frame = bounds
let rect = CGRect(x: spaceBetweenBars + CGFloat(i) * barSpacing, y: bounds.size.height, width: barWidth, height: -barHeight)
barLayer.path = CGPath(rect: rect, transform: nil)
barLayer.fillColor = gradient.color(at: t).cgColor
}
}
private let gradient = Gradient(startColor: .red, endColor: .green)
private var barLayers = [CAShapeLayer]()
private func createOrDestroyBarLayers() {
while barLayers.count < data.count {
barLayers.append(CAShapeLayer())
layer.addSublayer(barLayers.last!)
}
while barLayers.count > data.count {
barLayers.removeLast().removeFromSuperlayer()
}
}
}
private extension UIColor {
var hsba: [CGFloat] {
var hue: CGFloat = 0
var saturation: CGFloat = 0
var brightness: CGFloat = 0
var alpha: CGFloat = 0
getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
return [hue, saturation, brightness, alpha]
}
}
private struct Gradient {
init(startColor: UIColor, endColor: UIColor) {
self.startColor = startColor
self.startHsba = startColor.hsba
self.endColor = endColor
self.endHsba = endColor.hsba
}
let startColor: UIColor
let endColor: UIColor
let startHsba: [CGFloat]
let endHsba: [CGFloat]
func color(at t: CGFloat) -> UIColor {
let out = zip(startHsba, endHsba).map { $0 * (1.0 - t) + $1 * t }
return UIColor(hue: out[0], saturation: out[1], brightness: out[2], alpha: out[3])
}
func colors(from t0: CGFloat, to t1: CGFloat, count: Int) -> [UIColor] {
var colors = [UIColor]()
colors.reserveCapacity(count)
for i in 0 ..< count {
let s = CGFloat(i) / CGFloat(count - 1)
let t = t0 * (1 - s) + t1 * s
colors.append(color(at: t))
}
return colors
}
}
Result:

How to make a custom semi-circular progress indicator with terminator?

I'd like to make a custom semi-circular inverted progress indicator that looks like this:
I've tried with CAShapeLayer for background, progress line and progress line terminator (small white circle). I'm using ring.strokeEnd = progress to define where the progress line ends.
How to add the line terminator to the end of progress line?
Here's my code (note: I've hardcoded the position for the small white circle, as I don't know how to properly implement this):
class AKBezierson: UIView {
var ringBackground: CAShapeLayer!
var ring: CAShapeLayer!
var ringTerminator: CAShapeLayer!
var innerRect: CGRect!
var radius: CGFloat { return min(innerRect.width, innerRect.height) / 2 }
var progress: CGFloat = 0.8 { didSet { updateLayerProperties() } }
private struct Const {
static let gap: CGFloat = 10
static let width: CGFloat = 3.0
}
override func layoutSubviews() {
super.layoutSubviews()
innerRect = CGRectInset(bounds, Const.width / 2 + Const.gap, Const.width / 2 + Const.gap)
let path = UIBezierPath()
path.addArcWithCenter(
CGPointMake(bounds.width / 2, bounds.height / 2),
radius: radius,
startAngle: CGFloat(0),
endAngle: CGFloat(M_PI),
clockwise: true)
if (ringBackground == nil) {
ringBackground = CAShapeLayer()
layer.addSublayer(ringBackground)
ringBackground.path = path.CGPath
ringBackground.fillColor = nil
ringBackground.lineWidth = Const.width
ringBackground.strokeColor = UIColor(white: 1.0, alpha: 0.5).CGColor
}
ringBackground.frame = ringBackground.bounds
if (ring == nil) {
ring = CAShapeLayer()
layer.addSublayer(ring)
ring.path = path.CGPath
ring.fillColor = nil
ring.lineWidth = Const.width
ring.strokeColor = UIColor.whiteColor().CGColor
ring.strokeEnd = progress
}
ring.frame = ring.bounds
if (ringTerminator == nil) { // small circle
ringTerminator = CAShapeLayer()
layer.addSublayer(ringTerminator)
let terminatorCircle = UIBezierPath(ovalInRect: CGRectMake(15, 83, 12, 12))
ringTerminator.path = terminatorCircle.CGPath
ringTerminator.fillColor = UIColor.whiteColor().CGColor
ringTerminator.lineWidth = Const.width
ringTerminator.strokeColor = UIColor.whiteColor().CGColor
}
ringTerminator.frame = ringTerminator.bounds
updateLayerProperties()
}
func updateLayerProperties () {
if !(ring == nil) {
ring.strokeEnd = progress
}
}
}
I've also tried to calculate the point where the progress line ends and move the line terminator to a new position every time the progress changed, but I was not satisfied with the animation since the terminator was trying to reach the new position following the shortest (strait) path.