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.

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 = polyLayer
The 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



Comments

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

    In 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

    ReplyDelete
    Replies
    1. Absolutely fine, anything you see on this blog you can reuse, and an acknowledgement is always welcome.

      Delete
  2. Thanks 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 {
    let 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
    }

    ReplyDelete
    Replies
    1. Pleasure, thanks for prompting me to update code.

      Delete
  3. This comment has been removed by the author.

    ReplyDelete

Post a Comment