I have a task to draw the line with a circular gradient (colour should change by the circle) and then add animation. Now I draw 360 layers with a certain interval and different colours.
var colours: [UIColor] = [UIColor]()
var startAngle = CGFloat(-0.5 * Double.pi)
var index = 0
func drawLayers() {
let smallAngle = (1.5 * CGFloat.pi - (-0.5 * CGFloat.pi)) / 360
if index < colours.count { //colours.count = 360
let endAngle = startAngle + smallAngle
let circlePath = UIBezierPath(arcCenter: .init(x: 100, y: 100), radius: 100, startAngle: startAngle, endAngle: endAngle, clockwise: true)
startAngle = endAngle
let shapeLayer = CAShapeLayer()
shapeLayer.path = circlePath.cgPath
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.strokeColor = colours[index].cgColor
shapeLayer.lineWidth = 8
view.layer.addSublayer(shapeLayer)
index += 1
Timer.scheduledTimer(
withTimeInterval: 0.004,
repeats: false) { (_) in
self.drawLayers()
}
}
}
Something like that but with linear animation
Can anyone tell me how to do it right?
iOS has circular (conic) gradients built in now. So I would just ask for the gradient, once, and then animate a single path used as a mask. That’s just two layers, much less work, true animation, and a true gradient.
Example:
Here's my test code; change the colors and numbers as desired:
let grad = CAGradientLayer()
grad.type = .conic
grad.colors = [UIColor.red.cgColor, UIColor.green.cgColor, UIColor.red.cgColor]
grad.startPoint = CGPoint(x: 0.5, y: 0.5)
grad.frame = CGRect(x: 100, y: 100, width: 200, height: 200)
self.view.layer.addSublayer(grad)
let c = CAShapeLayer()
let p = UIBezierPath(ovalIn: CGRect(x: 20, y: 20, width: 160, height: 160))
c.path = p.cgPath
c.fillColor = UIColor.clear.cgColor
c.strokeColor = UIColor.black.cgColor
c.lineWidth = 8
grad.mask = c
c.strokeEnd = 0
To make the animation happen, just say:
c.strokeEnd = 1
Related
need some help adding a drop shadow to a line created with a CAShapeLayer / UIBezierPath
This code snippet produces the following line shape as in the screenshot below
let bounds = UIScreen.main.bounds
let shapeLayer = CAShapeLayer()
shapeLayer.lineWidth = 10.0
shapeLayer.frame = CGRect(x: 0, y: 0 , width: bounds.width, height: bounds.width)
shapeLayer.lineWidth = 5.0
shapeLayer.strokeColor = UIColor.red.cgColor
let offset : CGFloat = 20
let arcCenter = shapeLayer.position
let radius = shapeLayer.bounds.size.width / 2.0 - offset
let startAngle = CGFloat(0.0)
let endAngle = CGFloat(1.0 * .pi)
let circlePath = UIBezierPath(arcCenter: arcCenter, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
shapeLayer.shadowOffset = CGSize(width: 0, height: 0)
shapeLayer.shadowColor = UIColor.red.cgColor
shapeLayer.shadowOpacity = 1.0
shapeLayer.shadowRadius = 25
shapeLayer.path = circlePath.cgPath
As you can see the shadow surrounds the outer part of the semi-circle shape.
Could anyone give me any hints on adding a drop shadow around the line only ?
Output:
UIView Extension:
extension UIView {
func renderCircle() {
let semiCircleLayer = CAShapeLayer()
let center = CGPoint (x: self.frame.size.width / 2, y: self.frame.size.height / 2)
let circleRadius = self.frame.size.width / 2
let circlePath = UIBezierPath(arcCenter: center, radius: circleRadius, startAngle: CGFloat(Double.pi * 2), endAngle: CGFloat(Double.pi), clockwise: true)
semiCircleLayer.path = circlePath.cgPath
semiCircleLayer.strokeColor = UIColor.red.cgColor
semiCircleLayer.fillColor = UIColor.clear.cgColor
semiCircleLayer.lineWidth = 8
semiCircleLayer.shadowColor = UIColor.red.cgColor
semiCircleLayer.shadowRadius = 25.0
semiCircleLayer.shadowOpacity = 1.0
semiCircleLayer.shadowPath = circlePath.cgPath.copy(strokingWithWidth: 25, lineCap: .round, lineJoin: .miter, miterLimit: 0)
semiCircleLayer.shadowOffset = CGSize(width: 1.0, height: 1.0)
self.layer.addSublayer(semiCircleLayer)
}
}
Usage:
semiCircleView.renderCircle()
I want to program a custom pie menu. In the code below you see how I create a pie menu with two items. My structure is the following: I'm using a rectengular UIBezierPath with a CAShapeLayer as the context as my circular background. Inside my circular background I've got a child, the inner small circle (also UIBezierPath with CAShapeLayer). The other childs of my circular background layer are the items, which are also a CAShapeLayer with using a custom UIBezierPath (I draw my items depends on the number of items (different degrees and so on)). Now I want to add inside every item layer a CATextLayer ("Item 1", "Item 2" and so on). My problem is, that I don't know how to set the frame of my specific item layers and how I can add the specific CATextLayer in the way that the text is dynamically inside the parent item layer. In my case the CATextLayer depends on the frame of the menu background layer.
func setMenuBackgroundLayer() {
//Draw a circle background with UIBezierPath for the static pie menu
let path = UIBezierPath(arcCenter: CGPoint(x: self.frame.size.width / 2, y: self.frame.size.height / 2), radius: menuRadius, startAngle: CGFloat(0), endAngle: CGFloat(Double.pi * 2), clockwise: true)
menuBackgroundLayer = CAShapeLayer()
menuBackgroundLayer.path = path.cgPath
menuBackgroundLayer.fillColor = menuBackgroundLayerColor.cgColor
menuBackgroundLayer.frame = self.bounds
menuBackgroundLayer.zPosition = 1
self.layer.addSublayer(menuBackgroundLayer)
//Draw the inner circle (back button)
let pathInner = UIBezierPath(arcCenter: CGPoint(x: menuBackgroundLayer.frame.size.width / 2, y: menuBackgroundLayer.frame.size.height / 2), radius: innerCircleRadius, startAngle: CGFloat(0), endAngle: CGFloat(Double.pi * 2), clockwise: true)
innerCircleLayer = CAShapeLayer()
innerCircleLayer.path = pathInner.cgPath
innerCircleLayer.fillColor = menuBackgroundLayerColor.cgColor
innerCircleLayer.strokeColor = UIColor.black.cgColor
innerCircleLayer.lineWidth = 1
innerCircleLayer.frame = menuBackgroundLayer.frame
menuBackgroundLayer.addSublayer(innerCircleLayer)
//Set the inner circle above all other menu items
innerCircleLayer.zPosition = 100
//Add the arrow image inside the inner circle
//addBackImage()
}
func insertMenuItems() {
//Compare which item has to get inserted and insert it
if numberOfItems == 1 {
let path = UIBezierPath(arcCenter: CGPoint(x: menuBackgroundLayer.frame.size.width / 2, y: menuBackgroundLayer.frame.size.height / 2), radius: menuRadius, startAngle: CGFloat(0), endAngle: CGFloat(Double.pi * 2), clockwise: true)
item1Layer = CAShapeLayer()
item1Layer.path = path.cgPath
item1Layer.fillColor = menuBackgroundLayerColor.cgColor
item1Layer.strokeColor = UIColor.black.cgColor
item1Layer.lineWidth = 1
item1Layer.frame = menuBackgroundLayer.bounds
menuBackgroundLayer.addSublayer(item1Layer)
item1Layer.zPosition = 2
let textLayer = CATextLayer()
textLayer.string = "ITEM 1"
textLayer.foregroundColor = UIColor.white.cgColor
textLayer.font = UIFont(name: "Avenir", size: 15.0)
textLayer.fontSize = 15.0
textLayer.alignmentMode = CATextLayerAlignmentMode.center
textLayer.zPosition = 3
textLayer.frame = item1Layer.bounds
textLayer.position = CGPoint(x: item1Layer.position.x, y: item1Layer.position.y + 20.0)
textLayer.contentsScale = UIScreen.main.scale
item1Layer.addSublayer(textLayer)
}
else if numberOfItems == 2 {
//Item 1
let path1 = UIBezierPath()
path1.move(to: CGPoint(x: menuBackgroundLayer.frame.size.width / 2, y: menuBackgroundLayer.frame.size.height / 2))
path1.addArc(withCenter: CGPoint(x: menuBackgroundLayer.frame.size.width / 2, y: menuBackgroundLayer.frame.size.height / 2), radius: menuRadius, startAngle: rad2deg(180.0), endAngle: rad2deg(0.0), clockwise: true)
path1.close()
item1Layer = CAShapeLayer()
item1Layer.path = path1.cgPath
item1Layer.fillColor = menuBackgroundLayerColor.cgColor
item1Layer.strokeColor = UIColor.black.cgColor
item1Layer.lineWidth = 1
item1Layer.frame = menuBackgroundLayer.bounds
menuBackgroundLayer.addSublayer(item1Layer)
item1Layer.zPosition = 2
let textLayer1 = CATextLayer()
textLayer1.string = "ITEM 1"
textLayer1.foregroundColor = UIColor.white.cgColor
textLayer1.font = UIFont(name: "Avenir", size: 15.0)
textLayer1.fontSize = 15.0
textLayer1.alignmentMode = CATextLayerAlignmentMode.center
textLayer1.zPosition = 3
textLayer1.frame = item1Layer.bounds
textLayer1.position = CGPoint(x: item1Layer.position.x, y: item1Layer.position.y + 20.0)
textLayer1.contentsScale = UIScreen.main.scale
item1Layer.addSublayer(textLayer1)
//Item 2
let path2 = UIBezierPath()
path2.move(to: CGPoint(x: menuBackgroundLayer.frame.size.width / 2, y: menuBackgroundLayer.frame.size.height / 2))
path2.addArc(withCenter: CGPoint(x: menuBackgroundLayer.frame.size.width / 2, y: menuBackgroundLayer.frame.size.height / 2), radius: menuRadius, startAngle: rad2deg(0.0), endAngle: rad2deg(180.0), clockwise: true)
path2.close()
item2Layer = CAShapeLayer()
item2Layer.path = path2.cgPath
item2Layer.fillColor = menuBackgroundLayerColor.cgColor
item2Layer.strokeColor = UIColor.black.cgColor
item2Layer.lineWidth = 1
item2Layer.frame = menuBackgroundLayer.bounds
menuBackgroundLayer.addSublayer(item2Layer)
item2Layer.zPosition = 2
let textLayer2 = CATextLayer()
textLayer2.string = "ITEM 2"
textLayer2.foregroundColor = UIColor.white.cgColor
textLayer2.font = UIFont(name: "Avenir", size: 15.0)
textLayer2.fontSize = 15.0
textLayer2.alignmentMode = CATextLayerAlignmentMode.center
textLayer2.zPosition = 3
textLayer2.frame = item2Layer.bounds
textLayer2.position = CGPoint(x: item2Layer.position.x, y: item2Layer.position.y + 20.0)
textLayer2.contentsScale = UIScreen.main.scale
item2Layer.addSublayer(textLayer2)
}
and so on...
}
So, here's a rough prototype which does the stuff you need, but not very precise.
If you want to rotate the text, this can be achieved with CATransform.
You can play with the code here: https://github.com/gatamar/stackoverflow_answers/tree/master/so64348954
Or I can make it more precise, if this is almost what you need.
The code for Pie Menu:
import Foundation
import UIKit
class HackLinesView: UIView {
init(frame: CGRect, partsCount parts: Int) {
super.init(frame: frame)
backgroundColor = .clear
let side = frame.width/2
// add lines
for part in 0..<parts {
let angle = CGFloat(part)/CGFloat(parts) * 2 * .pi
let lineLayer = CAShapeLayer()
lineLayer.backgroundColor = UIColor.black.cgColor
let path = UIBezierPath(rect: CGRect(x: 0, y: 0, width: 1, height: side))
lineLayer.path = path.cgPath
lineLayer.transform = CATransform3DMakeRotation(angle, 0, 0, 1)
layer.addSublayer(lineLayer)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
class PieMenuView: UIView {
init(frame: CGRect, partsCount parts: Int) {
assert( abs(frame.width-frame.height) < 0.001)
super.init(frame: frame)
setupLayers(parts)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupLayers(_ parts: Int) {
let side = bounds.width
let outerRadius = side * 0.5
let innerRadius = side * 0.2
// add outer circle
let outerCircleLayer = CAShapeLayer()
outerCircleLayer.frame = bounds
outerCircleLayer.cornerRadius = outerRadius
outerCircleLayer.backgroundColor = UIColor.orange.cgColor
layer.addSublayer(outerCircleLayer)
// add inner circle
let innerCircleLayer = CAShapeLayer()
innerCircleLayer.frame = CGRect(x: side/2-innerRadius, y: side/2-innerRadius, width: innerRadius*2, height: innerRadius*2)
innerCircleLayer.cornerRadius = innerRadius
innerCircleLayer.backgroundColor = UIColor.yellow.cgColor
layer.addSublayer(innerCircleLayer)
let linesView = HackLinesView(frame: CGRect(x: side/2, y: side/2, width: side, height: side), partsCount: parts)
addSubview(linesView)
// add text
for part in 0..<parts {
let angle = CGFloat(part)/CGFloat(parts) * 2 * .pi
let textLayer = CATextLayer()
textLayer.string = String(format: "%d", part)
textLayer.foregroundColor = UIColor.blue.cgColor
// calc the center for text layer
let x1 = side/2
let y1 = side/2
let x2 = x1 + cos(angle)*outerRadius
let y2 = y1 + sin(angle)*outerRadius
let textCenterX = (x1 + x2)/2, textCenterY = (y1 + y2)/2
let textLayerSide: CGFloat = 50
textLayer.frame = CGRect(x: textCenterX-textLayerSide/2, y: textCenterY-textLayerSide/2, width: textLayerSide, height: textLayerSide)
layer.addSublayer(textLayer)
}
}
}
I'm trying to replicate the default camera button on iOS devices:
I'm able to create a white circular button with black button within it. However, the black button is also filled, instead of just being a thin circle.
This is what I have (most of it has been copied from different sources and put together, so the code isn't efficient)
The object represents the button,
func applyRoundCorner(_ object: AnyObject) {
//object.layer.shadowColor = UIColor.black.cgColor
//object.layer.shadowOffset = CGSize(width: 0.0, height: 2.0)
object.layer.cornerRadius = (object.frame.size.width)/2
object.layer.borderColor = UIColor.black.cgColor
object.layer.borderWidth = 5
object.layer.masksToBounds = true
//object.layer.shadowRadius = 1.0
//object.layer.shadowOpacity = 0.5
var CircleLayer = CAShapeLayer()
let center = CGPoint (x: object.frame.size.width / 2, y: object.frame.size.height / 2)
let circleRadius = object.frame.size.width / 6
let circlePath = UIBezierPath(arcCenter: center, radius: circleRadius, startAngle: CGFloat(M_PI), endAngle: CGFloat(M_PI * 2), clockwise: true)
CircleLayer.path = circlePath.cgPath
CircleLayer.strokeColor = UIColor.black.cgColor
//CircleLayer.fillColor = UIColor.blue.cgColor
CircleLayer.lineWidth = 1
CircleLayer.strokeStart = 0
CircleLayer.strokeEnd = 1
object.layer.addSublayer(CircleLayer)
}
Basic Approach
You could do it like this (for the purpose of demonstration, I would do the button programmatically, using a playground):
let buttonWidth = 100.0
let button = UIButton(frame: CGRect(x: 0, y: 0, width: buttonWidth, height: buttonWidth))
button.backgroundColor = .white
button.layer.cornerRadius = button.frame.width / 2
Drawing Part:
So, after adding the button and do the desired setup (make it circular), here is part of how you could draw a circle in it:
let circlePath = UIBezierPath(arcCenter: CGPoint(x: buttonWidth / 2,y: buttonWidth / 2), radius: 40.0, startAngle: 0, endAngle: CGFloat.pi * 2, clockwise: true)
let circleLayer = CAShapeLayer()
circleLayer.path = circlePath.cgPath
circleLayer.fillColor = UIColor.clear.cgColor
circleLayer.strokeColor = UIColor.black.cgColor
circleLayer.lineWidth = 2.5
// adding the layer into the button:
button.layer.addSublayer(circleLayer)
Probably, circleLayer.fillColor = UIColor.clear.cgColor is the part you missing 🙂.
Therefore:
Back to your case:
Aside Bar Tip:
For implementing applyRoundCorner, I would suggest to let it has only the job for rounding the view, and then create another function to add the circle inside the view. And that's for avoiding any naming conflict, which means that when reading "applyRoundCorner" I would not assume that it is also would add circle to my view! So:
func applyRoundedCorners(for view: UIView) {
view.layer.cornerRadius = view.frame.size.width / 2
view.layer.borderColor = UIColor.white.cgColor
view.layer.borderWidth = 5.0
view.layer.masksToBounds = true
}
func drawCircle(in view: UIView) {
let circlePath = UIBezierPath(arcCenter: CGPoint(x: view.frame.size.width / 2,y: view.frame.size.width / 2),
radius: view.frame.size.width / 2.5,
startAngle: 0,
endAngle: CGFloat.pi * 2,
clockwise: true)
let shapeLayer = CAShapeLayer()
shapeLayer.path = circlePath.cgPath
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.strokeColor = UIColor.black.cgColor
shapeLayer.lineWidth = 2.5
button.layer.addSublayer(shapeLayer)
}
and now:
applyRoundedCorners(for: button)
drawCircle(in: button)
That's seems to be better. From another aspect, consider that you want to make a view to be circular without add a circle in it, with separated methods you could simply applyRoundedCorners(for: myView) without the necessary of adding a circle in it.
Furthermore:
As you can see, I changed AnyObject to UIView, it seems to be more logical to your case. So here is a cool thing that we could do:
extension UIView {
func applyRoundedCorners(for view: UIView) {
view.layer.cornerRadius = view.frame.size.width / 2
view.layer.borderColor = UIColor.white.cgColor
view.layer.borderWidth = 5.0
view.layer.masksToBounds = true
}
func drawCircle(in view: UIView) {
let circlePath = UIBezierPath(arcCenter: CGPoint(x: view.frame.size.width / 2,y: view.frame.size.width / 2),
radius: view.frame.size.width / 2.5,
startAngle: 0,
endAngle: CGFloat.pi * 2,
clockwise: true)
let shapeLayer = CAShapeLayer()
shapeLayer.path = circlePath.cgPath
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.strokeColor = UIColor.black.cgColor
shapeLayer.lineWidth = 2.5
button.layer.addSublayer(shapeLayer)
}
}
Now both applyRoundedCorners and drawCircle are implicitly included to the UIView (which means UIButton), instead of passing the button to these functions, you would be able to:
button.applyRoundedCorners()
button.drawCircle()
You just need to add circle Shape layer with lesser width and height
Try this code
func applyRoundCorner(_ object: UIButton) {
object.layer.cornerRadius = (object.frame.size.width)/2
object.layer.borderColor = UIColor.black.cgColor
object.layer.borderWidth = 5
object.layer.masksToBounds = true
let anotherFrame = CGRect(x: 12, y: 12, width: object.bounds.width - 24, height: object.bounds.height - 24)
let circle = CAShapeLayer()
let path = UIBezierPath(arcCenter: object.center, radius: anotherFrame.width / 2, startAngle: 0, endAngle: .pi * 2, clockwise: true)
circle.path = path.cgPath
circle.strokeColor = UIColor.black.cgColor
circle.lineWidth = 1.0
circle.fillColor = UIColor.clear.cgColor
object.layer.addSublayer(circle)
}
Note: Change frame value according to your requirements and best user experience
Output
I have no doubt there are a million different ways to approach this problem, this is just one...
I started with a UIButton for simplicity and speed, I might consider actually starting with a UIImage and simply setting the image properties of the button, but it would depend a lot on what I'm trying to achieve
internal extension FloatingPoint {
var degreesToRadians: Self { return self * .pi / 180 }
var radiansToDegrees: Self { return self * 180 / .pi }
}
class RoundButton: UIButton {
override func draw(_ rect: CGRect) {
makeButtonImage()?.draw(at: CGPoint(x: 0, y: 0))
}
func makeButtonImage() -> UIImage? {
let size = bounds.size
UIGraphicsBeginImageContext(CGSize(width: size.width, height: size.height))
defer {
UIGraphicsEndImageContext()
}
guard let ctx = UIGraphicsGetCurrentContext() else {
return nil
}
let center = CGPoint(x: size.width / 2.0, y: size.height / 2.0)
// Want to "over fill" the image area, so the mask can be applied
// to the entire image
let radius = min(size.width / 2.0, size.height / 2.0)
let innerRadius = radius * 0.75
let innerCircle = UIBezierPath(arcCenter: center,
radius: innerRadius,
startAngle: CGFloat(0.0).degreesToRadians,
endAngle: CGFloat(360.0).degreesToRadians,
clockwise: true)
// The color doesn't matter, only it's alpha level
UIColor.red.setStroke()
innerCircle.lineWidth = 4.0
innerCircle.stroke(with: .normal, alpha: 1.0)
let circle = UIBezierPath(arcCenter: center,
radius: radius,
startAngle: CGFloat(0.0).degreesToRadians,
endAngle: CGFloat(360.0).degreesToRadians,
clockwise: true)
UIColor.clear.setFill()
ctx.fill(bounds)
UIColor.white.setFill()
circle.fill(with: .sourceOut, alpha: 1.0)
return UIGraphicsGetImageFromCurrentImageContext()
}
}
nb: This is unoptimised! I would consider caching the result of makeButtonImage and invalidate it when the state/size of the button changes, just beware of that
Why is this approach any "better" then any other? I just want to say, it's not, but what it does create, is a "cut out" of the inner circle
It's a nitpick on my part, but I think it looks WAY better and is a more flexible solution, as you don't "need" a inner circle stroke color, blah, blah, blah
The solution makes use of the CoreGraphics CGBlendModes
Of course I might just do the whole thing in PaintCodeApp and be done with it
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.
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: