Can I use UIBezier to draw an oval progress bar - swift

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!!!

Related

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:

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

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

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

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)