Swift: Going Around in Semicircles, Quarter Circles and Three-quarter Circles with UIBezierPath (Updated: now with pie chart drawing)
This is a short one (edit: well it started that way anyway). I'm simply posting some code that I wrote to solve a problem I was having. More on that problem in a later post. For now this is a semicircle extension for UIBezierPath that enables you to init a semi-circular path with one line of code:
UIBezierPath(semiCircleMidpoint: CGPoint.zero, radius: 100, facingDirection: .RightFacing)To do so you'll need to add this extension:
extension UIBezierPath { enum SemiCircleDirection { case RightFacing, LeftFacing, DownFacing, UpFacing var startAngle: CGFloat { switch self { case .RightFacing: return CGFloat(M_PI) * 1.5 case .LeftFacing: return CGFloat(M_PI_2) case .DownFacing: return 0 case .UpFacing: return CGFloat(M_PI) } } var endAngle: CGFloat { switch self { case .RightFacing: return CGFloat(M_PI_2) case .LeftFacing: return CGFloat(M_PI) * 1.5 case .DownFacing: return CGFloat(M_PI) case .UpFacing: return 0 } } } convenience init(semiCircleMidpoint:CGPoint, radius:CGFloat, facingDirection:SemiCircleDirection) { self.init(arcCenter: semiCircleMidpoint, radius: radius, startAngle: facingDirection.startAngle, endAngle: facingDirection.endAngle, clockwise: true) self.closePath() } }Copy and paste into a Playground to experiment with this. (Whether you choose to place the enum inside or outside the extension is up to you.)
Warning for the Playground
Be warned that UIBezierPaths are displayed in the playground upside down. Or at least they are upside down when we think in the co-ordinate system of iOS where the top left of the screen is (x = 0, y = 0). This is shown in this simple example of a triangle. And so while left- and right-facing semicircles will look fine, up- and down-facing will appear inverted in the little playground previews.But when used for the path of a CAShapeLayer the shapes will be rendered correctly.
Update: Beyond the semicircle
Having created semicircles, I couldn't resist extending this to quarter circles and three-quarter circles. To see if we could init a UIBezierPath of this type simply by writing:
UIBezierPath(quarterCircleCentre:CGPointMake(100,299), width:100, quadrant: .BottomLeft)The code isn't as refined as it could be and I've lazily added the three-quarter circle code to the enum for the quarter circle. Perhaps I need to change the name of the initialiser or split into two functions and enums but given the similarity in the code I didn't spend the time with two enums and two convenience inits:
extension UIBezierPath { enum QuarterCircleQuadrant { case TopLeft, BottomLeft, TopRight, BottomRight, TopLeftThreeQuarterCircle, BottomRightThreeQuarterCircle, BottomLeftThreeQuarterCircle, TopRightThreeQuarterCircle var startAngle: CGFloat { switch self { case .BottomLeft: return CGFloat(M_PI) case .BottomLeftThreeQuarterCircle: return CGFloat(M_PI) case .TopLeft: return CGFloat(M_PI) case .BottomRight: return 0 case .TopRight: return 0 case .TopRightThreeQuarterCircle: return 0 case .TopLeftThreeQuarterCircle: return CGFloat(M_PI) case .BottomRightThreeQuarterCircle: return 0 } } var endAngle: CGFloat { switch self { case .BottomLeft: return CGFloat(M_PI_2) case .BottomLeftThreeQuarterCircle: return CGFloat(M_PI_2) case .TopLeft: return CGFloat(M_PI) * 1.5 case .BottomRight: return CGFloat(M_PI_2) case .TopRight: return CGFloat(M_PI) * 1.5 case .TopRightThreeQuarterCircle: return CGFloat(M_PI) * 1.5 case .TopLeftThreeQuarterCircle: return CGFloat(M_PI) * 1.5 case .BottomRightThreeQuarterCircle: return CGFloat(M_PI_2) } } var clockwise: Bool { switch self { case .BottomLeft: return false case .TopLeft: return true case .BottomRight: return true case .TopRight: return false case .BottomLeftThreeQuarterCircle: return true case .TopRightThreeQuarterCircle: return true case .TopLeftThreeQuarterCircle: return false case .BottomRightThreeQuarterCircle: return false } } } convenience init(quarterCircleCentre centre:CGPoint, radius:CGFloat, quadrant:QuarterCircleQuadrant) { self.init() self.moveToPoint(CGPointMake(centre.x, centre.y)) self.addArcWithCenter(centre, radius:radius, startAngle:quadrant.startAngle, endAngle: quadrant.endAngle, clockwise:quadrant.clockwise) self.closePath() } }
What's next?
Well let's have some fun and create an exploded pie chart like this:Using the following code (and utilising our new extensions):
let topRight = UIBezierPath(quarterCircleCentre:CGPointMake(210,190), radius:100, quadrant: .TopRight) let topRightThreeQuarter = UIBezierPath(quarterCircleCentre:CGPointMake(200,200), radius:100, quadrant: .TopRightThreeQuarterCircle) let topLeftShapeLayer = CAShapeLayer() topLeftShapeLayer.path = topRight.CGPath topLeftShapeLayer.fillColor = UIColor.orangeColor().CGColor let topLeftThreeShapeLayer = CAShapeLayer() topLeftThreeShapeLayer.path = topRightThreeQuarter.CGPath topLeftThreeShapeLayer.fillColor = UIColor.orangeColor().CGColor let view = UIView(frame: CGRectMake(0,0,400,400)) view.layer.addSublayer(topLeftShapeLayer) view.layer.addSublayer(topLeftThreeShapeLayer) view
Flexibility
Of course if we were to be taking statistical data and transforming it into an actual pie chart we wouldn't use enums. Instead we'd want to be able to create segments of a circle far more freely. So first of all we'll write a method for calculating radians from degrees:extension CGFloat { func radians() -> CGFloat { let b = CGFloat(M_PI) * (self/180) return b } }And next we'll write the extension to UIBezierPath once again to create the segments:
extension UIBezierPath { convenience init(circleSegmentCenter center:CGPoint, radius:CGFloat, startAngle:CGFloat, endAngle:CGFloat) { self.init() self.moveToPoint(CGPointMake(center.x, center.y)) self.addArcWithCenter(center, radius:radius, startAngle:startAngle.radians(), endAngle: endAngle.radians(), clockwise:true) self.closePath() } }Simple, isn't it? We move to the given point, draw an arc from that same point and then close the paths on both sides of the arc back to the starting point. We can now write:
UIBezierPath(circleSegmentCenter: CGPoint.zero, radius: 100, startAngle: 250, endAngle: 360)And wonder why we bothered will all the enums. Although arguably it is convenient should you have a need for fixed angles to not think about the orientation of a circle and how to get the results you want. Especially true in the case of the semicircle where there is little code involved in our system.
Update: added pie chart
So now we are ready to add some flexibility and create a simple pie chart with any number of segments and any number of colours. And this is the entire code:
extension CGFloat { func radians() -> CGFloat { let b = CGFloat(M_PI) * (self/180) return b } } extension UIBezierPath { convenience init(circleSegmentCenter center:CGPoint, radius:CGFloat, startAngle:CGFloat, endAngle:CGFloat) { self.init() self.moveToPoint(CGPointMake(center.x, center.y)) self.addArcWithCenter(center, radius:radius, startAngle:startAngle.radians(), endAngle: endAngle.radians(), clockwise:true) self.closePath() } } func pieChart(pieces:[(UIBezierPath, UIColor)], viewRect:CGRect) -> UIView { var layers = [CAShapeLayer]() for p in pieces { let layer = CAShapeLayer() layer.path = p.0.CGPath layer.fillColor = p.1.CGColor layer.strokeColor = UIColor.whiteColor().CGColor layers.append(layer) } let view = UIView(frame: viewRect) for l in layers { view.layer.addSublayer(l) } return view } let rectSize = CGRectMake(0,0,400,400) let centrePointOfChart = CGPointMake(CGRectGetMidX(rectSize),CGRectGetMidY(rectSize)) let radius:CGFloat = 100 let piePieces = [(UIBezierPath(circleSegmentCenter: centrePointOfChart, radius: radius, startAngle: 250, endAngle: 360),UIColor.brownColor()), (UIBezierPath(circleSegmentCenter: centrePointOfChart, radius: radius, startAngle: 0, endAngle: 200),UIColor.orangeColor()), (UIBezierPath(circleSegmentCenter: centrePointOfChart, radius: radius, startAngle: 200, endAngle: 250),UIColor.lightGrayColor())] pieChart(piePieces, viewRect: CGRectMake(0,0,400,400))
Further ideas
I started to swiftify this code for the outside arc of a quarter circle, but time was short at present, so I only got this far:func oppositeArcOfPathForBottomRightCorner(width:CGFloat) -> UIBezierPath { let path = UIBezierPath() path.moveToPoint(CGPointMake(0, width)) path.addLineToPoint(CGPointMake(width, width)) path.addArcWithCenter(CGPointMake(0, 0), radius:width, startAngle:0, endAngle: CGFloat(M_PI_2), clockwise:true) path.closePath() return path; } oppositeArcOfPathForBottomRightCorner(100)
I'm using this code and it works great to draw my pie-chart. But I've spent the last 4 hours trying to add text (eg "24%") to the individual slices and can't get any text to show up at all....and certainly not centered on the slice. When I print the layer.bounds, they all show to be 0,0,0,0 so I'm guessing that is clipping my text somehow. But I can see the slices because clipToBounds defaults to false so that doesn't make sense..... all tips appreciated!
ReplyDeleteThis code - https://gist.github.com/sketchytech/d077388a09ff4455be5d64747374155a - demonstrates how to add a single text label, hopefully you can extrapolate from there.
Deletethanks so much!!! I'll dig into it right now!
Deleteoh, I meant to ask you: do you really need two loops in your pieChart function or can you just add the CAShapeLayer() to the view as they are built? I'm asking what's the value of the intermediate" layers" array?
Deletethis is close; I think it has a problem when startAngle > endAngle (e.g. a slice starts at 270 & ends at 45) but I'm not sure yet....something is wrong with my positioning & I'm still trying to figure it out but this is a great start
Delete(1) One loop is fine and would be better.
Delete(2) As regards positioning you need to treat 45 as 405 (i.e. 360 + 45), thinking of it as a full revolution and then the additional value. So the midAngle be 337.5 degrees