Better performance of UIBezierPath drawing iOS Swift ? - ios

In my app I'm doing some drawing, but the way I'm doing it is very inefficient and updating the view takes long time. I'm just curious of there is a better way to achieve this. The image and the code are below.
Any kind of help is highly appreciated.
func drawDialWithCurrentValue( currentValue:CGFloat, andNewValue newValue:CGFloat)
{
let radius = 500.0
let circlePath = UIBezierPath(arcCenter: CGPoint(x: 0,y: radius),
radius: CGFloat(radius),
startAngle: CGFloat(0),
endAngle:CGFloat(M_PI * 2),
clockwise: true)
let shapeLayer = CAShapeLayer()
shapeLayer.path = circlePath.CGPath
shapeLayer.fillColor = UIColor.blueColor().CGColor
shapeLayer.strokeColor = UIColor.blueColor().CGColor
shapeLayer.lineWidth = 3.0
drawView.layer.addSublayer(shapeLayer)
let x0 = 0.0
let y0 = radius
var x1 = x0
var y1 = y0
var degsInRadians = 0.0
for var index = 0; index < 360; index++
{
if index%2 == 0
{
degsInRadians = Double(index) * (M_PI/180)
x1 = x0 + radius * cos(degsInRadians)
y1 = y0 + radius * sin(degsInRadians)
let linePath = UIBezierPath()
linePath.moveToPoint(CGPoint( x: x1, y: y1))
linePath.addLineToPoint(CGPoint( x:0, y:radius))
let shapeLayerLine = CAShapeLayer()
shapeLayerLine.path = linePath.CGPath
shapeLayerLine.fillColor = UIColor.clearColor().CGColor
shapeLayerLine.strokeColor = UIColor.greenColor().CGColor
shapeLayerLine.lineWidth = 3.0
drawView.layer.addSublayer(shapeLayerLine)
}
}
let innerCirc = UIBezierPath(arcCenter: CGPoint(x: 0,y: radius),
radius: CGFloat(radius - 100),
startAngle: CGFloat(0),
endAngle:CGFloat(M_PI * 2),
clockwise: true)
let shapeLayer2 = CAShapeLayer()
shapeLayer2.path = innerCirc.CGPath
shapeLayer2.fillColor = UIColor.blueColor().CGColor
shapeLayer2.strokeColor = UIColor.blueColor().CGColor
shapeLayer2.lineWidth = 3.0
drawView.layer.addSublayer(shapeLayer2)
}

You can do this with only two layers. One for the line segments and one for the background circle (which has a different stroke color).
This function will make a single BezierPath for all the line segments (for your top layer). If you are not going to be scaling at every draw, you should set keep a single copy of the path in a static (or instance) variable and reuse it. Even if you do resize the shape, you should only recalculate the path when the radius actually changes.
func clockSegments(count:Int, radius:CGFloat, length:CGFloat)->UIBezierPath
{
let allSegments = UIBezierPath()
let baseSegment = UIBezierPath()
// baseSegment is horizontal movement from origin, followed by line up to radius
// (0,0).....................---------
baseSegment.moveToPoint( CGPointMake(radius-length, 0.0) )
baseSegment.addLineToPoint( CGPointMake(radius, 0.0) )
baseSegment.lineWidth = 3
// rotate and add segments
let segmentAngle = 2 * CGFloat(M_PI) / CGFloat(count)
let rotate = CGAffineTransformMakeRotation(segmentAngle)
for _ in 1...count
{
allSegments.appendPath(baseSegment)
baseSegment.applyTransform(rotate)
}
return allSegments
}
to get the segment path for the parameters you used in your example you can call the function like this:
layer.path = clockSegments(360, radius:500, length:100)
On an iPad3, using SpriteKit, rendering choked at 200 segments but if I used two shape nodes with 180 each, it had no problem. This may hint at a practical limit to the number of elements in the BezierPath. If you encounter this issue, you could add a parameter to offset the starting segment and use two 180 segment layers (one being rotated by 1 degree).

Related

How to fill only a part of the shape on CAShapeLayer

I am creating following shape with UIBezierPath and then draw it with CAShapeLayer as following.
Then I can change CAShapeLayer fillColor property from shapeLayer.fillColor = UIColor.clear.cgColor to shapeLayer.fillColor = UIColor.blue.cgColor and get following shape.
Here is the code:
import UIKit
class ViewController: UIViewController {
var line = [CGPoint]()
let bezierPath: UIBezierPath = UIBezierPath()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
let point1 = CGPoint(x: 100, y: 100)
let point2 = CGPoint(x: 400, y: 100)
line.append(point1)
line.append(point2)
addCircle(toRight: false)
addLine()
addCircle(toRight: true)
let shapeLayer = CAShapeLayer()
shapeLayer.path = bezierPath.cgPath
shapeLayer.strokeColor = UIColor.blue.cgColor
shapeLayer.fillColor = UIColor.clear.cgColor
self.view.layer.addSublayer(shapeLayer)
}
func addLine() -> Void {
bezierPath.move(to: line.first!)
bezierPath.addLine(to: line.last!)
}
func addCircle(toRight: Bool) -> Void {
let angle = CGFloat( Double.pi / 180 )
let r = CGFloat(20.0)
let x0 = toRight ? line.first!.x : line.last!.x
let y0 = toRight ? line.first!.y : line.last!.y
let x1 = toRight ? line.last!.x : line.first!.x
let y1 = toRight ? line.last!.y : line.first!.y
let x = x1 - x0
let y = y1 - y0
let h = (x*x + y*y).squareRoot()
let x2 = x0 + (x * (h + r) / h)
let y2 = y0 + (y * (h + r) / h)
// Add the arc, starting at that same point
let point2 = CGPoint(x: x2, y: y2)
let pointZeroDeg = CGPoint(x: x2 + r, y: y2)
self.bezierPath.move(to: pointZeroDeg)
self.bezierPath.addArc(withCenter: point2, radius: r,
startAngle: 0*angle, endAngle: 360*angle,
clockwise: true)
self.bezierPath.close()
}
}
But what I actually want is following shape (left side circle is filled, right side circle is not filled).
So my question is, How to fill only a part of the shape on CAShapeLayer?
Is it possible? Is there any trick to achieve this?
PS: I can achieve this by creating 3 different UIBezierPaths (for leftCircle, line and rightCircle) and drawing them with 3 different CAShapeLayers as following.
// left Circle
shapeLayer1.path = bezierPath1.cgPath
shapeLayer1.fillColor = UIColor.blue.cgColor
// line
shapeLayer2.path = bezierPath2.cgPath
// right Circle
shapeLayer3.path = bezierPath3.cgPath
shapeLayer3.fillColor = UIColor.clear.cgColor
self.view.layer.addSublayer(shapeLayer1)
self.view.layer.addSublayer(shapeLayer2)
self.view.layer.addSublayer(shapeLayer3)
But I prefer to achieve this with a single CAShapeLayer.
I think better approach is using of multiple shapes.
However you can make one-shape implementation by using evenOdd rule of shape filling.
Just add an extra circle to the right circle in method addCircle:
if toRight {
self.bezierPath.addArc(withCenter: point2, radius: r,
startAngle: 0*angle, endAngle: 2*360*angle,
clockwise: true)
} else {
self.bezierPath.addArc(withCenter: point2, radius: r,
startAngle: 0*angle, endAngle: 360*angle,
clockwise: true)
}
And set fillRule to evenOdd:
shapeLayer.fillColor = UIColor.blue.cgColor
shapeLayer.fillRule = .evenOdd
So you'll get that the left circle will have one circle of drawing and will be filled by evenOdd rule. But right circle will have two circles of drawing and will be unfilled.
The evenOdd rule (https://developer.apple.com/documentation/quartzcore/cashapelayerfillrule/1521843-evenodd):
Specifies the even-odd winding rule. Count the total number of path
crossings. If the number of crossings is even, the point is outside
the path. If the number of crossings is odd, the point is inside the
path and the region containing it should be filled.

Swift 3: Animate color fill of arc added to UIBezierPath

I wish to animate the color fill of a section of a pie chart. I create the pie chart by creating a UIBezierPath() for each piece of the pie and then use the addArc method to specify the size/constraints of the arc. To animate the pie chart segment, I want the color fill of the arc to animate from the center of the circle to the radius end. However, I am having trouble. I heard the strokeEnd keyPath animated from 0 to 1 should work, but there is no animation happening on the arcs (the arcs are just appearing at app launch).
let rad = 2 * Double.pi
let pieCenter: CGPoint = CGPoint(x: frame.width / 2, y: frame.height / 2)
var start: Double = 0
for i in 0...data.count - 1 {
let size: Double = Double(data[i])! / 100 // the percentege of the circle that the given arc will take
let end: Double = start + (size * rad)
let path = UIBezierPath()
path.move(to: pieCenter)
path.addArc(withCenter: pieCenter, radius: frame.width / 3, startAngle: CGFloat(start), endAngle: CGFloat(end), clockwise: true)
start += size * rad
let lineLayer = CAShapeLayer()
lineLayer.bounds = self.bounds
lineLayer.position = self.layer.position
lineLayer.path = path.cgPath
lineLayer.strokeColor = UIColor.white.cgColor
lineLayer.fillColor = colors[i]
lineLayer.lineWidth = 0
self.layer.addSublayer(lineLayer)
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
animation.fillMode = kCAFillModeForwards
animation.fromValue = pieCenter
animation.toValue = frame.width / 3 // radius
animation.duration = 2.5
lineLayer.add(animation, forKey: nil)
}
I've seen a solution to a similar problem here, but it does not work for the individual arcs.
When you animate strokeEnd, that animates the stroke around the path, but not the fill of the path.
If you're looking for just any animation of the fill, easy options include animating the fillColor key path from UIColor.clear.cgColor to the final color. Or animate the opacity key path from 0 to 1.
func addPie(_ animated: Bool = true) {
shapeLayers.forEach { $0.removeFromSuperlayer() }
shapeLayers.removeAll()
guard let dataPoints = dataPoints else { return }
let center = pieCenter
let radius = pieRadius
var startAngle = -CGFloat.pi / 2
let sum = dataPoints.reduce(0.0) { $0 + $1.value }
for (index, dataPoint) in dataPoints.enumerated() {
let endAngle = startAngle + CGFloat(dataPoint.value / sum) * 2 * .pi
let path = closedArc(at: center, with: radius, start: startAngle, end: endAngle)
let shape = CAShapeLayer()
shape.fillColor = dataPoint.color.cgColor
shape.strokeColor = UIColor.black.cgColor
shape.lineWidth = lineWidth
shape.path = path.cgPath
layer.addSublayer(shape)
shapeLayers.append(shape)
shape.frame = bounds
if animated {
shape.opacity = 0
DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) / Double(dataPoints.count)) {
shape.opacity = 1
let animation = CABasicAnimation(keyPath: "opacity")
animation.fromValue = 0
animation.toValue = 1
animation.duration = 1
shape.add(animation, forKey: nil)
}
}
startAngle = endAngle
}
}
That yields:
The delaying of the animations give it a slightly more dynamic effect.
If you want to get fancy, you can play around with animations of transform of the entire CAShapeLayer. For example, you can scale the pie wedges:
func addPie(_ animated: Bool = true) {
shapeLayers.forEach { $0.removeFromSuperlayer() }
shapeLayers.removeAll()
guard let dataPoints = dataPoints else { return }
let center = pieCenter
let radius = pieRadius
var startAngle = -CGFloat.pi / 2
let sum = dataPoints.reduce(0.0) { $0 + $1.value }
for (index, dataPoint) in dataPoints.enumerated() {
let endAngle = startAngle + CGFloat(dataPoint.value / sum) * 2 * .pi
let path = closedArc(at: center, with: radius, start: startAngle, end: endAngle)
let shape = CAShapeLayer()
shape.fillColor = dataPoint.color.cgColor
shape.strokeColor = UIColor.black.cgColor
shape.lineWidth = lineWidth
shape.path = path.cgPath
layer.addSublayer(shape)
shapeLayers.append(shape)
shape.frame = bounds
if animated {
shape.opacity = 0
DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) / Double(dataPoints.count) + 1) {
shape.opacity = 1
let animation = CABasicAnimation(keyPath: "transform")
animation.fromValue = CATransform3DMakeScale(0, 0, 1)
animation.toValue = CATransform3DIdentity
animation.duration = 1
shape.add(animation, forKey: nil)
}
}
startAngle = endAngle
}
}
Yielding:
Or you can rotate the pie wedge shape layer about its center angle making it appear to angularly expand:
func addPie(_ animated: Bool = true) {
shapeLayers.forEach { $0.removeFromSuperlayer() }
shapeLayers.removeAll()
guard let dataPoints = dataPoints else { return }
let center = pieCenter
let radius = pieRadius
var startAngle = -CGFloat.pi / 2
let sum = dataPoints.reduce(0.0) { $0 + $1.value }
for (index, dataPoint) in dataPoints.enumerated() {
let endAngle = startAngle + CGFloat(dataPoint.value / sum) * 2 * .pi
let path = closedArc(at: center, with: radius, start: startAngle, end: endAngle)
let shape = CAShapeLayer()
shape.fillColor = dataPoint.color.cgColor
shape.strokeColor = UIColor.black.cgColor
shape.lineWidth = lineWidth
shape.path = path.cgPath
layer.addSublayer(shape)
shapeLayers.append(shape)
shape.frame = bounds
if animated {
shape.opacity = 0
let centerAngle = startAngle + CGFloat(dataPoint.value / sum) * .pi
let transform = CATransform3DMakeRotation(.pi / 2, cos(centerAngle), sin(centerAngle), 0)
DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) / Double(dataPoints.count)) {
shape.opacity = 1
let animation = CABasicAnimation(keyPath: "transform")
animation.fromValue = transform
animation.toValue = CATransform3DIdentity
animation.duration = 1
shape.add(animation, forKey: nil)
}
}
startAngle = endAngle
}
}
That yields:
I'd encourage you to not get too lost in the details of my CAShapeLayer and my model, but rather focus on the CABasicAnimation and the various keyPath values we can animate.
It sounds like what you are after is a "clock wipe" effect that reveals your graph. If that's the case then there is a simpler way than creating a separate mask layer for each separate wedge of your pie chart. Instead, make each wedge of your graph a sublayer of a single layer, install a mask layer on that super-layer, and run a clock wipe animation on that super layer.
Here is a GIF illustrating a clock wipe animation of a static image:
I wrote a post explaining how to do it, and linking to a Github project demonstrating it:
How do you achieve a "clock wipe"/ radial wipe effect in iOS?
In order to ensure a smooth, clockwise animation of the pie chart, you must perform the following steps in order:
Create a new parent layer of type CAShapeLayer
In a loop each pie chart slice to the parent layer
In another loop, iterate through the sublayers (pie slices) of the parent layer and assign each sublayer a mask and animate that mask in the loop
Add the parent layer to the main layer: self.layer.addSublayer(parentLayer)
In a nutshell, the code will look like this:
// Code above this line adds the pie chart slices to the parentLayer
for layer in parentLayer.sublayers! {
// Code in this block creates and animates the same mask for each layer
}
Each animation applied to each pie slice will be a strokeEnd keypath animation from 0 to 1. When creating the mask, be sure its fillColor property is set to UIColor.clear.cgColor.
reduce the arc radius to half and make the line twice as thick as the radius
let path = closedArc(at: center, with: radius * 0.5, start: startAngle, end: endAngle)
lineLayer.lineWidth = radius
set the fillColor to clear
I may be very late to the party. But if anyone are looking for smooth circle Pie chart animation try this:-
In your ViewController class use below
class YourViewController: UIViewController {
#IBOutlet weak var myView: UIView!
let chartView = PieChartView()
override func viewDidLoad() {
super.viewDidLoad()
myView.addSubview(chartView)
chartView.translatesAutoresizingMaskIntoConstraints = false
chartView.leftAnchor.constraint(equalTo: myView.leftAnchor).isActive = true
chartView.rightAnchor.constraint(equalTo: myView.rightAnchor).isActive = true
chartView.topAnchor.constraint(equalTo: myView.topAnchor).isActive = true
chartView.bottomAnchor.constraint(equalTo: myView.bottomAnchor).isActive = true
// Do any additional setup after loading the view.
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
let segments = [Segment(value: 70, color: .systemBlue), Segment(value: 40, color: .systemCyan), Segment(value: 5, color: .systemTeal), Segment(value: 4, color: .systemMint), Segment(value: 5, color: .systemPurple)]
chartView.segments = segments
}
}
Create below PieChartView class which will animate pieChart as well
class PieChartView: UIView {
/// An array of structs representing the segments of the pie chart
var pieSliceLayer = CAShapeLayer()
var count = 0
var startAngle = -CGFloat.pi * 0.5
var segments = [Segment]() {
didSet {
setNeedsDisplay() // re-draw view when the values get set
}
}
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) {
addSlices()
}
func addSlices() {
guard count < segments.count else {return}
let radius = min(bounds.width, bounds.height) / 2.0 - 20
let center: CGPoint = CGPoint(x: bounds.midX, y: bounds.midY)
let valueCount = segments.reduce(0, {$0 + $1.value})
for value in segments {
let pieSliceLayer = CAShapeLayer()
pieSliceLayer.strokeColor = value.color.cgColor
pieSliceLayer.fillColor = UIColor.clear.cgColor
pieSliceLayer.lineWidth = radius
layer.addSublayer(pieSliceLayer)
let endAngle = CGFloat(value.value) / valueCount * CGFloat.pi * 2.0 + startAngle
pieSliceLayer.path = UIBezierPath(arcCenter: center, radius: radius/2, startAngle: startAngle, endAngle: endAngle, clockwise: true).cgPath
startAngle = endAngle
}
pieSliceLayer.strokeColor = UIColor.white.cgColor
pieSliceLayer.fillColor = UIColor.clear.cgColor
pieSliceLayer.lineWidth = radius
let startAngle: CGFloat = 3 * .pi / 2
let endAngle: CGFloat = -3 * .pi / 2
pieSliceLayer.path = UIBezierPath(arcCenter: center, radius: radius/2, startAngle: startAngle, endAngle: endAngle, clockwise: false).cgPath
pieSliceLayer.strokeEnd = 1
layer.addSublayer(pieSliceLayer)
startCircleAnimation()
}
private func startCircleAnimation() {
pieSliceLayer.strokeEnd = 0
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = 1
animation.toValue = 0
animation.duration = 1.0
animation.timingFunction = CAMediaTimingFunction(controlPoints: 0.42, 0.00, 0.09, 1.00)
pieSliceLayer.add(animation, forKey: nil)
}
}

arcs donut chart with CAShapelayer - border of underlaying layers are visible

I draw a donut chart with CAShapeLayers arcs. I draw it by putting one on top of another and the problem that underneath layers edges are visible.
code of drawing is following
for (index, item) in values.enumerated() {
var currentValue = previousValue + item.value
previousValue = currentValue
if index == values.count - 1 {
currentValue = 100
}
let layer = CAShapeLayer()
let path = UIBezierPath()
let separatorLayer = CAShapeLayer()
let separatorPath = UIBezierPath()
let radius: CGFloat = self.frame.width / 2 - lineWidth / 2
let center: CGPoint = CGPoint(x: self.bounds.width / 2, y: self.bounds.width / 2)
separatorPath.addArc(withCenter: center, radius: radius, startAngle: percentToRadians(percent: -25), endAngle: percentToRadians(percent: CGFloat(currentValue - 25 + 0.2)), clockwise: true)
separatorLayer.path = separatorPath.cgPath
separatorLayer.fillColor = UIColor.clear.cgColor
separatorLayer.strokeColor = UIColor.white.cgColor
separatorLayer.lineWidth = lineWidth
separatorLayer.contentsScale = UIScreen.main.scale
self.layer.addSublayer(separatorLayer)
separatorLayer.add(createGraphAnimation(), forKey: nil)
separatorLayer.zPosition = -(CGFloat)(index)
path.addArc(withCenter: center, radius: radius, startAngle: percentToRadians(percent: -25), endAngle: percentToRadians(percent: CGFloat(currentValue - 25)), clockwise: true)
layer.path = path.cgPath
layer.fillColor = UIColor.clear.cgColor
layer.strokeColor = item.color.cgColor
layer.lineWidth = lineWidth
layer.contentsScale = UIScreen.main.scale
layer.shouldRasterize = true
layer.rasterizationScale = UIScreen.main.scale
layer.allowsEdgeAntialiasing = true
separatorLayer.addSublayer(layer)
layer.add(createGraphAnimation(), forKey: nil)
layer.zPosition = -(CGFloat)(index)
What am I doing wrong ?
UPD
Tried code
let mask = CAShapeLayer()
mask.frame = CGRect(x: 0, y: 0, width: radius * 2, height: radius * 2)
mask.fillColor = nil
mask.strokeColor = UIColor.white.cgColor
mask.lineWidth = lineWidth * 2
let maskPath = CGMutablePath()
maskPath.addArc(center: CGPoint(x: self.radius, y: self.radius), radius: radius, startAngle: 0, endAngle: 2 * .pi, clockwise: true)
maskPath.closeSubpath()
mask.path = maskPath
self.layer.mask = mask
but it masks only inner edges, outer still has fringe
The fringe you're seeing happens because you're drawing exactly the same shape in the same position twice, and alpha compositing (as commonly implemented) is not designed to handle that. Porter and Duff's paper, “Compositing Digital Images”, which introduced alpha compositing, discusses the problem:
We must remember that our basic assumption about the
division of subpixel areas by geometric objects breaks
down in the face of input pictures with correlated mattes.
When one picture appears twice in a compositing expression,
we must take care with our computations of F A and
F B. Those listed in the table are correct only for uncorrelated
pictures.
When it says “matte”, it basically means transparency. When it says “uncorrelated pictures”, it means two pictures whose transparent areas have no special relationship. But in your case, your two pictures do have a special relationship: the pictures are transparent in exactly the same areas!
Here's a self-contained test that reproduces your problem:
private func badVersion() {
let center = CGPoint(x: view.bounds.width / 2, y: view.bounds.height / 2)
let radius: CGFloat = 100
let ringWidth: CGFloat = 44
let ring = CAShapeLayer()
ring.frame = view.bounds
ring.fillColor = nil
ring.strokeColor = UIColor.red.cgColor
ring.lineWidth = ringWidth
let ringPath = CGMutablePath()
ringPath.addArc(center: center, radius: radius, startAngle: 0, endAngle: 2 * .pi, clockwise: true)
ringPath.closeSubpath()
ring.path = ringPath
view.layer.addSublayer(ring)
let wedge = CAShapeLayer()
wedge.frame = view.bounds
wedge.fillColor = nil
wedge.strokeColor = UIColor.darkGray.cgColor
wedge.lineWidth = ringWidth
wedge.lineCap = kCALineCapButt
let wedgePath = CGMutablePath()
wedgePath.addArc(center: center, radius: radius, startAngle: 0.1, endAngle: 0.6, clockwise: false)
wedge.path = wedgePath
view.layer.addSublayer(wedge)
}
Here's the part of the screen that shows the problem:
One way to fix this is to draw the colors beyond the edges of the ring, and use a mask to clip them to the ring shape.
I'll change my code so that instead of drawing a red ring, and part of a gray ring on top of it, I draw a red disc, and a gray wedge on top of it:
If you zoom in, you can see that this still shows the red fringe at the edge of the gray wedge. So the trick is to use a ring-shaped mask to get the final shape. Here's the shape of the mask, drawn in white on top of the prior image:
Note that the mask is well away from the problematic area with the fringe. When I use the mask as a mask instead of drawing it, I get the final, perfect result:
Here's the code that draws the perfect version:
private func goodVersion() {
let center = CGPoint(x: view.bounds.width / 2, y: view.bounds.height / 2)
let radius: CGFloat = 100
let ringWidth: CGFloat = 44
let slop: CGFloat = 10
let disc = CAShapeLayer()
disc.frame = view.bounds
disc.fillColor = UIColor.red.cgColor
disc.strokeColor = nil
let ringPath = CGMutablePath()
ringPath.addArc(center: center, radius: radius + ringWidth / 2 + slop, startAngle: 0, endAngle: 2 * .pi, clockwise: true)
ringPath.closeSubpath()
disc.path = ringPath
view.layer.addSublayer(disc)
let wedge = CAShapeLayer()
wedge.frame = view.bounds
wedge.fillColor = UIColor.darkGray.cgColor
wedge.strokeColor = nil
let wedgePath = CGMutablePath()
wedgePath.move(to: center)
wedgePath.addArc(center: center, radius: radius + ringWidth / 2 + slop, startAngle: 0.1, endAngle: 0.6, clockwise: false)
wedgePath.closeSubpath()
wedge.path = wedgePath
view.layer.addSublayer(wedge)
let mask = CAShapeLayer()
mask.frame = view.bounds
mask.fillColor = nil
mask.strokeColor = UIColor.white.cgColor
mask.lineWidth = ringWidth
let maskPath = CGMutablePath()
maskPath.addArc(center: center, radius: radius, startAngle: 0, endAngle: 2 * .pi, clockwise: true)
maskPath.closeSubpath()
mask.path = maskPath
view.layer.mask = mask
}
Note that the mask applies to everything in view, so (in your case) you may need to move all of your layers into a subview has no other contents so it's safe to mask.
UPDATE
Looking at your playground, the problem is (still) that you're drawing two shapes that have exactly the same partially-transparent edge on top of each other. You can't do that. The solution is to draw the colored shapes larger, so that they are both completely opaque at the edge of the donut, and then use the layer mask to clip them to the donut shape.
I fixed your playground. Notice how in my version, the lineWidth of each colored section is donutThickness + 10, and the mask's lineWidth is only donutThickness. Here's the result:
Here's the playground:
import UIKit
import PlaygroundSupport
class ABDonutChart: UIView {
struct Datum {
var value: Double
var color: UIColor
}
var donutThickness: CGFloat = 20 { didSet { setNeedsLayout() } }
var separatorValue: Double = 1 { didSet { setNeedsLayout() } }
var separatorColor: UIColor = .white { didSet { setNeedsLayout() } }
var data = [Datum]() { didSet { setNeedsLayout() } }
func withAnimation(_ wantAnimation: Bool, do body: () -> ()) {
let priorFlag = wantAnimation
self.wantAnimation = true
defer { self.wantAnimation = priorFlag }
body()
layoutIfNeeded()
}
override func layoutSubviews() {
super.layoutSubviews()
let bounds = self.bounds
let center = CGPoint(x: bounds.origin.x + bounds.size.width / 2, y: bounds.origin.y + bounds.size.height / 2)
let radius = (min(bounds.size.width, bounds.size.height) - donutThickness) / 2
let maskLayer = layer.mask as? CAShapeLayer ?? CAShapeLayer()
maskLayer.frame = bounds
maskLayer.fillColor = nil
maskLayer.strokeColor = UIColor.white.cgColor
maskLayer.lineWidth = donutThickness
maskLayer.path = CGPath(ellipseIn: CGRect(x: center.x - radius, y: center.y - radius, width: 2 * radius, height: 2 * radius), transform: nil)
layer.mask = maskLayer
var spareLayers = segmentLayers
segmentLayers.removeAll()
let finalSum = data.reduce(Double(0)) { $0 + $1.value + separatorValue }
var runningSum: Double = 0
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = 0.0
animation.toValue = 1.0
animation.duration = 2
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
func addSegmentLayer(color: UIColor, segmentSum: Double) {
let angleOffset: CGFloat = -0.25 * 2 * .pi
let segmentLayer = spareLayers.popLast() ?? CAShapeLayer()
segmentLayer.strokeColor = color.cgColor
segmentLayer.lineWidth = donutThickness + 10
segmentLayer.lineCap = kCALineCapButt
segmentLayer.fillColor = nil
let path = CGMutablePath()
path.addArc(center: center, radius: radius, startAngle: angleOffset, endAngle: CGFloat(segmentSum / finalSum * 2 * .pi) + angleOffset, clockwise: false)
segmentLayer.path = path
layer.insertSublayer(segmentLayer, at: 0)
segmentLayers.append(segmentLayer)
if wantAnimation {
segmentLayer.add(animation, forKey: animation.keyPath)
}
}
for datum in data {
addSegmentLayer(color: separatorColor, segmentSum: runningSum + separatorValue / 2)
runningSum += datum.value + separatorValue
addSegmentLayer(color: datum.color, segmentSum: runningSum - separatorValue / 2)
}
addSegmentLayer(color: separatorColor, segmentSum: finalSum)
spareLayers.forEach { $0.removeFromSuperlayer() }
}
private var segmentLayers = [CAShapeLayer]()
private var wantAnimation = false
}
let container = UIView()
container.frame.size = CGSize(width: 300, height: 300)
container.backgroundColor = .black
PlaygroundPage.current.liveView = container
PlaygroundPage.current.needsIndefiniteExecution = true
let m = ABDonutChart(frame: CGRect(x: 0, y: 0, width: 215, height: 215))
m.center = CGPoint(x: container.bounds.size.width / 2, y: container.bounds.size.height / 2)
container.addSubview(m)
m.withAnimation(true) {
m.data = [
.init(value: 10, color: .red),
.init(value: 30, color: .blue),
.init(value: 15, color: .orange),
.init(value: 40, color: .yellow),
.init(value: 50, color: .green)]
}
To me, it looks like the edges are antialiased resulting in somewhat transparent pixels. The orange of the background can then be seen through the 'blurred' edges of the overlay.
Have you tried making the overlaid layers opaque?
layer.Opaque = true; //C#
An alternative way may be to draw a thin circle with the background color on top the orange edges. This should work, but it's not the prettiest method.

iOS: Draw circle as percentage in UIView

I have one UIView in circular shape, I need to show that UIView border colour in percentage value like if percentage value is 50%, it should fill half border colour of UIView. I have used UIBeizer path addArcWithCenter however I didn't get perfect solution. Please help me in this
You can achieve it with following code, simply adjust strokeStart and strokeEnd:
// round view
let roundView = UIView(frame: CGRectMake(100, 100, 250, 250))
roundView.backgroundColor = UIColor.whiteColor()
roundView.layer.cornerRadius = roundView.frame.size.width / 2
// bezier path
let circlePath = UIBezierPath(arcCenter: CGPoint (x: roundView.frame.size.width / 2, y: roundView.frame.size.height / 2),
radius: roundView.frame.size.width / 2,
startAngle: CGFloat(-0.5 * M_PI),
endAngle: CGFloat(1.5 * M_PI),
clockwise: true)
// circle shape
let circleShape = CAShapeLayer()
circleShape.path = circlePath.CGPath
circleShape.strokeColor = UIColor.redColor().CGColor
circleShape.fillColor = UIColor.clearColor().CGColor
circleShape.lineWidth = 1.5
// set start and end values
circleShape.strokeStart = 0.0
circleShape.strokeEnd = 0.8
// add sublayer
roundView.layer.addSublayer(circleShape)
// add subview
self.view.addSubview(roundView)
I've written my custom function for doing this in Swift 5. I'm sure I'll save someone a lot of time. Have fun with it.
func buildRoundView(roundView: UIView, total : Int, current : Int){
roundView.layer.cornerRadius = roundView.frame.size.width / 2
roundView.backgroundColor = .clear
let width :CGFloat = 10.0
let reducer :CGFloat = 0.010
let circlePath = UIBezierPath(arcCenter: CGPoint (x: roundView.frame.size.width / 2, y: roundView.frame.size.height / 2),
radius: roundView.frame.size.width / 2,
startAngle: CGFloat(-0.5 * Double.pi),
endAngle: CGFloat(1.5 * Double.pi),
clockwise: true)
let multiplier = CGFloat((100.000 / Double(total)) * 0.0100)
for i in 1...total {
let circleShape = CAShapeLayer()
circleShape.path = circlePath.cgPath
if i <= current {
circleShape.strokeColor = UIColor.systemRed.cgColor
}
else{
circleShape.strokeColor = UIColor.lightGray.cgColor
}
circleShape.fillColor = UIColor.clear.cgColor
circleShape.lineWidth = width
circleShape.strokeStart = CGFloat(CGFloat(i - 1) * multiplier) + reducer
circleShape.strokeEnd = CGFloat(CGFloat(i) * multiplier) - reducer
roundView.layer.addSublayer(circleShape)
}
}
According #gvuksic's answer:
Swift 5:
// round view
let roundView = UIView(
frame: CGRect(
x: circleContainerView.bounds.origin.x,
y: circleContainerView.bounds.origin.y,
width: circleContainerView.bounds.size.width - 4,
height: circleContainerView.bounds.size.height - 4
)
)
roundView.backgroundColor = .white
roundView.layer.cornerRadius = roundView.frame.size.width / 2
// bezier path
let circlePath = UIBezierPath(arcCenter: CGPoint (x: roundView.frame.size.width / 2, y: roundView.frame.size.height / 2),
radius: roundView.frame.size.width / 2,
startAngle: CGFloat(-0.5 * .pi),
endAngle: CGFloat(1.5 * .pi),
clockwise: true)
// circle shape
let circleShape = CAShapeLayer()
circleShape.path = circlePath.cgPath
circleShape.strokeColor = UIColor.customColor?.cgColor
circleShape.fillColor = UIColor.clear.cgColor
circleShape.lineWidth = 4
// set start and end values
circleShape.strokeStart = 0.0
circleShape.strokeEnd = 0.8
// add sublayer
roundView.layer.addSublayer(circleShape)
// add subview
circleContainerView.addSubview(roundView)
And the result:

iOS Swift Xcode 6: CGAffineTransformRotate with auto-layout anchorpoint

I'm making an app with a rotatable pie chart. However, my pie chart rotates around (0,0) and I can't find a solution to make it rotate around its center.
Some code:
// DRAWING CODE
// Set constants for piePieces
let radius: CGFloat = CGFloat(screen.width * 0.43)
let pi: CGFloat = 3.1415926535
let sliceRad: CGFloat = 2.0 * pi / CGFloat(categoryArray.count)
var currentAngle: CGFloat = -0.5 * sliceRad - 0.5 * pi
let center: CGPoint = CGPoint(x: screen.width / 2.0, y: radius)
println("Center point: \(center)")
//container!.layer.anchorPoint = CGPoint(x: screen.width, y: radius)
// Draw all pie charts, add them to container (chartView)
for category in categoryArray {
let slice = UIView()
slice.frame = self.frame
let shapeLayer = CAShapeLayer()
let scoreIndex = CGFloat(category.calculateScoreIndex())
let sliceRadius: CGFloat = scoreIndex * radius
// Draw the path
var path:UIBezierPath = UIBezierPath()
path.moveToPoint(center)
path.addLineToPoint(CGPoint(x: center.x + sliceRadius * cos(currentAngle), y: center.y + sliceRadius * sin(currentAngle)))
path.addArcWithCenter(center, radius: sliceRadius, startAngle: currentAngle, endAngle: currentAngle + sliceRad, clockwise: true)
path.addLineToPoint(center)
path.closePath()
// For next slice, add 2*pi Rad / n categories
currentAngle += sliceRad
// Add path to shapeLayer
shapeLayer.path = path.CGPath
//shapeLayer.frame = self.frame
shapeLayer.fillColor = SurveyColors().getColor(category.categoryIndex).CGColor
shapeLayer.anchorPoint = center
// Add shapeLayer to sliceView
slice.layer.addSublayer(shapeLayer)
// Add slice to chartView
container!.addSubview(slice)
}
self.addSubview(container!)
//container!.center = center
container!.layer.anchorPoint = center
}
I sheduled a NSTimer to perform a rotation every 2 seconds (for testing purposes):
func rotate() {
let t: CGAffineTransform = CGAffineTransformRotate(container!.transform, -0.78)
container!.transform = t;
}
The pie chart rotates around (0,0). What is going wrong?
I believe a good solution to your problem would be to set a container view:
container = UIView()
container!.frame = CGRect(x: 0.0, y: 0.0, width: screen.width, height: screen.width)
container!.layer.anchorPoint = CGPoint(x: 0.5, y: 0.5)
container!.backgroundColor = UIColor.clearColor()
And then add all slices to this container view:
let slice = UIView()
slice.frame = CGRect(x: 0.0 , y: 0.0 , width: container!.bounds.width, height: container!.bounds.height)
let shapeLayer = CAShapeLayer()
let scoreIndex = CGFloat(category.calculateScoreIndex())
let sliceRadius: CGFloat = scoreIndex * radius
/*
*/
container!.addSubview(slice)
Also, make sure not to add your chart as a subview to a view with auto-layout constraints (which might interfere with you CGAffineTransformRotate).

Resources