Swift: Drawing Regular Polygons with CGPath, CGContext, UIBezierPath and CAShapeLayer (Xcode 7.1.1; updated Swift 3, Xcode 8)
"You can think of a graphics context as a drawing destination" (Apple Developer)** Jump to Swift 3 code **
If you are going to draw using the CoreGraphics framework, then the first thing to get to grips with is the graphics context. This is a "drawing destination", which might either be a view or the offscreen location of a layer, PDF or a bitmap image.
You obtain the context for a view from within the drawRect: method of a subclassed view. This is true for OS X and iOS, where the type to be subclassed is NSView and UIView respectively.
Drawing with Swift in iOS
Looking at the case of iOS, it is necessary first of all to write functions (or methods) for obtaining the points of a regular polygon. Here I adapt a piece of code from an earlier post:extension CGFloat { func radians() -> CGFloat { let b = CGFloat(M_PI) * (self/180) return b } } func polygonPointArray(sides:Int,x:CGFloat,y:CGFloat,radius:CGFloat,offset:CGFloat)->[CGPoint] { let angle = (360/CGFloat(sides)).radians() let cx = x // x origin let cy = y // y origin let r = radius // radius of circle var i = 0 var points = [CGPoint]() while i <= sides { let xpo = cx + r * cos(angle * CGFloat(i) - offset.radians()) let ypo = cy + r * sin(angle * CGFloat(i) - offset.radians()) points.append(CGPoint(x: xpo, y: ypo)) i++ } return points }It has been adapted to work as consistently as possible with CGFloat. This is because drawing is an "expensive" operation and most interface elements employ the CGFloat type to allow 64-bit devices to use 64-bit precision while 32-bit devices can enjoy the speed of 32-bit precision.
Building a CGPath
Now that the points of the polygon are available via the first method, the next step is to create a path. This is done by working through the points in the polygon point array:
func polygonPath(x:CGFloat, y:CGFloat, radius:CGFloat, sides:Int, offset: CGFloat) -> CGPathRef { let path = CGPathCreateMutable() let points = polygonPointArray(sides,x: x,y: y,radius: radius, offset: offset) let cpg = points[0] CGPathMoveToPoint(path, nil, cpg.x, cpg.y) for p in points { CGPathAddLineToPoint(path, nil, p.x, p.y) } CGPathCloseSubpath(path) return path }
Drawing the polygon: Three options
With the points array in place and the path building method available, now the drawing function is needed. And here there are three options available. First is by adding the path to the context:
func drawPolygonUsingPath(ctx:CGContextRef, x:CGFloat, y:CGFloat, radius:CGFloat, sides:Int, color:UIColor, offset:CGFloat) { let path = polygonPath(x, y: y, radius: radius, sides: sides, offset: offset) CGContextAddPath(ctx, path) let cgcolor = color.CGColor CGContextSetFillColorWithColor(ctx,cgcolor) CGContextFillPath(ctx) }Second is by using the UIBezierPath class, which provides a wrapper that removes the necessity of referencing the context directly unless you wish to specify a context that is not the current view (see here) or unless you wish to apply transforms and therefore need to save and restore the current context.
func drawPolygonBezier(x:CGFloat, y:CGFloat, radius:CGFloat, sides:Int, color:UIColor, offset:CGFloat) -> UIBezierPath { let path = polygonPath(x, y: y, radius: radius, sides: sides, offset: offset) let bez = UIBezierPath(CGPath: path) // no need to convert UIColor to CGColor when using UIBezierPath color.setFill() bez.fill() return bez }In the third method, the path isn't required at all because the lines are added directly to the context:
func drawPolygon(ctx:CGContextRef, x:CGFloat, y:CGFloat, radius:CGFloat, sides:Int, color:UIColor, offset:CGFloat) { let points = polygonPointArray(sides,x: x,y: y,radius: radius, offset: offset) CGContextAddLines(ctx, points, points.count) let cgcolor = color.CGColor CGContextSetFillColorWithColor(ctx,cgcolor) CGContextFillPath(ctx) }
Note: Using this method the polygonPath() function goes unused.
The last step
Now that all of the functions are in place it is possible to create a UIView subclass that implements each of the three methods:class View: UIView { override func drawRect(rect:CGRect) { let ctx = UIGraphicsGetCurrentContext() drawPolygonUsingPath(ctx!, x: CGRectGetMidX(rect),y: CGRectGetMidY(rect),radius: CGRectGetWidth(rect)/3, sides: 3, color: UIColor.blueColor(), offset:0) drawPolygonBezier(CGRectGetMidX(rect),y: CGRectGetMidY(rect),radius: CGRectGetWidth(rect)/4, sides: 4, color: UIColor.yellowColor(), offset:0) drawPolygon(ctx!, x: CGRectGetMidX(rect),y: CGRectGetMidY(rect),radius: CGRectGetWidth(rect)/5, sides: 6, color: UIColor.greenColor(), offset:0) } } View(frame: CGRect(x: 0, y: 0, width: 200, height: 200))The example results in this:
Moving into layers (CAShapeLayer)
Extending this a little further most of the code can also be reused when adding a layer to a view. By creating the following function to return a CAShapeLayer instance:
func drawPolygonLayer(x:CGFloat, y:CGFloat, radius:CGFloat, sides:Int, color:UIColor, offset:CGFloat) -> CAShapeLayer { let shape = CAShapeLayer() shape.path = polygonPath(x, y: y, radius: radius, sides: sides, offset: offset) shape.fillColor = color.CGColor return shape }It is then possible to add (within the viewDidLoad method of the ViewController) the following:
let bez = drawPolygonLayer(CGRectGetMidX(v.frame), y: CGRectGetMidY(v.frame), radius: 30, sides: 6, color: UIColor.blueColor(), offset: 30) v.layer.addSublayer(bez)Note: originally I placed this code in the drawRect, which works but @ChromophoreApp raised concerns that this would lead to a duplication of added layers every time drawRect is called. He's absolutely correct about this oversight, so I've updated it.
@sketchyTech I think that using addSublayer within drawRect will result in duplicate layers getting added to the layer hierarchy.
— Chromophore (@ChromophoreApp) November 6, 2014
The full code
And to finish here is the complete code that can be cut and pasted into your initial view controller:
extension CGFloat { func radians() -> CGFloat { let b = CGFloat(M_PI) * (self/180) return b } } func polygonPointArray(sides:Int,x:CGFloat,y:CGFloat,radius:CGFloat,offset:CGFloat)->[CGPoint] { let angle = (360/CGFloat(sides)).radians() let cx = x // x origin let cy = y // y origin let r = radius // radius of circle var i = 0 var points = [CGPoint]() while i <= sides { let xpo = cx + r * cos(angle * CGFloat(i) - offset.radians()) let ypo = cy + r * sin(angle * CGFloat(i) - offset.radians()) points.append(CGPoint(x: xpo, y: ypo)) i++ } return points } func polygonPath(x:CGFloat, y:CGFloat, radius:CGFloat, sides:Int, offset: CGFloat) -> CGPathRef { let path = CGPathCreateMutable() let points = polygonPointArray(sides,x: x,y: y,radius: radius, offset: offset) let cpg = points[0] CGPathMoveToPoint(path, nil, cpg.x, cpg.y) for p in points { CGPathAddLineToPoint(path, nil, p.x, p.y) } CGPathCloseSubpath(path) return path } func drawPolygonBezier(x:CGFloat, y:CGFloat, radius:CGFloat, sides:Int, color:UIColor, offset:CGFloat) -> UIBezierPath { let path = polygonPath(x, y: y, radius: radius, sides: sides, offset: offset) let bez = UIBezierPath(CGPath: path) // no need to convert UIColor to CGColor when using UIBezierPath color.setFill() bez.fill() return bez } func drawPolygonUsingPath(ctx:CGContextRef, x:CGFloat, y:CGFloat, radius:CGFloat, sides:Int, color:UIColor, offset:CGFloat) { let path = polygonPath(x, y: y, radius: radius, sides: sides, offset: offset) CGContextAddPath(ctx, path) let cgcolor = color.CGColor CGContextSetFillColorWithColor(ctx,cgcolor) CGContextFillPath(ctx) } func drawPolygon(ctx:CGContextRef, x:CGFloat, y:CGFloat, radius:CGFloat, sides:Int, color:UIColor, offset:CGFloat) { let points = polygonPointArray(sides,x: x,y: y,radius: radius, offset: offset) CGContextAddLines(ctx, points, points.count) let cgcolor = color.CGColor CGContextSetFillColorWithColor(ctx,cgcolor) CGContextFillPath(ctx) } func drawPolygonLayer(x:CGFloat, y:CGFloat, radius:CGFloat, sides:Int, color:UIColor, offset:CGFloat) -> CAShapeLayer { let shape = CAShapeLayer() shape.path = polygonPath(x, y: y, radius: radius, sides: sides, offset: offset) shape.fillColor = color.CGColor return shape } class View: UIView { override func drawRect(rect:CGRect) { let ctx = UIGraphicsGetCurrentContext() drawPolygonUsingPath(ctx!, x: CGRectGetMidX(rect),y: CGRectGetMidY(rect),radius: CGRectGetWidth(rect)/3, sides: 3, color: UIColor.blueColor(), offset:0) drawPolygonBezier(CGRectGetMidX(rect),y: CGRectGetMidY(rect),radius: CGRectGetWidth(rect)/4, sides: 4, color: UIColor.yellowColor(), offset:0) drawPolygon(ctx!, x: CGRectGetMidX(rect),y: CGRectGetMidY(rect),radius: CGRectGetWidth(rect)/5, sides: 6, color: UIColor.greenColor(), offset:0) } } View(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
Swift 3, Xcode 8
extension CGFloat { func radians() -> CGFloat { let b = CGFloat(M_PI) * (self/180) return b } } func polygonPointArray(sides:Int,x:CGFloat,y:CGFloat,radius:CGFloat,offset:CGFloat)->[CGPoint] { let angle = (360/CGFloat(sides)).radians() let cx = x // x origin let cy = y // y origin let r = radius // radius of circle var i = 0 var points = [CGPoint]() while i <= sides { let xpo = cx + r * cos(angle * CGFloat(i) - offset.radians()) let ypo = cy + r * sin(angle * CGFloat(i) - offset.radians()) points.append(CGPoint(x: xpo, y: ypo)) i += 1 } return points } func polygonPath(x:CGFloat, y:CGFloat, radius:CGFloat, sides:Int, offset: CGFloat) -> CGPath { let path = CGMutablePath() let points = polygonPointArray(sides: sides,x: x,y: y,radius: radius, offset: offset) let cpg = points[0] path.move(to: cpg) for p in points { path.addLine(to: p) } path.closeSubpath() return path } func drawPolygonBezier(x:CGFloat, y:CGFloat, radius:CGFloat, sides:Int, color:UIColor, offset:CGFloat) -> UIBezierPath { let path = polygonPath(x: x, y: y, radius: radius, sides: sides, offset: offset) let bez = UIBezierPath(cgPath: path) // no need to convert UIColor to CGColor when using UIBezierPath color.setFill() bez.fill() return bez } func drawPolygonUsingPath(ctx:CGContext, x:CGFloat, y:CGFloat, radius:CGFloat, sides:Int, color:UIColor, offset:CGFloat) { let path = polygonPath(x: x, y: y, radius: radius, sides: sides, offset: offset) ctx.addPath(path) let cgcolor = color.cgColor ctx.setFillColor(cgcolor) ctx.fillPath() } func drawPolygon(ctx:CGContext, x:CGFloat, y:CGFloat, radius:CGFloat, sides:Int, color:UIColor, offset:CGFloat) { let points = polygonPointArray(sides: sides,x: x,y: y,radius: radius, offset: offset) ctx.addLines(between: points) let cgcolor = color.cgColor ctx.setFillColor(cgcolor) ctx.fillPath() } func drawPolygonLayer(x:CGFloat, y:CGFloat, radius:CGFloat, sides:Int, color:UIColor, offset:CGFloat) -> CAShapeLayer { let shape = CAShapeLayer() shape.path = polygonPath(x: x, y: y, radius: radius, sides: sides, offset: offset) shape.fillColor = color.cgColor return shape } class View: UIView { override func draw(_ rect:CGRect) { let ctx = UIGraphicsGetCurrentContext() drawPolygonUsingPath(ctx: ctx!, x: rect.midX,y: rect.midY,radius: rect.width/3, sides: 3, color: UIColor.blue, offset:0) drawPolygonBezier(x: rect.midX,y: rect.midY,radius: rect.width/4, sides: 4, color: UIColor.yellow, offset:0) drawPolygon(ctx: ctx!, x: rect.midX,y: rect.midY,radius: rect.width/5, sides: 6, color: UIColor.green, offset:0) } } View(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
Using a CAShapeLayer as a mask
If you want to achieve the type of effect that you see at the head of this blogpost, then you simply use the created layer as a mask, like so (inside the viewDidLoad method of the ViewController):
// Add image let img = UIImageView(image: UIImage(named: "Flumserberg.jpg")) v.layer.addSublayer(img.layer) // Create and add mask let polyLayer = drawPolygonLayer(CGRectGetMidX(v.frame),y: CGRectGetMidY(v.frame),radius: CGRectGetMidX(v.frame), sides: 12, color: UIColor.yellowColor(), offset: 0) v.layer.mask = polyLayerThe image is from here.
Swift 3, Xcode 8
let v = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 200)) let img = UIImageView(image: UIImage(named: "Flumserberg.jpg")) v.layer.addSublayer(img.layer) // Create and add mask let polyLayer = drawPolygonLayer(x: v.frame.midX,y: v.frame.midY,radius: v.frame.midX, sides: 12, color: UIColor.yellow, offset: 0) v.layer.mask = polyLayer
Hi. Is it permissible to release a free app based on your tutorial that displays a "duodecimal-ised" clock? It feels silly as it would virtually be wholesale plagiarism given how little needs to be modified to implement. That said I'm not a programmer, just a vague comprehension in how to "hello world" in a few languages.
ReplyDeleteIn this 'plan' you of course would be getting full credit for the work diminishing the plagiarism charge. Then again it does not appear to be the intent of this tutorial to actually implement this as an app but to teach the concepts behind a potential app.
Regards
Absolutely fine, anything you see on this blog you can reuse, and an acknowledgement is always welcome.
DeleteThanks for unlocking the mystery of CGPath for me. Made some cool physics bodies with these functions. Updated code and modified to rotate shape where I needed it. Offset parameter added is in radians. Normally it draws shapes starting at angle 0 but sometimes shape needs to be rotated to match existing bodies. New code (swift 2): func degree2radian(a:CGFloat)->CGFloat {
ReplyDeletelet b = CGFloat(M_PI) * a/180
return b
}
func polygonPointArray(sides:Int,x:CGFloat,y:CGFloat,radius:CGFloat,offset:CGFloat)->[CGPoint] {
let angle = degree2radian(360/CGFloat(sides))
let cx = x // x origin
let cy = y // y origin
let r = radius // radius of circle
var i = 0
var points = [CGPoint]()
while i <= sides {
let xpo = cx + r * cos(angle * CGFloat(i) - offset)
let ypo = cy + r * sin(angle * CGFloat(i) - offset)
// need to shift x and y by a few degrees so that a triangle lines up with a ship
points.append(CGPoint(x: xpo, y: ypo))
i++
}
print("Points: \(points)")
return points
}
func polygonPath(x:CGFloat, y:CGFloat, radius:CGFloat, sides:Int, offset: CGFloat) -> CGPathRef {
let path = CGPathCreateMutable()
let points = polygonPointArray(sides,x: x,y: y,radius: radius, offset: offset)
let cpg = points[0]
CGPathMoveToPoint(path, nil, cpg.x, cpg.y)
for p in points {
CGPathAddLineToPoint(path, nil, p.x, p.y)
}
CGPathCloseSubpath(path)
return path
}
Pleasure, thanks for prompting me to update code.
DeleteThis comment has been removed by the author.
ReplyDelete