Reload values for draw circle - swift

Hi I'm creating a semicircle that updates your line widh depending on the values I give you that are always varying. I want to know how to update the values to determine the end angle and that these are always updated. Thanks
class Circle: UIView {
//These 2 var (total dictionary and Secondary dictionary) have the number of the keys and the values ​​are always varying because they are obtained from json data
var dictionaryTotal: [String:String] = ["a":"153.23", "b":"162.45", "c":"143.65", "d":"140.78", "e":"150.23"]
var dictionarySecundario: [String:String] = ["w":"153.23", "l":"162.45", "v":"143.65"]
var lineWidth: CGFloat = 25
// circles radius
var half = CGFloat.pi
var quarter = CGFloat(3 * CGFloat.pi / 2)
func drawCircleCenteredAtVariavel(center:CGPoint, withRadius radius:CGFloat) -> UIBezierPath{
let circlePath = UIBezierPath(arcCenter: centerOfCircleView, radius: halfOfViewsSize, startAngle: half, endAngle: CGFloat(((CGFloat(self.dictionarySecundario.count) * CGFloat.pi) / CGFloat(self. dictionaryTotal.count)) + CGFloat.pi), clockwise: true)
circlePath.lineWidth = lineWidth
UIColor(red: 0, green: 0.88, blue: 0.70, alpha: 1).set()
return circlePath
}
override func draw(_ rect: CGRect) {
drawCircleCenteredAtVariavel(center: centerOfCircleView, withRadius: halfOfViewsSize).stroke()
}
}
This is the picture of semicircle

It's not clear from your question what you're trying to accomplish, so this isn't really answer, but it's more than I wanted to type in a comment.
The first thing you should do is separate responsibilities in so the Circle class above should only be responsible for drawing the semicircle for a particular configuration.
For example, a semicircular progress view based on your code above:
class CircleProgressView: UIView {
var lineWidth: CGFloat = 25
var barColor = UIColor(red: 0, green: 0.88, blue: 0.70, alpha: 1)
var progress: Float = 0
{
didSet {
progress = max(min(progress, 1.0), 0.0)
setNeedsDisplay()
}
}
func semicirclePath(at center: CGPoint, radius: CGFloat, percentage: Float) -> UIBezierPath {
let endAngle = CGFloat.pi + (CGFloat(percentage) * CGFloat.pi)
let circlePath = UIBezierPath(arcCenter: center, radius: radius, startAngle: CGFloat.pi, endAngle: endAngle, clockwise: true)
circlePath.lineWidth = lineWidth
return circlePath
}
override func draw(_ rect: CGRect) {
barColor.set()
let center = CGPoint.init(x: bounds.midX, y: bounds.midY)
let radius = max(bounds.width, bounds.height)/2 - lineWidth/2
semicirclePath(at: center, radius: radius, percentage: progress).stroke()
}
}
Then, outside of this class, you would have other code that would determine what progress is based on the JSON values you're receiving.

Assuming that your drawCircleCenteredAtVariavel works, you should redraw whenever the lineWidth value is changed. Try this
var lineWidth: CGFloat = 25 {
didSet {
setNeedsDisplay()
}
}
override func draw(_ rect: CGRect) {
super.draw(rect)
drawCircleCenteredAtVariavel(center: centerOfCircleView, withRadius: halfOfViewsSize).stroke()
}

Related

Giving a CAShapeLayer a 3d effect

I am playing around with countdown timer in swift. What I want to give the countdown circle a 3D shadow of effect.
the code below will operate the countdown time perfectly but i guess its more of the visuals i am trying to edit with it. is there any way to make the countdown circle larger and while giving it a 3d effect. If you run the code you will see it is just a 2d type of fill. I have been playing around with overlapping circles with a different color and alpha like a dark color to try and make it look more 3d, but Its definitely not the most efficient because it involves drawing multiple circles at once. Is there a way to get a similar 3d effect like the image below without having to redraw multiple overlapping circles. the code below is for the basic 2d flat version of the counter.
//for countdown timer: ----------------
let timeLeftShapeLayer = CAShapeLayer()
let bgShapeLayer = CAShapeLayer()
var timeLeft: TimeInterval = 10
var endTime: Date?
var timeLabel = UILabel()
var timer = Timer()
// here you create your basic animation object to animate the strokeEnd
let strokeIt = CABasicAnimation(keyPath: "strokeEnd")
func drawBgShape() {
bgShapeLayer.path = UIBezierPath(arcCenter: CGPoint(x: view.frame.midX , y: view.frame.midY), radius:
100, startAngle: -90.degreesToRadians, endAngle: 270.degreesToRadians, clockwise: true).cgPath
bgShapeLayer.strokeColor = UIColor.white.cgColor
bgShapeLayer.fillColor = UIColor.clear.cgColor
bgShapeLayer.lineWidth = 15
view.layer.addSublayer(bgShapeLayer)
}
func drawTimeLeftShape() {
timeLeftShapeLayer.path = UIBezierPath(arcCenter: CGPoint(x: view.frame.midX , y: view.frame.midY), radius:
100, startAngle: -90.degreesToRadians, endAngle: 270.degreesToRadians, clockwise: true).cgPath
timeLeftShapeLayer.strokeColor = UIColor.red.cgColor
timeLeftShapeLayer.fillColor = UIColor.clear.cgColor
timeLeftShapeLayer.lineWidth = 15
view.layer.addSublayer(timeLeftShapeLayer)
}
func addTimeLabel() {
timeLabel = UILabel(frame: CGRect(x: view.frame.midX-50 ,y: view.frame.midY/3, width: 100, height: 50))
timeLabel.textAlignment = .center
timeLabel.text = timeLeft.time
view.addSubview(timeLabel)
}
#objc func updateTime() {
if timeLeft > 0 {
timeLeft = endTime?.timeIntervalSinceNow ?? 0
timeLabel.text = timeLeft.time
} else {
timeLabel.text = "00:00"
timer.invalidate()
}
}
//-------------timer finish------------(extension for timer at bottom of file------
Extensions:
//extensions for timer
extension TimeInterval {
var time: String {
return String(format:"%02d:%02d", Int(self/60), Int(ceil(truncatingRemainder(dividingBy: 60))) )
}
}
extension Int {
var degreesToRadians : CGFloat {
return CGFloat(self) * .pi / 180
}
}
ViewDidload:
//for countdown timer: -------------
view.backgroundColor = UIColor(white: 0.94, alpha: 1.0)
drawBgShape()
drawTimeLeftShape()
addTimeLabel()
// here you define the fromValue, toValue and duration of your animation
strokeIt.fromValue = 0
strokeIt.toValue = 1
strokeIt.duration = timeLeft
// add the animation to your timeLeftShapeLayer
timeLeftShapeLayer.add(strokeIt, forKey: nil)
// define the future end time by adding the timeLeft to now Date()
endTime = Date().addingTimeInterval(timeLeft)
timer = Timer.scheduledTimer(timeInterval: 0.1, target: self, selector: #selector(updateTime), userInfo: nil, repeats: true)
To obtain 3D effects, you usually work with color gradients. In your use case, you would work with a radial CAGradientLayer. You have to mask this layer to see only the area you want to be visible. The path to be filled consists of the area of the outer and the inner circle.
This fill path can be created as follows:
let path = UIBezierPath(arcCenter: centerPoint, radius: outerRadius, startAngle: 0, endAngle: .pi * 2, clockwise: true)
let inner = UIBezierPath(arcCenter: centerPoint, radius: outerRadius - thickness, startAngle: 0, endAngle: .pi * 2, clockwise: true)
path.append(inner.reversing())
For the gradients, you can use the locations parameter to specify an array of NSNumber objects that define the location of the gradient stops. The values must be in the range [0,1]. The corresponding associated colors of type CGColor are set in colors property.
In a simple case you could define something like:
gradient.locations = [0, //0
NSNumber(value: innerRadius / outerRadius), //1
NSNumber(value: middle / outerRadius), //2
1] //3
let colors = [color, //0
color, //1
color.lighter(), //2
color, //3
]
gradient.colors = colors.map { $0.cgColor }
However, the desired 3D appearance will be visible only after applying a mask with the corresponding path, see the right part of the figure:
Animation
It is easy to see that you can use one CAGradientLayer for the background and one for the foreground. The question then naturally arises, how can we animate the fill process with the foreground gradient?
This can be achieved by placing the foreground gradient over the background gradient and using a CAShapeLayer as a mask for the foreground gradient. In doing so, the animation is done similarly to the example in your question using the strokeEnd property. Since it is a mask, the foreground gradient becomes visible gradually.
Gradients
Gradients can contain several areas. 3D effects are usually achieved by combining slightly lighter or darker gradations of similar colors. For the demo example, I used this nice, minimally modified answer to get lighter or darker color variants.
Demo
Using the above points, this may look like the following:
The colors, distances and the gradients depend of course strongly on the requirements, this serves only as an example, how one could make such a thing. For the foreground gradient, two similar but different colors were chosen for the shadow area (inner circular area) and the outer circular area, which is in the light.
Self-Contained Complete Example
CircleProgressView.swift
import UIKit
class CircleProgressView: UIView {
private let backgroundGradient = CAGradientLayer()
private let foregroundGradient = CAGradientLayer()
private let timeLeftShapeLayer = CAShapeLayer()
private let backgroundMask = CAShapeLayer()
private let thickness: CGFloat
private let innerBackgroundColor: UIColor
private let outerBackgroundColor: UIColor
private let innerForegroundColor: UIColor
private let outerForegroundColor: UIColor
init(_ thickness: CGFloat,
_ innerBackgroundColor: UIColor,
_ outerBackgroundColor: UIColor,
_ innerForegroundColor: UIColor,
_ outerForegroundColor: UIColor) {
self.thickness = thickness
self.innerBackgroundColor = innerBackgroundColor
self.outerBackgroundColor = outerBackgroundColor
self.innerForegroundColor = innerForegroundColor
self.outerForegroundColor = outerForegroundColor
super.init(frame: .zero)
backgroundGradient.type = .radial
layer.addSublayer(backgroundGradient)
foregroundGradient.type = .radial
layer.addSublayer(foregroundGradient)
timeLeftShapeLayer.strokeColor = UIColor.white.cgColor
timeLeftShapeLayer.fillColor = UIColor.clear.cgColor
timeLeftShapeLayer.lineWidth = thickness
layer.addSublayer(timeLeftShapeLayer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func circle(_ gradient: CAGradientLayer,
_ path: UIBezierPath,
_ outerRadius: CGFloat,
_ innerColor: UIColor,
_ outerColor: UIColor) {
let innerRadius = outerRadius - thickness
let middle = outerRadius - thickness / 2
let slice: CGFloat = thickness / 16
gradient.frame = bounds
gradient.locations = [0, //0
NSNumber(value: (innerRadius) / outerRadius), //1
NSNumber(value: (middle - slice) / outerRadius), //2
NSNumber(value: (middle) / outerRadius), //3
NSNumber(value: (middle + slice) / outerRadius), //4
1] //5
let colors = [innerColor, //0
innerColor, //1
innerColor.darker(), //2
outerColor, //3
outerColor.lighter(), //4
outerColor //5
]
gradient.colors = colors.map { $0.cgColor }
gradient.bounds = path.bounds
gradient.startPoint = CGPoint(x: 0.5, y: 0.5)
gradient.endPoint = CGPoint(x: 1, y: 1)
}
override func layoutSubviews() {
super.layoutSubviews()
let outerRadius: CGFloat = min(bounds.width, bounds.height) / 2.0
let centerPoint = CGPoint(x: bounds.midX, y: bounds.midY)
let path = UIBezierPath(arcCenter: centerPoint, radius: outerRadius, startAngle: 0, endAngle: .pi * 2, clockwise: true)
let inner = UIBezierPath(arcCenter: centerPoint, radius: outerRadius - thickness, startAngle: 0, endAngle: .pi * 2, clockwise: true)
path.append(inner.reversing())
circle(backgroundGradient, path, outerRadius, innerBackgroundColor, outerBackgroundColor)
backgroundMask.frame = bounds
backgroundMask.path = path.cgPath
backgroundMask.lineWidth = 0
backgroundGradient.mask = backgroundMask
circle(foregroundGradient, path, outerRadius, innerForegroundColor, outerForegroundColor)
let middlePath = UIBezierPath(arcCenter: centerPoint, radius: outerRadius - thickness / 2, startAngle: 0, endAngle: .pi * 2, clockwise: true)
middlePath.lineWidth = thickness
timeLeftShapeLayer.path = middlePath.cgPath
foregroundGradient.mask = timeLeftShapeLayer
timeLeftShapeLayer.strokeEnd = 0
}
func startAnimation() {
timeLeftShapeLayer.removeAllAnimations()
timeLeftShapeLayer.strokeEnd = 1
DispatchQueue.main.async {
let strokeIt = CABasicAnimation(keyPath: "strokeEnd")
strokeIt.fromValue = 0
strokeIt.toValue = 1
strokeIt.duration = 5
self.timeLeftShapeLayer.add(strokeIt, forKey: nil)
}
}
}
UIColor+Brightness.swift
Only for the sake of completeness, please note that the original can be found at https://stackoverflow.com/a/31466450.
import UIKit
extension UIColor {
func lighter(amount : CGFloat = 0.15) -> UIColor {
return hueColorWithBrightness(amount: 1 + amount)
}
func darker(amount : CGFloat = 0.15) -> UIColor {
return hueColorWithBrightness(amount: 1 - amount)
}
private func hueColorWithBrightness(amount: CGFloat) -> UIColor {
var hue: CGFloat = 0
var saturation: CGFloat = 0
var brightness: CGFloat = 0
var alpha: CGFloat = 0
if getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) {
return UIColor( hue: hue,
saturation: saturation,
brightness: brightness * amount,
alpha: alpha )
} else {
return self
}
}
}
ViewController.swift
The call is rather unsurprising and should look something like this:
import UIKit
class ViewController: UIViewController {
private var circleProgressView: CircleProgressView?
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(red: 0xBB / 0xFF, green: 0xBB / 0xFF, blue: 0xBB / 0xFF, alpha: 1)
let innerBackgroundColor = UIColor(red: 0x65 / 0xFF, green: 0x79 / 0xFF, blue: 0x85 / 0xFF, alpha: 1)
let outerBackgroundColor = innerBackgroundColor
let innerForegroundColor = UIColor(red: 0xCF / 0xFF, green: 0xC9 / 0xFF, blue: 0x22 / 0xFF, alpha: 1)
let outerForegroundColor = UIColor(red: 0xF3 / 0xFF, green: 0xCA / 0xFF, blue: 0x46 / 0xFF, alpha: 1)
let progressView = CircleProgressView(24, innerBackgroundColor, outerBackgroundColor, innerForegroundColor, outerForegroundColor)
circleProgressView = progressView
progressView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(progressView)
let button = UIButton()
button.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(button)
button.setTitle("Start", for: .normal)
button.setTitleColor(.blue, for: .normal)
button.addTarget(self, action: #selector(onStart), for: .touchUpInside)
let margin: CGFloat = 24
NSLayoutConstraint.activate([
progressView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: margin),
progressView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: margin),
progressView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -margin),
button.topAnchor.constraint(equalTo: progressView.bottomAnchor, constant: margin),
button.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: margin),
button.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -margin),
button.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -margin),
])
}
#objc func onStart() {
circleProgressView?.startAnimation()
}
}

WRONG LINE LENGHT shapeLayer.strokeEnd = 0.5 draws more than half of the circle [duplicate]

This question already has an answer here:
Stroke of CAShapeLayer stops at wrong point?
(1 answer)
Closed 1 year ago.
I want the line to end at the top with shapeLayer.strokeEnd = 1.0 and get a circle. the line must end here
but she goes on, it just can't be seen
full circle
when i specify a value of 0.5 i want to get half of the circle but i get much more
half circle
My code:
View
public func createCircleLine(progress: CGFloat, color: UIColor, width: CGFloat) {
let radius = (min(bounds.width, bounds.height) - circleLineWidth) / 2
let center = min(bounds.width, bounds.height) / 2
let bezierPath = UIBezierPath(arcCenter: CGPoint(x: center, y: center),
radius: radius,
startAngle: -CGFloat.pi / 2,
endAngle: 2 * CGFloat.pi,
clockwise: true)
let shapeLayer = CAShapeLayer()
shapeLayer.path = bezierPath.cgPath
shapeLayer.fillColor = nil
shapeLayer.strokeColor = circleProgressLineColor.cgColor
shapeLayer.lineWidth = circleLineWidth
shapeLayer.lineCap = .round
shapeLayer.strokeEnd = progress
layer.addSublayer(shapeLayer)
}
ViewController
class ViewController: UIViewController {
#IBOutlet weak var progressView: CircleProgressView!
override func viewDidLoad() {
super.viewDidLoad()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(false)
progressView.createCircleLine(progress: 1.0, color: .green, width: 10)
} }
I don’t understand why I can’t get the correct line length, the coordinates are correct
can i get the correct line length without CABasicAnimation ()?
You need to change the endAngle of your circle to 3 * CGFloat.pi / 2, so it is a complete cycle with no overlapping. The current circle you have has π/2 (90 degrees) overlap
let bezierPath = UIBezierPath(arcCenter: CGPoint(x: center, y: center),
radius: radius,
startAngle: -CGFloat.pi / 2,
endAngle: 3 * CGFloat.pi / 2,
clockwise: true)

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:

How to draw text when using layers in NSView with Swift

I'm using layers in my NSView as much of the display will change infrequently. This is working fine. I'm now at the point where I'd like to add text to the layer. This is not working.
To draw the text, I'm trying to use code from another answer on how to draw text on a curve. I've researched and nothing I've tried helps. I've created a Playground to demonstrate. I can see when I run the Playground that the code to display the text is actually called, but nothing is rendered to the screen. I'm sure I've got something wrong, just not sure what.
The playground is below, along with the code referenced from above (updated for Swift 5, NS* versus UI*, and comments removed for size).
import AppKit
import PlaygroundSupport
func centreArcPerpendicular(text str: String,
context: CGContext,
radius r: CGFloat,
angle theta: CGFloat,
colour c: NSColor,
font: NSFont,
clockwise: Bool){
let characters: [String] = str.map { String($0) }
let l = characters.count
let attributes = [NSAttributedString.Key.font: font]
var arcs: [CGFloat] = []
var totalArc: CGFloat = 0
for i in 0 ..< l {
arcs += [chordToArc(characters[i].size(withAttributes: attributes).width, radius: r)]
totalArc += arcs[i]
}
let direction: CGFloat = clockwise ? -1 : 1
let slantCorrection: CGFloat = clockwise ? -.pi / 2 : .pi / 2
var thetaI = theta - direction * totalArc / 2
for i in 0 ..< l {
thetaI += direction * arcs[i] / 2
centre(text: characters[i],
context: context,
radius: r,
angle: thetaI,
colour: c,
font: font,
slantAngle: thetaI + slantCorrection)
thetaI += direction * arcs[i] / 2
}
}
func chordToArc(_ chord: CGFloat, radius: CGFloat) -> CGFloat {
return 2 * asin(chord / (2 * radius))
}
func centre(text str: String,
context: CGContext,
radius r: CGFloat,
angle theta: CGFloat,
colour c: NSColor,
font: NSFont,
slantAngle: CGFloat) {
let attributes = [NSAttributedString.Key.foregroundColor: c, NSAttributedString.Key.font: font]
context.saveGState()
context.translateBy(x: r * cos(theta), y: -(r * sin(theta)))
context.rotate(by: -slantAngle)
let offset = str.size(withAttributes: attributes)
context.translateBy (x: -offset.width / 2, y: -offset.height / 2)
str.draw(at: CGPoint(x: 0, y: 0), withAttributes: attributes)
context.restoreGState()
}
class CustomView: NSView {
let textOnCurve = TextOnCurve()
override init(frame: NSRect) {
super.init(frame: frame)
wantsLayer = true
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func updateLayer() {
let textOnCurveLayer = CAShapeLayer()
textOnCurveLayer.frame = self.frame
textOnCurveLayer.delegate = textOnCurve
textOnCurveLayer.setNeedsDisplay()
layer?.addSublayer(textOnCurveLayer)
}
}
class TextOnCurve: NSObject, CALayerDelegate {
func draw(_ layer: CALayer, in ctx: CGContext) {
ctx.setFillColor(.white)
ctx.move(to: CGPoint(x: 100, y: 100))
ctx.addLine(to: CGPoint(x: 150, y: 100))
ctx.addLine(to: CGPoint(x: 150, y: 150))
ctx.addLine(to: CGPoint(x: 100, y: 150))
ctx.closePath()
ctx.drawPath(using: .fill)
ctx.translateBy(x: 200.0, y: 200.0)
centreArcPerpendicular(text: "Anticlockwise",
context: ctx,
radius: 100,
angle: CGFloat(-.pi/2.0),
colour: .red,
font: NSFont.systemFont(ofSize: 16),
clockwise: false)
centre(text: "Hello flat world",
context: ctx,
radius: 0,
angle: 0 ,
colour: .yellow,
font: NSFont.systemFont(ofSize: 16),
slantAngle: .pi / 4)
}
}
let containerView = CustomView(frame: CGRect(x: 0, y: 0, width: 400, height: 400))
PlaygroundPage.current.liveView = containerView
The box is drawn, but the text is not.
If I do not use layers, then I can draw text by calling the methods in the standard draw: method. So the code should work, but, something about the use of layers is preventing it.

Swift Custom Pie Chart - Strange behavior cutting transparent circle from multiple UIBezierPaths

Creating a custom pie chart / doughnut style graph with Swift, and am running into a strange problem when trying to cut the hole out of the doughnut. I've tried variations on center and radius for the second UIBezierPath, but I haven't been able to accomplish a clean cut hole from the center. Any help would be greatly appreciated.
Subclass of UIView:
import UIKit
public class DoughnutView: UIView {
public var data: [Float]? {
didSet { setNeedsDisplay() }
}
public var colors: [UIColor]? {
didSet { setNeedsDisplay() }
}
#IBInspectable public var spacerWidth: CGFloat = 2 {
didSet { setNeedsDisplay() }
}
#IBInspectable public var thickness: CGFloat = 20 {
didSet { setNeedsDisplay() }
}
public override func draw(_ rect: CGRect) {
guard data != nil && colors != nil else {
return
}
guard data?.count == colors?.count else {
return
}
let center = CGPoint(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0)
let radius = min(bounds.size.width, bounds.size.height) / 2.0
let total = data?.reduce(Float(0)) { $0 + $1 }
var startAngle = CGFloat(Float.pi)
UIColor.clear.setStroke()
for (key, value) in data!.enumerated() {
let endAngle = startAngle + CGFloat(2.0 * Float.pi) * CGFloat(value / total!)
let doughnut = UIBezierPath()
doughnut.move(to: center)
doughnut.addArc(withCenter: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
let hole = UIBezierPath(arcCenter: center, radius: radius - thickness, startAngle: startAngle, endAngle: endAngle, clockwise: true)
hole.move(to: center)
doughnut.append(hole)
doughnut.usesEvenOddFillRule = true
doughnut.close()
doughnut.lineWidth = spacerWidth
colors?[key].setFill()
doughnut.fill()
doughnut.stroke()
startAngle = endAngle
}
}
public override func layoutSubviews() {
super.layoutSubviews()
self.backgroundColor = UIColor.clear
setNeedsDisplay()
}
}
And then a ViewController:
class ViewController: UIViewController {
#IBOutlet weak var doughnut: DoughnutView!
override func viewDidLoad() {
super.viewDidLoad()
doughnut.data = [3, 14, 5]
doughnut.colors = [UIColor.red, UIColor.yellow, UIColor.blue]
view.backgroundColor = .purple
}
}
The result:
So you want this:
The problem is that addArc creates, well, an arc, not a wedge. That is, it creates a path that traces part of a circle, without radial segments going to or from the center of the circle. Since you haven't been careful adding those radial segments, when you call close(), you get straight lines where you don't want them.
I guess you're trying to add those radial segments with your move(to:) calls, but you haven't done everything necessary to make that work.
Anyway, this can be done more simply. Start with an arc tracing the outer edge of the slice, then add an arc tracing the inner edge of the slice in the opposite direction. UIBezierPath will automatically connect the end of the first arc to the start of the second arc with a straight line, and close() will connect the end of the second arc to the start of the first arc with another straight line. Thus:
let slice = UIBezierPath()
slice.addArc(withCenter: center, radius: radius,
startAngle: startAngle, endAngle: endAngle, clockwise: true)
slice.addArc(withCenter: center, radius: radius - thickness,
startAngle: endAngle, endAngle: startAngle, clockwise: false)
slice.close()
That said, we can improve your draw(_:) method in some other ways:
We can use guard to rebind data and colors to non-optionals.
We can also guard that data is not empty.
We can reduce radius by spacerWidth to avoid clipping the stroked borders. (You changed the stroke color to .clear in your question's code, but your image shows it as .white.)
We can use CGFloat uniformly to have fewer conversions.
We can divide total by 2π once instead of multiplying every value by 2π.
We can zip(colors, data) into a sequence instead of using enumerated() and subscripting colors.
Thus:
public override func draw(_ rect: CGRect) {
guard
let data = data, !data.isEmpty,
let colors = colors, data.count == colors.count
else { return }
let center = CGPoint(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0)
let radius = min(bounds.size.width, bounds.size.height) / 2.0 - spacerWidth
let total: CGFloat = data.reduce(0) { $0 + CGFloat($1) } / (2 * .pi)
var startAngle = CGFloat.pi
UIColor.white.setStroke()
for (color, value) in zip(colors, data) {
let endAngle = startAngle + CGFloat(value) / total
let slice = UIBezierPath()
slice.addArc(withCenter: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
slice.addArc(withCenter: center, radius: radius - thickness, startAngle: endAngle, endAngle: startAngle, clockwise: false)
slice.close()
color.setFill()
slice.fill()
slice.lineWidth = spacerWidth
slice.stroke()
startAngle = endAngle
}
}