iOS Swift circle chart - swift

Any suggestion how to implement the following progress chart for iOS Swift?

Just break this down into individual steps.
The first question is how to draw the individual tickmarks.
One way is to draw four strokes using a UIBezierPath:
a clockwise arc at the outer radius;
a line to the inner radius;
a counter-clockwise arc at the inner radius; and
a line back out to the outer radius.
Turns out, you can skip the two lines, and just add those two arcs, and then close the path and you’re done. The UIBezierPath will add the lines between the two arcs for you. E.g.:
let startAngle: CGFloat = 2 * .pi * (CGFloat(i) - 0.2) / CGFloat(tickCount)
let endAngle: CGFloat = 2 * .pi * (CGFloat(i) + 0.2) / CGFloat(tickCount)
// create path for individual tickmark
let path = UIBezierPath()
path.addArc(withCenter: center, radius: outerRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
path.addArc(withCenter: center, radius: innerRadius, startAngle: endAngle, endAngle: startAngle, clockwise: false)
path.close()
// use that path in a `CAShapeLayer`
let shapeLayer = CAShapeLayer()
shapeLayer.fillColor = …
shapeLayer.strokeColor = UIColor.clear.cgColor
shapeLayer.path = path.cgPath
// add it to our view’s `layer`
view.layer.addSublayer(shapeLayer)
Repeat this for i between 0 and tickCount, where tickCount is 90, and you have ninety tickmarks:
Obviously, use whatever colors you want, make the ones outside your progress range gray, etc. But hopefully this illustrates the basic idea of how to use UIBezierPath to render two arcs and fill the shape for each respective tick mark with a specified color.
E.g.
class CircularTickView: UIView {
var progress: CGFloat = 0.7 { didSet { setNeedsLayout() } }
private var shapeLayers: [CAShapeLayer] = []
private let startHue: CGFloat = 0.33
private let endHue: CGFloat = 0.66
override func layoutSubviews() {
super.layoutSubviews()
shapeLayers.forEach { $0.removeFromSuperlayer() }
shapeLayers = []
let outerRadius = min(bounds.width, bounds.height) / 2
let innerRadius = outerRadius * 0.7
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let tickCount = 90
for i in 0 ..< tickCount {
let shapeLayer = CAShapeLayer()
shapeLayer.fillColor = color(percent: CGFloat(i) / CGFloat(tickCount)).cgColor
shapeLayer.strokeColor = UIColor.clear.cgColor
let startAngle: CGFloat = 2 * .pi * (CGFloat(i) - 0.2) / CGFloat(tickCount)
let endAngle: CGFloat = 2 * .pi * (CGFloat(i) + 0.2) / CGFloat(tickCount)
let path = UIBezierPath()
path.addArc(withCenter: center, radius: outerRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
path.addArc(withCenter: center, radius: innerRadius, startAngle: endAngle, endAngle: startAngle, clockwise: false)
path.close()
shapeLayer.path = path.cgPath
layer.addSublayer(shapeLayer)
shapeLayers.append(shapeLayer)
}
}
private func color(percent: CGFloat) -> UIColor {
if percent > progress {
return .lightGray
}
let hue = (endHue - startHue) * percent + startHue
return UIColor(hue: hue, saturation: 1, brightness: 1, alpha: 1)
}
}
Clearly, you will want to tweak as you see fit. Perhaps change the color algorithm. Perhaps have it start from 12 o’clock rather than 3 o’clock. Etc. The details are less important than groking the basic idea of how you add shape layers with paths to your UI.

Related

How to call UIBezier outline in Swift Playground?

EDIT: Sorry, I wasn't clear originally. I want to get the "outline" path of a line or shape. I'm specifically trying to understand how to use:
context.replacePathWithStrokedPath()
and / or:
CGPathRef CGPathCreateCopyByStrokingPath(CGPathRef path, const CGAffineTransform *transform, CGFloat lineWidth, CGLineCap lineCap, CGLineJoin lineJoin, CGFloat miterLimit);
https://developer.apple.com/documentation/coregraphics/1411128-cgpathcreatecopybystrokingpath?language=objc
I'm not looking for workarounds, thanks.
=====
I'm really trying to wrap my head around drawing a line with an outline around it. i'm using UIBezier, but running into brick walls. So far, I've got this:
import UIKit
import PlaygroundSupport
let screenWidth = 375.0 // points
let screenHeight = 467.0 // points
let centerX = screenWidth / 2.0
let centerY = screenHeight / 2.0
let screenCenterCoordinate = CGPoint(x: centerX, y: centerY)
class LineDrawingView: UIView {
override func draw(_ rect: CGRect) {
let path = UIBezierPath()
path.lineWidth = 5
path.lineCapStyle = .round
//Move to Drawing Point
path.move(to: CGPoint(x:20, y:120))
path.addLine(to: CGPoint(x:200, y:120))
path.stroke()
let dot = UIBezierPath()
dot.lineWidth = 1
dot.lineCapStyle = .round
dot.move(to: CGPoint(x:200, y:120))
dot.addArc(withCenter: CGPoint(x:200, y:120), radius: 5, startAngle: CGFloat(0.0), endAngle: CGFloat(8.0), clockwise: true)
UIColor.orange.setStroke()
UIColor.orange.setFill()
path.stroke()
dot.fill()
let myStrokedPath = UIBezierPath.copy(path)
myStrokedPath().stroke()
}
}
let tView = LineDrawingView(frame: CGRect(x: 0,y: 0, width: screenWidth, height: screenHeight))
tView.backgroundColor = UIColor.white
PlaygroundPage.current.liveView = tView
So, where am I going wrong in this? I cannot seem to figure out where to use CGPathCreateCopyByStrokingPath...or how...
EDIT 2:
Ok, now I've got this. Closer, but how do I fill the path again?
let c = UIGraphicsGetCurrentContext()!
c.setLineWidth(15.0)
let clipPath = UIBezierPath(arcCenter: CGPoint(x:centerX,y:centerY), radius: 90.0, startAngle: -0.5 * .pi, endAngle: 1.0 * .pi, clockwise: true).cgPath
c.addPath(clipPath)
c.saveGState()
c.replacePathWithStrokedPath()
c.setLineWidth(0.2)
c.setStrokeColor(UIColor.black.cgColor)
c.strokePath()
The class was modified slightly to produce this graphic:
The path was not copied in the modified code. Instead the existing path was used to draw, then modified and reused. The dot did not have a stroke so that was added. Since only closed paths can be filled, I drew a thinner path on top of a thicker path by changing the line width.
This is the modified code:
class LineDrawingView: UIView {
override func draw(_ rect: CGRect) {
let path = UIBezierPath()
path.lineWidth = 7
path.lineCapStyle = .round
//Move to Drawing Point
path.move(to: CGPoint(x:20, y:120))
path.addLine(to: CGPoint(x:200, y:120))
path.stroke()
let dot = UIBezierPath()
dot.lineWidth = 1
dot.lineCapStyle = .round
dot.move(to: CGPoint(x:200, y:120))
dot.addArc(withCenter: CGPoint(x:200, y:120), radius: 5, startAngle: CGFloat(0.0), endAngle: CGFloat(8.0), clockwise: true)
dot.stroke()
UIColor.orange.setStroke()
UIColor.orange.setFill()
path.lineWidth = 5
path.stroke()
dot.fill()
}
}
So, I've found the (an) answer. I used CAShapeLayer:
let c = UIGraphicsGetCurrentContext()!
c.setLineCap(.round)
c.setLineWidth(15.0)
c.addArc(center: CGPoint(x:centerX,y:centerY), radius: 90.0, startAngle: -0.5 * .pi, endAngle: (-0.5 * .pi) + (3 / 2 * .pi ), clockwise: false)
c.replacePathWithStrokedPath()
let shape = CAShapeLayer()
shape.path = c.path
shape.fillColor = UIColor.yellow.cgColor
shape.strokeColor = UIColor.darkGray.cgColor
shape.lineWidth = 1
myView.layer.addSublayer(shape)
It works well enough, but not on overlapping layers. I need to learn how to connect contours or something.

Swift Draw Custom Circle [duplicate]

This question already has answers here:
Animate CAShapeLayer path change
(1 answer)
Circular Progress Bars in IOS
(7 answers)
Closed 4 years ago.
I am trying to implement a custom circular analysis view.
The view should be circular but cut-off.
Goal:
My Code:
let circlePath = UIBezierPath(ovalIn: CGRect(x: innerRect.minX, y: innerRect.minY, width: innerRect.width, height: innerRect.height))
if trackBackgroundColor != UIColor.clear {
trackBackgroundColor.setFill()
circlePath.fill();
}
if trackBorderWidth > 0 {
circlePath.lineWidth = trackBorderWidth
trackBorderColor.setStroke()
circlePath.stroke()
}
// progress Drawing
let progressPath = UIBezierPath()
let progressRect: CGRect = CGRect(x: innerRect.minX, y: innerRect.minY, width: innerRect.width, height: innerRect.height)
let center = CGPoint(x: progressRect.midX, y: progressRect.midY)
let radius = progressRect.width / 2.0
let startAngle:CGFloat = clockwise ? CGFloat(-internalProgress * Double.pi / 180.0) : CGFloat(constants.twoSeventyDegrees * Double.pi / 180)
let endAngle:CGFloat = clockwise ? CGFloat(constants.twoSeventyDegrees * Double.pi / 180) : CGFloat(-internalProgress * Double.pi / 180.0)
progressPath.addArc(withCenter: center, radius:radius, startAngle:startAngle, endAngle:endAngle, clockwise:!clockwise)
Current Output:
How do I draw the custom circle described as Goal.
Here's a rough implementation of what you need. This draws the two arcs.
class CircularProgressView: UIView {
var trackBackgroundColor = UIColor.lightGray
var trackBorderWidth: CGFloat = 10
var progressColor = UIColor.red
var percent: Double = 0 {
didSet {
setNeedsDisplay()
}
}
// Adjust these to meet your needs. 90 degrees is the bottom of the circle
static let startDegrees: CGFloat = 120
static let endDegrees: CGFloat = 60
override func draw(_ rect: CGRect) {
let startAngle: CGFloat = radians(of: CircularProgressView.startDegrees)
let endAngle: CGFloat = radians(of: CircularProgressView.endDegrees)
let progressAngle = radians(of: CircularProgressView.startDegrees + (360 - CircularProgressView.startDegrees + CircularProgressView.endDegrees) * CGFloat(max(0.0, min(percent, 1.0))))
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let radius = min(center.x, center.y) - trackBorderWidth / 2 - 10
print(startAngle, endAngle, progressAngle)
let trackPath = UIBezierPath(arcCenter: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
let progressPath = UIBezierPath(arcCenter: center, radius: radius, startAngle: startAngle, endAngle: progressAngle, clockwise: true)
trackPath.lineWidth = trackBorderWidth
trackPath.lineCapStyle = .round
progressPath.lineWidth = trackBorderWidth
progressPath.lineCapStyle = .round
trackBackgroundColor.set()
trackPath.stroke()
progressColor.set()
progressPath.stroke()
}
private func radians(of degrees: CGFloat) -> CGFloat {
return degrees / 180 * .pi
}
}
let progress = CircularProgressView(frame: CGRect(x: 0, y: 0, width: 500, height: 400))
progress.backgroundColor = .white
progress.percent = 0.95

How to crop the UIView as semi circle?

I want to crop UIView in semi circle shape
Thanks in advance.
A convenient way is just subclass a UIView, add a layer on it and make the view color transparent if it's not by default.
import UIKit
class SemiCirleView: UIView {
var semiCirleLayer: CAShapeLayer!
override func layoutSubviews() {
super.layoutSubviews()
if semiCirleLayer == nil {
let arcCenter = CGPoint(x: bounds.size.width / 2, y: bounds.size.height / 2)
let circleRadius = bounds.size.width / 2
let circlePath = UIBezierPath(arcCenter: arcCenter, radius: circleRadius, startAngle: CGFloat.pi, endAngle: CGFloat.pi * 2, clockwise: true)
semiCirleLayer = CAShapeLayer()
semiCirleLayer.path = circlePath.cgPath
semiCirleLayer.fillColor = UIColor.red.cgColor
layer.addSublayer(semiCirleLayer)
// Make the view color transparent
backgroundColor = UIColor.clear
}
}
}
This question was already answered here: Draw a semi-circle button iOS
This is a extract of that question but using a UIView:
Swift 3
let myView = [this should be your view]
let circlePath = UIBezierPath(arcCenter: CGPoint(x: myView.bounds.size.width / 2, y: 0), radius: myView.bounds.size.height, startAngle: 0.0, endAngle: CGFloat(M_PI), clockwise: true)
let circleShape = CAShapeLayer()
circleShape.path = circlePath.cgPath
myView.layer.mask = circleShape
Swift 4
let myView = [this should be your view]
let circlePath = UIBezierPath(arcCenter: CGPoint(x: myView.bounds.size.width / 2, y: 0), radius: myView.bounds.size.height, startAngle: 0.0, endAngle: .pi, clockwise: true)
let circleShape = CAShapeLayer()
circleShape.path = circlePath.cgPath
myView.layer.mask = circleShape
I hope this helps you
it works in swift 4 and swift 5 for this issue.
let yourView = (is the view you want to crop semi circle from top)
let circlePath = UIBezierPath(arcCenter: CGPoint(x: yourView.bounds.size.width / 2, y: yourView.bounds.size.height), radius: yourView.bounds.size.height, startAngle: 0.0, endAngle: .pi, clockwise: false)
let circleShape = CAShapeLayer()
circleShape.path = circlePath.cgPath
yourView.layer.mask = circleShape

Drawing shapes using UIBezierPath in swift

I dont know how to draw shape using UIBezierPath.Anyways i tried with lot of ways but did not successes.
I want to make the below shapes
Please help
func drawSliceInView(view: UIView, startAngle: CGFloat, endAngle: CGFloat, radius: CGFloat)
{
let centerPoint = CGPoint(x: view.frame.size.width / 2, y: view.frame.size.width / 2)
let startValue = (startAngle * 2 * CGFloat(M_PI)) / 360 - CGFloat(M_PI_2)
let endValue = (endAngle * 2 * CGFloat(M_PI)) / 360 - CGFloat(M_PI_2)
let path = UIBezierPath(arcCenter: centerPoint, radius: radius, startAngle: startValue, endAngle: endValue, clockwise: true)
path.addLineToPoint(centerPoint)
path.closePath()
let sliceLayer = CAShapeLayer()
sliceLayer.path = path.CGPath
sliceLayer.fillColor = UIColor.blueColor().CGColor
sliceLayer.backgroundColor = nil
sliceLayer.strokeColor = UIColor.whiteColor().CGColor
sliceLayer.lineWidth = 2.0
view.layer.addSublayer(sliceLayer)
}
Usage example:
drawSliceInView(self.view, startAngle: -30, endAngle: 90, radius: 50)
drawSliceInView(self.view, startAngle: 90, endAngle: 210, radius: 50)

Drawing a partial circle

I'm writing a program that will take a number between 0 and 1, and then spits out a circle (or arc I guess) that is completed by that much.
So for example, if 0.5 was inputted, the program would output a semicircle
if 0.1, the program would output a tiny little arc that would ultimately be 10% of the whole circle.
I can get this to work by making the angle starting point 0, and the angle ending point 2*M_PI*decimalInput
However, I need to have the starting point at the top of the circle, so the starting point is 3*M_PI_2 and the ending point would be 7*M_PI_2
I'm just having trouble drawing a circle partially complete with these new starting/ending points. And I'll admit, my math is not the best so any advice/input is appreciated
Here is what I have so far
var decimalInput = 0.75 //this number can be any number between 0 and 1
let start = CGFloat(3*M_PI_2)
let end = CGFloat(7*M_PI_2*decimalInput)
let circlePath = UIBezierPath(arcCenter: circleCenter, radius: circleRadius, startAngle: start, endAngle: end, clockwise: true)
circlePath.stroke()
I just cannot seem to get it right despite what I try. I reckon the end angle is culprit, unless I'm going about this the wrong way
The arc length is 2 * M_PI * decimalInput. You need to add the arc length to the starting angle, like this:
let circleCenter = CGPointMake(100, 100)
let circleRadius = CGFloat(80)
var decimalInput = 0.75
let start = CGFloat(3 * M_PI_2)
let end = start + CGFloat(2 * M_PI * decimalInput)
let circlePath = UIBezierPath(arcCenter: circleCenter, radius: circleRadius, startAngle: start, endAngle: end, clockwise: true)
XCPCaptureValue("path", circlePath)
Result:
Note that the path will be flipped vertically when used to draw in a UIView.
You can use this extension to draw a partial circle
extension UIBezierPath {
func addCircle(center: CGPoint, radius: CGFloat, startAngle: Double, circlePercentage: Double) {
let start = deg2rad(startAngle)
let end = start + CGFloat(2 * Double.pi * circlePercentage)
addArc(withCenter: center,
radius: radius,
startAngle: start,
endAngle: end,
clockwise: true)
}
private func deg2rad(_ number: Double) -> CGFloat {
return CGFloat(number * Double.pi / 180)
}
}
Example usage (you can copy paste it in a playground to see the result)
let view = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
view.backgroundColor = UIColor.green
let layer = CAShapeLayer()
layer.strokeColor = UIColor.red.cgColor
layer.fillColor = UIColor.clear.cgColor
layer.lineWidth = 8
let path = UIBezierPath()
path.addCircle(center: CGPoint(x: 50, y: 50), radius: 50, startAngle: 270, circlePercentage: 0.87)
layer.path = path.cgPath
view.layer.addSublayer(layer)
view.setNeedsLayout()