How to fill only a part of the shape on CAShapeLayer - ios

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.

Related

How to curve a view edge to looks like an arc

I want to make an view to looks like this:
If there is a way, something like define point A, point B, with a predefined angle.
The only solution that I found is to make a giant rounded view and insert it as a subview of another view with clipToBounds = true. But this has some problems in multiple screen sizes because I'm using constraints.
Edit1: After some search, i'm trying to create that view with CAShapeLayer, without success. I'm creating that view by storyboard, with constraints, that view is connected by IBOutlet and your leading constraint too. Here's the code:
On viewDidLoad:
self.cnstRoundedLeading.constant = -(self.vwRounded.frame.width/3)
let maskPath : UIBezierPath = UIBezierPath(roundedRect: CGRect(x: self.vwRounded.bounds.minX*4,
y: self.vwRounded.bounds.minY*4,
width: self.vwRounded.bounds.width*4,
height: self.vwRounded.bounds.height*4),
byRoundingCorners: .topLeft,
cornerRadii: CGSize(width: self.vwRounded.frame.size.width*2,
height: self.vwRounded.frame.size.height))
let maskLayer : CAShapeLayer = CAShapeLayer()
maskLayer.frame = self.vwRounded.bounds
maskLayer.path = maskPath.cgPath
self.vwRounded.layer.mask = maskLayer
And on viewWillLayoutSubviews:
gradient2.colors = [startColorBlue.cgColor, endColorBlue.cgColor]
gradient2.locations = [0.0, 1.0]
gradient2.startPoint = CGPoint(x: 0, y: 1)
gradient2.endPoint = CGPoint(x: 1, y: 0)
vwRounded.applyGradient(gradient2)
applyGradient it's a extension of UIView:
func applyGradient(_ gradient: CAGradientLayer) -> Void {
gradient.frame = self.bounds
self.layer.insertSublayer(gradient, at: 0)
}
Does not work properly, i don't know the right way to construct that 'arc edge' effect
You can create that shape - and use it as a mask - with a UIBezierPath (the black border is showing the actual view frame):
Basically,
Find the midpoint of the line from pt1 to pt2.
Find the point perpendicular to that line, with a distance from the line that makes the curve look the way you want. Using the same length as half-the-line-length is what's shown here.
Create a UIBezierPath, connecting pt1 to pt2 with a quadratic curve.
Here is example code that you can run directly in a Playground page. I based the pt1 and pt2 y-positions based on the image you posted... If you change the frame of the view, it will maintain the proportions you've shown.
import PlaygroundSupport
import UIKit
class TestViewController: UIViewController {
override public var preferredContentSize: CGSize {
get { return CGSize(width: 800, height: 800) }
set { super.preferredContentSize = newValue }
}
let myPlainView: UIView = {
let v = UIView()
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
let myBorderView: UIView = {
let v = UIView()
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
func customCurvedPath(for rect: CGRect) -> UIBezierPath {
// curve start point Y is 490/544ths of the height of the view
let curveStartPoint = CGPoint(x: 0.0, y: rect.size.height * 490.0 / 544.0)
// curve end point Y is 22/544ths of the height of the view
let curveEndPoint = CGPoint(x: rect.size.width, y: rect.size.height * 22.0 / 544.0)
var x1 = curveStartPoint.x
var y1 = curveStartPoint.y
let x2 = curveEndPoint.x
let y2 = curveEndPoint.y
// get the midpoint of the line from x1,y1 to x2,y2
x1 = (x1 + x2) / 2.0
y1 = (y1 + y2) / 2.0
// get the length of half the line (midpoint to endpoint)
var dx = x1 - x2
var dy = y1 - y2
let dist = sqrt(dx*dx + dy*dy)
// use length of helf the line for distance from line
// increase or decrease this value to get the desired curve
let distFromLine = dist
dx /= dist
dy /= dist
// get perpendicular point at distFromLine
let x3 = x1 - (distFromLine/2)*dy
let y3 = y1 + (distFromLine/2)*dx
let curveControlPoint = CGPoint(x: x3, y: y3)
let myBezier = UIBezierPath()
// pt1
myBezier.move(to: curveStartPoint)
// quad curve to pt2
myBezier.addQuadCurve(to: curveEndPoint, controlPoint: curveControlPoint)
// line to pt3 (bottom right corner)
myBezier.addLine(to: CGPoint(x: rect.width, y: rect.height))
// line to pt4 (bottom left corner)
myBezier.addLine(to: CGPoint(x: 0.0, y: rect.height))
// close the path (automatically add a line from bottom left corner to curve start point)
myBezier.close()
return myBezier
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
let vwWidth = CGFloat(710.0)
let vwHeight = CGFloat(544.0)
view.addSubview(myBorderView)
myBorderView.backgroundColor = .clear
NSLayoutConstraint.activate([
myBorderView.widthAnchor.constraint(equalToConstant: vwWidth + 2.0),
myBorderView.heightAnchor.constraint(equalToConstant: vwHeight + 2.0),
myBorderView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
myBorderView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
myBorderView.layer.borderWidth = 2.0
// comment this next line (or set to false) to see the actual view frame
myBorderView.isHidden = true
view.addSubview(myPlainView)
myPlainView.backgroundColor = .red
NSLayoutConstraint.activate([
myPlainView.widthAnchor.constraint(equalToConstant: vwWidth),
myPlainView.heightAnchor.constraint(equalToConstant: vwHeight),
myPlainView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
myPlainView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
let bezPath = customCurvedPath(for: CGRect(x: 0, y: 0, width: vwWidth, height: vwHeight))
// add the bezier path as a layer mask
let maskForPath = CAShapeLayer()
maskForPath.path = bezPath.cgPath
myPlainView.layer.mask = maskForPath
}
}
let viewController = TestViewController()
PlaygroundPage.current.liveView = viewController
As I mentioned, this would work better as part of a custom view class, as you could override layoutSubviews() to keep the path shape consistent.
Here's an example using a gradient layer + a layer mask in a custom view, set to 300 x 250:
And the Playground-runnable source:
import PlaygroundSupport
import UIKit
class MaskedGradientView: UIView {
var gradLayer: CAGradientLayer!
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
func commonInit() -> Void {
gradLayer = CAGradientLayer()
gradLayer.colors = [UIColor.blue.cgColor, UIColor.cyan.cgColor]
gradLayer.locations = [0.0, 1.0]
gradLayer.startPoint = CGPoint(x: 0, y: 1)
gradLayer.endPoint = CGPoint(x: 1, y: 0)
layer.addSublayer(gradLayer)
}
override func layoutSubviews() {
super.layoutSubviews()
let rect = self.bounds
gradLayer.frame = self.bounds
// curve start point Y is 490/544ths of the height of the view
let curveStartPoint = CGPoint(x: 0.0, y: rect.size.height * 490.0 / 544.0)
// curve end point Y is 22/544ths of the height of the view
let curveEndPoint = CGPoint(x: rect.size.width, y: rect.size.height * 22.0 / 544.0)
var x1 = curveStartPoint.x
var y1 = curveStartPoint.y
let x2 = curveEndPoint.x
let y2 = curveEndPoint.y
// get the midpoint of the line from x1,y1 to x2,y2
x1 = (x1 + x2) / 2.0
y1 = (y1 + y2) / 2.0
// get the length of half the line (midpoint to endpoint)
var dx = x1 - x2
var dy = y1 - y2
let dist = sqrt(dx*dx + dy*dy)
// use length of helf the line for distance from line
// increase or decrease this value to get the desired curve
let distFromLine = dist
dx /= dist
dy /= dist
// get perpendicular point at distFromLine
let x3 = x1 - (distFromLine/2)*dy
let y3 = y1 + (distFromLine/2)*dx
let curveControlPoint = CGPoint(x: x3, y: y3)
let myBezier = UIBezierPath()
// pt1
myBezier.move(to: curveStartPoint)
// quad curve to pt2
myBezier.addQuadCurve(to: curveEndPoint, controlPoint: curveControlPoint)
// line to pt3 (bottom right corner)
myBezier.addLine(to: CGPoint(x: rect.width, y: rect.height))
// line to pt4 (bottom left corner)
myBezier.addLine(to: CGPoint(x: 0.0, y: rect.height))
// close the path (automatically add a line from bottom left corner to curve start point)
myBezier.close()
// add the bezier path as a layer mask
let maskForPath = CAShapeLayer()
maskForPath.path = myBezier.cgPath
layer.mask = maskForPath
}
}
class TestViewController: UIViewController {
override public var preferredContentSize: CGSize {
get { return CGSize(width: 400, height: 400) }
set { super.preferredContentSize = newValue }
}
let myMaskedGradientView: MaskedGradientView = {
let v = MaskedGradientView()
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
let myPlainView: UIView = {
let v = UIView()
v.translatesAutoresizingMaskIntoConstraints = false
v.backgroundColor = .blue
return v
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
view.addSubview(myMaskedGradientView)
NSLayoutConstraint.activate([
myMaskedGradientView.widthAnchor.constraint(equalToConstant: 300.0),
myMaskedGradientView.heightAnchor.constraint(equalToConstant: 250.0),
myMaskedGradientView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
myMaskedGradientView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
}
}
let viewController = TestViewController()
PlaygroundPage.current.liveView = viewController

iOS Adding CAShapeLayer is not showing on runtime

I have a textfield, added from storyboard, trying to add line in bottom of textfield using CAShapeLayer and BeizerPath
extension UITextField {
func getLine() {
let x1 = self.frame.origin.x
let y = self.frame.origin.y + self.frame.size.height
let start = CGPoint(x: x1, y: y)
let x2 = self.frame.origin.x + self.frame.size.width
let end = CGPoint(x: x2, y: y)
print("\(start) \(end)")
let path = UIBezierPath()
path.move(to: start)
path.addLine(to: end)
let shapeLayer = CAShapeLayer()
shapeLayer.path = path.cgPath
shapeLayer.strokeColor = UIColor.black.cgColor
shapeLayer.lineWidth = 1.0
shapeLayer.fillColor = UIColor.clear.cgColor
self.layer.addSublayer(shapeLayer)
}
}
Usage : txtFiled.getLine()
But it is not showing.
Let me know what missed or anything incorrect doing?
The problem is in your coordinate calculation for the bezier path. You are using textfields frame's origin for your path. You should use bounds origin for calculation your path's coordinate as you are adding the path to UITextField not the container. Corrected code below:
extension UITextField {
func getLine() {
let x1 = self.bounds.origin.x
let y = self.bounds.origin.y + self.frame.size.height
let start = CGPoint(x: x1, y: y)
let x2 = self.bounds.origin.x + self.frame.size.width
let end = CGPoint(x: x2, y: y)
print("\(start) \(end)")
let path = UIBezierPath()
path.move(to: start)
path.addLine(to: end)
let shapeLayer = CAShapeLayer()
shapeLayer.path = path.cgPath
shapeLayer.strokeColor = UIColor.black.cgColor
shapeLayer.lineWidth = 5.0
shapeLayer.fillColor = UIColor.clear.cgColor
self.layer.addSublayer(shapeLayer)
}
}
Checkoout apple's doc on view programming guide

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.

How to draw a circle diagram looking like a clock face using UIKit

I've been trying to figure this out for too long. With the help of this blog I managed to draw the diagram itself, but it can't show me any data, because it seems like my idea of creating a context array is not possible and I can have only one context per view, is that right? So how can I change the color of each marker individually? I've seen the solution using SpriteKit, but I don't know anything at all about SpriteKit.
func degree2Radian(a:CGFloat)->CGFloat {
let b = CGFloat(M_PI) * a/180
return b
}
override func draw(_ rect: CGRect) {
color.set()
pathForCircleCenteredAtPoint(midPoint: circleCenter, withRadius: circleRadius).stroke()
color = UIColor.white
color.set()
pathForCircleCenteredAtPoint(midPoint: CGPoint(x: bounds.midX, y: bounds.midY), withRadius: circleRadius).fill()
color = UIColor(red: 0.93, green: 0.93, blue: 0.94, alpha: 1)
color.set()
let ctx = UIGraphicsGetCurrentContext()
for i in 0...100 {
secondMarkers(ctx: ctx!, x: circleCenter.x, y: circleCenter.y, radius: circleRadius - 4, sides: 100, color: color)
}
diagramArray[0].strokePath()
}
func degree2radian(a:CGFloat)->CGFloat {
let b = CGFloat(M_PI) * a/180
return b
}
func circleCircumferencePoints(sides:Int,x:CGFloat,y:CGFloat,radius:CGFloat,adjustment:CGFloat=0)->[CGPoint] {
let angle = degree2radian(a: 360/CGFloat(sides))
let cx = x // x origin
let cy = y // y origin
let r = radius // radius of circle
var i = sides
var points = [CGPoint]()
while points.count <= sides {
let xpo = cx - r * cos(angle * CGFloat(i)+degree2radian(a: adjustment))
let ypo = cy - r * sin(angle * CGFloat(i)+degree2radian(a: adjustment))
points.append(CGPoint(x: xpo, y: ypo))
i -= 1;
}
return points
}
func secondMarkers(ctx:CGContext, x:CGFloat, y:CGFloat, radius:CGFloat, sides:Int, color:UIColor) {
// retrieve points
let points = circleCircumferencePoints(sides: sides,x: x,y: y,radius: radius)
// create path
// determine length of marker as a fraction of the total radius
var divider:CGFloat = 1/16
//for p in points {
let path = CGMutablePath()
divider = 1/10
let xn = points[counter].x + divider * (x-points[counter].x)
let yn = points[counter].y + divider * (y-points[counter].y)
// build path
path.move(to: CGPoint(x: points[counter].x, y: points[counter].y))
//path, nil, p.x, p.y)
path.addLine(to: CGPoint(x: xn, y: yn))
//CGPathAddLineToPoint(path, nil, xn, yn)
path.closeSubpath()
// add path to context
ctx.addPath(path)
ctx.setStrokeColor(color.cgColor)
ctx.setLineWidth(2.0)
//ctx.strokePath()
diagramArray.append(ctx)
counter += 1
//}
// set path color
}
So basically I'm trying to append context for each marker to an array, but when I draw one element of this array, it draws the whole diagram. This is what I need to achieve.
You shouldn't need to create more than one CGContext - you should just be reusing the same one to draw all graphics. Also, your method to calculate the secondMarkers seems unnecessarily complex. I believe this does what you want:
private func drawTicks(context: CGContext, tickCount: Int, center: CGPoint, startRadius: CGFloat, endRadius: CGFloat, ticksToColor: Int) {
for i in 0 ... tickCount {
let color: UIColor = i < ticksToColor ? .blue : .lightGray
context.setStrokeColor(color.cgColor)
let angle = .pi - degree2Radian(a: (CGFloat(360.0) / CGFloat(tickCount)) * CGFloat(i))
let path = CGMutablePath()
path.move(to: circleCircumferencePoint(center: center, angle: angle, radius: startRadius))
path.addLine(to: circleCircumferencePoint(center: center, angle: angle, radius: endRadius))
context.addPath(path)
context.strokePath()
}
}
private func circleCircumferencePoint(center: CGPoint, angle: CGFloat, radius: CGFloat) -> CGPoint {
return CGPoint(x: radius * sin(angle) + center.x, y: radius * cos(angle) + center.y)
}

Better performance of UIBezierPath drawing iOS Swift ?

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

Resources