Swift: How to draw a clock face using CoreGraphics and CoreText (Part 1)


This is part one of a two-part series of blogposts. It follows other recent blogposts on CGContext and the drawing of circles and regular polygons.

Drawing the circular background

To begin drawing a clock face the first ingredient is the circular, lowermost element.
override func drawRect(rect:CGRect)

    {            
        // obtain context
        let ctx = UIGraphicsGetCurrentContext()
            
        // decide on radius
        let rad = CGRectGetWidth(rect)/3.5
            
        let endAngle = CGFloat(2*M_PI)
            
        // add the circle to the context
        CGContextAddArc(ctx, CGRectGetMidX(rect), CGRectGetMidY(rect), rad, 0, endAngle, 1)
            
        // set fill color
        CGContextSetFillColorWithColor(ctx,UIColor.grayColor().CGColor)
            
        // set stroke color
        CGContextSetStrokeColorWithColor(ctx,UIColor.whiteColor().CGColor)
            
        // set line width
        CGContextSetLineWidth(ctx, 4.0)
        // use to fill and stroke path (see http://stackoverflow.com/questions/13526046/cant-stroke-path-after-filling-it )
            
        // draw the path
        CGContextDrawPath(ctx, kCGPathFillStroke);
    }
The comments explain here the different steps in the creation of the circle within a UIView subclass.

Adding the second markers using translation and rotation

In this previous post, I explained the process of translating and rotating a context. I now want to write code to demonstrate how this can be used to draw second markers around a clock face.

First a function is required to do the drawing based on the co-ordinates we're going to supply to it.
func degree2radian(a:CGFloat)->CGFloat {
        let b = CGFloat(M_PI) * a/180
        return b
}

func drawSecondMarker(#ctx:CGContextRef, #x:CGFloat, #y:CGFloat, #radius:CGFloat, #color:UIColor) {
    // generate a path    
    let path = CGPathCreateMutable()
    // move to starting point on edge of circle 
    CGPathMoveToPoint(path, nil, radius, 0)
    // draw line of required length
    CGPathAddLineToPoint(path, nil, x, y)
    // close subpath
    CGPathCloseSubpath(path)
    // add the path to the context
    CGContextAddPath(ctx, path)
    // set the line width
    CGContextSetLineWidth(ctx, 1.5)
    // set the line color
    CGContextSetStrokeColorWithColor(ctx,color.CGColor)
    // draw the line
    CGContextStrokePath(ctx)
}
Next we need to draw sixty of these lines:
for i in 1...60 {          
     // save the original position and origin
     CGContextSaveGState(ctx)
     // make translation
     CGContextTranslateCTM(ctx, CGRectGetMidX(rect), CGRectGetMidY(rect))
     // make rotation
     CGContextRotateCTM(ctx, degree2radian(CGFloat(i)*6))
     if i % 5 == 0 {
        // if an hour position we want a line slightly longer
        drawSecondMarker(ctx: ctx, x: rad-15, y:0, radius:rad, color: UIColor.whiteColor())
     }
     else {
        drawSecondMarker(ctx: ctx, x: rad-10, y:0, radius:rad, color: UIColor.whiteColor())
     }
    // restore state before next translation
    CGContextRestoreGState(ctx)
}
So the entire code looks like this and the result when built and run looks like this:


Building a path using points along the circumference of the circle

The other way to position these markers would be divide the circumference of the circle into 60 equal points and to build an array of CGPoints that can be used to position the second markers. The advantage of which is that the function can be reused to place the numbers around the face as well.

To calculate the points use the following code:
func degree2radian(a:CGFloat)->CGFloat {
        let b = CGFloat(M_PI) * a/180
        return b
}
func circleCircumferencePoints(sides:Int,x:CGFloat,y:CGFloat,radius:CGFloat,adjustment:CGFloat=0)->[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 = sides
        var points = [CGPoint]()
        while points.count <= sides {
            let xpo = cx - r * cos(angle * CGFloat(i)+degree2radian(adjustment))
            let ypo = cy - r * sin(angle * CGFloat(i)+degree2radian(adjustment))
            points.append(CGPoint(x: xpo, y: ypo))
            i--;
        }
        return points
    }
To then draw the second markers a further function is necessary:
func secondMarkers(#ctx:CGContextRef, #x:CGFloat, #y:CGFloat, #radius:CGFloat, #sides:Int, #color:UIColor) {
        // retrieve points
        let points = circleCircumferencePoints(sides,x,y,radius)
        // create path
        let path = CGPathCreateMutable()
        // determine length of marker as a fraction of the total radius
        var divider:CGFloat = 1/16
        for p in enumerate(points) {
            if p.index % 5 == 0 {
                divider = 1/8
            }
            else {
                divider = 1/16
            }
            
            let xn = p.element.x + divider*(x-p.element.x)
            let yn = p.element.y + divider*(y-p.element.y)
            // build path
            CGPathMoveToPoint(path, nil, p.element.x, p.element.y)
            CGPathAddLineToPoint(path, nil, xn, yn)
            CGPathCloseSubpath(path)
            // add path to context
            CGContextAddPath(ctx, path)
        }
        // set path color
        let cgcolor = color.CGColor
        CGContextSetStrokeColorWithColor(ctx,cgcolor)
        CGContextSetLineWidth(ctx, 3.0)
        CGContextStrokePath(ctx)
        
    }
Finally the secondMarkers() function must be called from within the drawRect() of the UIView subclass:
secondMarkers(ctx: ctx, x: CGRectGetMidX(rect), y: CGRectGetMidY(rect), radius: rad, sides: 60, color: UIColor.whiteColor())

Adding text to the clock face

Finding the points at which to position the text the circleCircumferencePoints() function is used. While the text itself will be a series of CFAttributedStrings, which take a dictionary with the same range of keys as an NSAttributedString:
func drawText(#rect:CGRect, #ctx:CGContextRef, #x:CGFloat, #y:CGFloat, #radius:CGFloat, #sides:Int, #color:UIColor) {
        
    // Flip text co-ordinate space, see: http://blog.spacemanlabs.com/2011/08/quick-tip-drawing-core-text-right-side-up/
    CGContextTranslateCTM(ctx, 0.0, CGRectGetHeight(rect))
    CGContextScaleCTM(ctx, 1.0, -1.0)
    // dictates on how inset the ring of numbers will be
    let inset:CGFloat = radius/3.5
    // An adjustment of 270 degrees to position numbers correctly
    let points = circleCircumferencePoints(sides,x,y,radius-inset,adjustment:270)
    let path = CGPathCreateMutable()

    for p in enumerate(points) {
        if p.index > 0 {
            // Font name must be written exactly the same as the system stores it (some names are hyphenated, some aren't) and must exist on the user's device. Otherwise there will be a crash. (In real use checks and fallbacks would be created.) For a list of iOS 7 fonts see here: http://support.apple.com/en-us/ht5878
            let aFont = UIFont(name: "Optima-Bold", size: radius/5)
            // create a dictionary of attributes to be applied to the string
            let attr:CFDictionaryRef = [NSFontAttributeName:aFont!,NSForegroundColorAttributeName:UIColor.whiteColor()]
            // create the attributed string
            let text = CFAttributedStringCreate(nil, p.index.description, attr)
            // create the line of text
            let line = CTLineCreateWithAttributedString(text)
            // retrieve the bounds of the text
            let bounds = CTLineGetBoundsWithOptions(line, CTLineBoundsOptions.UseOpticalBounds)
            // set the line width to stroke the text with
            CGContextSetLineWidth(ctx, 1.5)
            // set the drawing mode to stroke
            CGContextSetTextDrawingMode(ctx, kCGTextStroke)
            // Set text position and draw the line into the graphics context, text length and height is adjusted for
            let xn = p.element.x - bounds.width/2
            let yn = p.element.y - bounds.midY
            CGContextSetTextPosition(ctx, xn, yn)
            // the line of text is drawn - see https://developer.apple.com/library/ios/DOCUMENTATION/StringsTextFonts/Conceptual/CoreText_Programming/LayoutOperations/LayoutOperations.html
                // draw the line of text
                CTLineDraw(line, ctx)
            }
        }
        
    }

Once the text is drawn, the result looks like this:


Conclusion

The fixed-position components of the clock are now complete and the entire code can be found in this Gist. Next time I'll work on drawing and animating the hands.

Note: currently the code will dynamically resize its elements based on the device, but when testing on an iPad you will need to start the code running while the device is in the portrait position, or alternatively alter the size of the view added to the UIViewController.


Endorse on Coderwall

Comments