iOS Swift - Animate drawing of arc filled, not the border stroke - ios

I am trying to Implement drawing of Arc from certain start angle to certain end angle. I am trying to animate drawing of arc but have no luck.
I have looked for couple of implementations but all of them refers to be drawing of animating boder of circle.
Like drawing a pie but with one color. With certain start and end angle. Animated drawing of pie.
What I like is to have have animated filling while arc is being animated from start angle to end angle.
Like arc is being drawn clockwise and while drawing it is filling its inside color.
Any idea How can I acheive this in Swift?
Thanks in advance.
Edit
This is what I have drawn:
(gray color shows area covered by drawing pie)
Code for drawing Pie
import Foundation
import UIKit
#IBDesignable class PieChart : UIView {
#IBInspectable var percentage: CGFloat = 50 {
didSet {
self.setNeedsDisplay()
}
}
#IBInspectable var outerBorderWidth: CGFloat = 2 {
didSet {
self.setNeedsDisplay()
}
}
#IBInspectable var outerBorderColor: UIColor = UIColor.white {
didSet {
self.setNeedsDisplay()
}
}
#IBInspectable var percentageFilledColor: UIColor = UIColor.primary {
didSet {
self.setNeedsDisplay()
}
}
let circleLayer = CAShapeLayer()
override func draw(_ rect: CGRect) {
drawPie(rect: rect, endPercent: percentage, color: UIColor.primary)
}
func drawPie(rect: CGRect, endPercent: CGFloat = 70, color: UIColor) {
let center = CGPoint(x: rect.origin.x + rect.width / 2, y: rect.origin.y + rect.height / 2)
let radius = min(rect.width, rect.height) / 2
let gpath = UIBezierPath(arcCenter: center, radius: radius, startAngle: 0, endAngle: CGFloat(360.degreesToRadians), clockwise: true)
UIColor.white.setFill()
gpath.fill()
let circlePath = UIBezierPath(arcCenter: center, radius: radius, startAngle: CGFloat(0), endAngle:CGFloat(360.degreesToRadians), clockwise: true)
circleLayer.path = circlePath.cgPath
circleLayer.fillColor = UIColor.clear.cgColor
circleLayer.strokeColor = outerBorderColor.cgColor
circleLayer.lineWidth = outerBorderWidth
circleLayer.borderColor = outerBorderColor.cgColor
self.layer.addSublayer(circleLayer)
let π: CGFloat = 3.14
let halfPi: CGFloat = π / 2
let startPercent: CGFloat = 0.0
let startAngle = (startPercent / 100 * π * 2 - π ) + halfPi
let endAngle = (endPercent / 100 * π * 2 - π ) + halfPi
let path = UIBezierPath()
path.move(to: center)
path.addArc(withCenter: center, radius: radius, startAngle: CGFloat(startAngle), endAngle: CGFloat(endAngle), clockwise: true)
path.close()
percentageFilledColor.setFill()
path.fill()
}
}

I've created this code using playground and swift. This code will draw a pie chart animating the filling color. Make sure you have the Timeline pane open on Xcode to see the animation working. I couldn't find a way to pass the path function directly to CAAnimation so my workaround was to create a list of steps, each one containing the path for a percentage stage. You can change the steps for a smother animation.
//: Playground - noun: a place where people can play
import UIKit
import XCPlayground
let STEPS_ANIMATION = 50
let initialPercentage : CGFloat = 0.10
let finalPercentage : CGFloat = 0.75
func buildPiePath(frame : CGRect, percentage : CGFloat) -> UIBezierPath {
let newPath = UIBezierPath()
let startPoint = CGPoint(x: frame.size.width, y: frame.height / 2.0)
let centerPoint = CGPoint(x: frame.size.width / 2.0, y: frame.height / 2.0)
let startAngle : CGFloat = 0
let endAngle : CGFloat = percentage * 2 * CGFloat(M_PI)
newPath.move(to: centerPoint)
newPath.addLine(to: startPoint)
newPath.addArc(withCenter: centerPoint,
radius: frame.size.width / 2.0,
startAngle: startAngle,
endAngle: endAngle,
clockwise: true)
newPath.addLine(to: centerPoint)
newPath.close()
return newPath
}
func buildPiePathList(frame: CGRect,
startPercentage: CGFloat,
finalPercentage: CGFloat) -> [AnyObject] {
var listValues = [AnyObject]()
for index in 1...STEPS_ANIMATION {
let delta = finalPercentage - startPercentage
let currentPercentage = CGFloat(index) / CGFloat(STEPS_ANIMATION)
let percentage = CGFloat(startPercentage + (delta * currentPercentage))
listValues.append(buildPiePath(frame: frame,
percentage: percentage)
.cgPath)
}
return listValues
}
// Container for pie chart
let container = UIView(frame: CGRect(x: 0, y: 0, width: 400, height: 400))
container.backgroundColor = UIColor.gray
XCPShowView(identifier: "Container View", view: container)
let circleFrame = CGRect(x: 20, y: 20, width: 360, height: 360)
// Red background
let background = CAShapeLayer()
background.frame = circleFrame
background.fillColor = UIColor.red.cgColor
background.path = buildPiePath(frame: circleFrame, percentage: 1.0).cgPath
container.layer.addSublayer(background)
// Green foreground that animates
let foreground = CAShapeLayer()
foreground.frame = circleFrame
foreground.fillColor = UIColor.green.cgColor
foreground.path = buildPiePath(frame: circleFrame, percentage: initialPercentage).cgPath
container.layer.addSublayer(foreground)
// Filling animation
let fillAnimation = CAKeyframeAnimation(keyPath: "path")
fillAnimation.values = buildPiePathList(frame: circleFrame,
startPercentage: initialPercentage,
finalPercentage: finalPercentage)
fillAnimation.duration = 3
fillAnimation.calculationMode = kCAAnimationDiscrete
foreground.add(fillAnimation, forKey:nil)

Related

Drawing a scale on semi-circle Swift/UIView

Imagine I am having a full semi-circle from 0 to Pi from the unit circle. There is a small number on the left side named min and a big number on the right side called max. There are both interchangeable inside the app depending on some factors.
Does anybody of you have a nice idea on how to draw a scale like I did in the drawing below? I would like to have longer lines for every x mod 10 = 0 and three larger ones in between. The grey circle is just for orientation.
So I started with the following piece of code:
let radius = CGFloat(40)
let dashLong = CGFloat(10)
let dashShort = CGFloat(5)
let middle = CGPoint(x: 50, y: 50)
let leftAngle = CGFloat(Double.pi)
let rightAngle = CGFloat(0)
let min = 45 //random num
let max = 117 //random num
let innerPath = UIBezierPath(arcCenter: middle, radius: radius, startAngle: rightAngle, endAngle: leftAngle, clockwise: true)
let middlePath = UIBezierPath(arcCenter: middle, radius: radius+dashShort, startAngle: rightAngle, endAngle: leftAngle, clockwise: true)
let outerPath = UIBezierPath(arcCenter: middle, radius: radius+dashLong, startAngle: rightAngle, endAngle: leftAngle, clockwise: true)
So there is a radius and also the length of the two types of dashes in the scale. I chose 45 and 117 as random integers for the extrem values of the scale. My three paths which do not need to be drawn are just an orientation on where the dashes need to be started and ended on. So for 50,60,...110 there start at the innerPath and go to the outer one, I am pretty sure that must be in the same angle for a dash on all circles.
Does anyone has a very smart idea how to continue this to calc the dashes and draw them without getting messed up code?
Here's the math for drawing a tick mark.
Let's do everything as CGFloat to keep the conversions to a minimum:
let radius: CGFloat = 40
let dashLong: CGFloat = 10
let dashShort: CGFloat 5
let middle = CGPoint(x: 50, y: 50)
let leftAngle: CGFloat = .pi
let rightAngle: CGFloat = 0
let min: CGFloat = 45 //random num
let max: CGFloat = 117 //random num
First, compute your angle.
let value: CGFloat = 50
let angle = (max - value)/(max - min) * .pi
Now compute your two points:
let p1 = CGPoint(x: middle.x + cos(angle) * radius,
y: middle.y - sin(angle) * radius)
// use dashLong for a long tick, and dashShort for a short tick
let radius2 = radius + dashLong
let p2 = CGPoint(x: middle.x + cos(angle) * radius2,
y: middle.y - sin(angle) * radius2)
Then draw a line between p1 and p2.
Note: In iOS, the coordinate system is upside down with +Y being down, which is why the sin calculation is subtracted from middle.y.
Complete Example
enum TickStyle {
case short
case long
}
class ScaleView: UIView {
// ScaleView properties. If any are changed, redraw the view
var radius: CGFloat = 40 { didSet { self.setNeedsDisplay() } }
var dashLong: CGFloat = 10 { didSet { self.setNeedsDisplay() } }
var dashShort: CGFloat = 5 { didSet { self.setNeedsDisplay() } }
var middle = CGPoint(x: 50, y: 50) { didSet { self.setNeedsDisplay() } }
var leftAngle: CGFloat = .pi { didSet { self.setNeedsDisplay() } }
var rightAngle: CGFloat = 0 { didSet { self.setNeedsDisplay() } }
var min: CGFloat = 45 { didSet { self.setNeedsDisplay() } }
var max: CGFloat = 117 { didSet { self.setNeedsDisplay() } }
override func draw(_ rect: CGRect) {
let path = UIBezierPath()
// draw the arc
path.move(to: CGPoint(x: middle.x - radius, y: middle.y))
path.addArc(withCenter: middle, radius: radius, startAngle: leftAngle, endAngle: rightAngle, clockwise: true)
let startTick = ceil(min / 2.5) * 2.5
let endTick = floor(max / 2.5) * 2.5
// add tick marks every 2.5 units
for value in stride(from: startTick, through: endTick, by: 2.5) {
let style: TickStyle = value.truncatingRemainder(dividingBy: 10) == 0 ? .long : .short
addTick(at: value, style: style, to: path)
}
// stroke the path
UIColor.black.setStroke()
path.stroke()
}
// add a tick mark at value with style to path
func addTick(at value: CGFloat, style: TickStyle, to path: UIBezierPath) {
let angle = (max - value)/(max - min) * .pi
let p1 = CGPoint(x: middle.x + cos(angle) * radius,
y: middle.y - sin(angle) * radius)
var radius2 = radius
if style == .short {
radius2 += dashShort
} else if style == .long {
radius2 += dashLong
}
let p2 = CGPoint(x: middle.x + cos(angle) * radius2,
y: middle.y - sin(angle) * radius2)
path.move(to: p1)
path.addLine(to: p2)
}
}
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let view = ScaleView(frame: CGRect(x: 50, y: 50, width: 100, height: 60))
view.backgroundColor = .yellow
self.view.addSubview(view)
}
}
Picture of scale running in app:
My suggestion is Draw this semi circle in CALayer and Draw lines from centre of the semi circle in different CALayer and Mask both of them so that It appears Like this

Creating a thin black circle (unfilled) within a filled white circle (UIButton)

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

Transparent semi circle in top center of UIView in swift [duplicate]

I want to crop an UIView with bottom and top of repeated semi circle like this image
I had been working on your question and here is my results, you need create a UIBezierPath and apply to your desired view, use this code for that
Function to generate the desired BezierPath
func pathSemiCirclesPathForView(givenView: UIView, ciclesRadius:CGFloat = 10, circlesDistance : CGFloat = 2) ->UIBezierPath
{
let width = givenView.frame.size.width
let height = givenView.frame.size.height
let semiCircleWidth = CGFloat(ciclesRadius*2)
let semiCirclesPath = UIBezierPath()
semiCirclesPath.move(to: CGPoint(x:0, y:0))
var x = CGFloat(0)
var i = 0
while x < width {
x = (semiCircleWidth) * CGFloat(i) + (circlesDistance * CGFloat(i))
let pivotPoint = CGPoint(x: x + semiCircleWidth/2, y: height)
semiCirclesPath.addArc(withCenter: pivotPoint, radius: ciclesRadius, startAngle: -180 * .pi / 180.0, endAngle: 0 * .pi / 180.0, clockwise: true)
semiCirclesPath.addLine(to: CGPoint(x: semiCirclesPath.currentPoint.x + circlesDistance, y: height))
i += 1
}
semiCirclesPath.addLine(to: CGPoint(x:width,y: 0))
i = 0
while x > 0 {
x = width - (semiCircleWidth) * CGFloat(i) - (circlesDistance * CGFloat(i))
let pivotPoint = CGPoint(x: x - semiCircleWidth/2, y: 0)
semiCirclesPath.addArc(withCenter: pivotPoint, radius: ciclesRadius, startAngle: 0 * .pi / 180.0, endAngle: -180 * .pi / 180.0, clockwise: true)
semiCirclesPath.addLine(to: CGPoint(x: semiCirclesPath.currentPoint.x - circlesDistance, y: 0))
i += 1
}
semiCirclesPath.close()
return semiCirclesPath
}
Function to apply the BezierPath to any View
func applySemiCircleEffect(givenView: UIView){
let shapeLayer = CAShapeLayer(layer: givenView.layer)
shapeLayer.path = self.pathSemiCirclesPathForView(givenView: givenView).cgPath
shapeLayer.frame = givenView.bounds
shapeLayer.masksToBounds = true
shapeLayer.shadowOpacity = 1
shapeLayer.shadowColor = UIColor.black.cgColor
shapeLayer.shadowOffset = CGSize(width: 0, height: 0)
shapeLayer.shadowRadius = 3
givenView.layer.mask = shapeLayer
}
Use it
#IBOutlet weak var customView: UIView!
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
self.applySemiCircleEffect(givenView: customView)
}
This is how it looks
Hope this helps you, Happy Coding

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