Skip to content

Instantly share code, notes, and snippets.

@sketchytech
Last active May 23, 2020 02:00
Show Gist options
  • Star 7 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save sketchytech/5ed3c8241ce1509c5342 to your computer and use it in GitHub Desktop.
Save sketchytech/5ed3c8241ce1509c5342 to your computer and use it in GitHub Desktop.
Swift: How to draw a clock face using CoreGraphics and CoreText (Part 2: Animating with CABasicAnimation)
// see this blogpost: http://sketchytech.blogspot.co.uk/2014/11/swift-how-to-draw-clock-face-using_12.html
import UIKit
class ViewController: UIViewController {
func rotateLayer(currentLayer:CALayer,dur:CFTimeInterval){
var angle = degree2radian(360)
// rotation http://stackoverflow.com/questions/1414923/how-to-rotate-uiimageview-with-fix-point
var theAnimation = CABasicAnimation(keyPath:"transform.rotation.z")
theAnimation.duration = dur
// Make this view controller the delegate so it knows when the animation starts and ends
theAnimation.delegate = self
theAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
// Use fromValue and toValue
theAnimation.fromValue = 0
theAnimation.repeatCount = Float.infinity
theAnimation.toValue = angle
// Add the animation to the layer
currentLayer.addAnimation(theAnimation, forKey:"rotate")
}
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
let endAngle = CGFloat(2*M_PI)
let newView = View(frame: CGRect(x: 0, y: 0, width: CGRectGetWidth(self.view.frame), height: CGRectGetWidth(self.view.frame)))
self.view.addSubview(newView)
let time = timeCoords(CGRectGetMidX(newView.frame), CGRectGetMidY(newView.frame), ctime(),50)
// Do any additional setup after loading the view, typically from a nib.
// Hours
let hourLayer = CAShapeLayer()
hourLayer.frame = newView.frame
let path = CGPathCreateMutable()
CGPathMoveToPoint(path, nil, CGRectGetMidX(newView.frame), CGRectGetMidY(newView.frame))
CGPathAddLineToPoint(path, nil, time.h.x, time.h.y)
hourLayer.path = path
hourLayer.lineWidth = 4
hourLayer.lineCap = kCALineCapRound
hourLayer.strokeColor = UIColor.blackColor().CGColor
// see for rasterization advice http://stackoverflow.com/questions/24316705/how-to-draw-a-smooth-circle-with-cashapelayer-and-uibezierpath
hourLayer.rasterizationScale = UIScreen.mainScreen().scale;
hourLayer.shouldRasterize = true
self.view.layer.addSublayer(hourLayer)
// time it takes for hour hand to pass through 360 degress
rotateLayer(hourLayer,dur:43200)
// Minutes
let minuteLayer = CAShapeLayer()
minuteLayer.frame = newView.frame
let minutePath = CGPathCreateMutable()
CGPathMoveToPoint(minutePath, nil, CGRectGetMidX(newView.frame), CGRectGetMidY(newView.frame))
CGPathAddLineToPoint(minutePath, nil, time.m.x, time.m.y)
minuteLayer.path = minutePath
minuteLayer.lineWidth = 3
minuteLayer.lineCap = kCALineCapRound
minuteLayer.strokeColor = UIColor.whiteColor().CGColor
minuteLayer.rasterizationScale = UIScreen.mainScreen().scale;
minuteLayer.shouldRasterize = true
self.view.layer.addSublayer(minuteLayer)
rotateLayer(minuteLayer,dur: 3600)
// Seconds
let secondLayer = CAShapeLayer()
secondLayer.frame = newView.frame
let secondPath = CGPathCreateMutable()
CGPathMoveToPoint(secondPath, nil, CGRectGetMidX(newView.frame), CGRectGetMidY(newView.frame))
CGPathAddLineToPoint(secondPath, nil, time.s.x, time.s.y)
secondLayer.path = secondPath
secondLayer.lineWidth = 1
secondLayer.lineCap = kCALineCapRound
secondLayer.strokeColor = UIColor.redColor().CGColor
secondLayer.rasterizationScale = UIScreen.mainScreen().scale;
secondLayer.shouldRasterize = true
self.view.layer.addSublayer(secondLayer)
rotateLayer(secondLayer,dur: 60)
let centerPiece = CAShapeLayer()
let circle = UIBezierPath(arcCenter: CGPoint(x:CGRectGetMidX(newView.frame),y:CGRectGetMidX(newView.frame)), radius: 4.5, startAngle: 0, endAngle: endAngle, clockwise: true)
// thanks to http://stackoverflow.com/a/19395006/1694526 for how to fill the color
centerPiece.path = circle.CGPath
centerPiece.fillColor = UIColor.whiteColor().CGColor
self.view.layer.addSublayer(centerPiece)
}
}
// MARK: Retrieve time
func ctime ()->(h:Int,m:Int,s:Int) {
var t = time_t()
time(&t)
let x = localtime(&t) // returns UnsafeMutablePointer
return (h:Int(x.memory.tm_hour),m:Int(x.memory.tm_min),s:Int(x.memory.tm_sec))
}
// END: Retrieve time
// MARK: Calculate coordinates of time
func timeCoords(x:CGFloat,y:CGFloat,time:(h:Int,m:Int,s:Int),radius:CGFloat,adjustment:CGFloat=90)->(h:CGPoint, m:CGPoint,s:CGPoint) {
let cx = x // x origin
let cy = y // y origin
var r = radius // radius of circle
var points = [CGPoint]()
var angle = degree2radian(6)
func newPoint (t:Int) {
let xpo = cx - r * cos(angle * CGFloat(t)+degree2radian(adjustment))
let ypo = cy - r * sin(angle * CGFloat(t)+degree2radian(adjustment))
points.append(CGPoint(x: xpo, y: ypo))
}
// work out hours first
var hours = time.h
if hours > 12 {
hours = hours-12
}
let hoursInSeconds = time.h*3600 + time.m*60 + time.s
newPoint(hoursInSeconds*5/3600)
// work out minutes second
r = radius * 1.25
let minutesInSeconds = time.m*60 + time.s
newPoint(minutesInSeconds/60)
// work out seconds last
r = radius * 1.5
newPoint(time.s)
return (h:points[0],m:points[1],s:points[2])
}
// END: Calculate coordinates of hour
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
}
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)
}
func drawText(#rect:CGRect, #ctx:CGContextRef, #x:CGFloat, #y:CGFloat, #radius:CGFloat, #sides:NumberOfNumerals, #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.rawValue,x,y,radius-inset,adjustment:270)
let path = CGPathCreateMutable()
// multiplier enables correcting numbering when fewer than 12 numbers are featured, e.g. 4 sides will display 12, 3, 6, 9
let multiplier = 12/sides.rawValue
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: "DamascusBold", 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 str = String(p.index*multiplier)
let text = CFAttributedStringCreate(nil, str, 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)
}
}
}
enum NumberOfNumerals:Int {
case two = 2, four = 4, twelve = 12
}
class View: UIView {
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);
secondMarkers(ctx: ctx, x: CGRectGetMidX(rect), y: CGRectGetMidY(rect), radius: rad, sides: 60, color: UIColor.whiteColor())
drawText(rect:rect, ctx: ctx, x: CGRectGetMidX(rect), y: CGRectGetMidY(rect), radius: rad, sides: .twelve, color: UIColor.whiteColor())
}
}
@asdfhamiltonian
Copy link

Thank you for your tutorials and sample code drawing an analog clock face in swift. Do you allow parts of your code to be repurposed in apps, and if so, do you have a preferred method of attribution? Thanks!

@sketchytech
Copy link
Author

@asdfhamiltonian apologies for delay in answering your question. Yes you may repurpose, please simply acknowledge with a link to http://sketchytech.blogspot.com

@asdfhamiltonian
Copy link

Ok awesome thanks! I will be sure to include an acknowledgment with a link if/when I get the app finished. Sorry for the late reply as well, I didn't see your response until now.

@sketchytech
Copy link
Author

Swift 3

// see this blogpost: http://sketchytech.blogspot.co.uk/2014/11/swift-how-to-draw-clock-face-using_12.html

import UIKit
import PlaygroundSupport

class ViewController: UIViewController, CAAnimationDelegate {
    
    func rotateLayer(currentLayer:CALayer,dur:CFTimeInterval){
        
        let angle = degree2radian(360)
        
        // rotation http://stackoverflow.com/questions/1414923/how-to-rotate-uiimageview-with-fix-point
        let theAnimation = CABasicAnimation(keyPath:"transform.rotation.z")
        theAnimation.duration = dur
        // Make this view controller the delegate so it knows when the animation starts and ends
        theAnimation.delegate = self
        theAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
        // Use fromValue and toValue
        theAnimation.fromValue = 0
        theAnimation.repeatCount = Float.infinity
        theAnimation.toValue = angle
        
        // Add the animation to the layer
        currentLayer.add(theAnimation, forKey:"rotate")
        
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.
        let endAngle = CGFloat(2*M_PI)
        
        
        let newView = View(frame: CGRect(x: 0, y: 0, width: self.view.frame.width, height: self.view.frame.width))
        
        
        self.view.addSubview(newView)
        let time = timeCoords(x: newView.frame.midX, y: newView.frame.midY, time: ctime(),radius: 50)
        // Do any additional setup after loading the view, typically from a nib.
        // Hours
        let hourLayer = CAShapeLayer()
        hourLayer.frame = newView.frame
        let path = CGMutablePath()
        
        path.move(to: CGPoint(x:newView.frame.midX, y:newView.frame.midY))
        path.addLine(to: CGPoint(x:time.h.x, y:time.h.y))
        hourLayer.path = path
        hourLayer.lineWidth = 4
        hourLayer.lineCap = kCALineCapRound
        hourLayer.strokeColor = UIColor.black.cgColor
        
        // see for rasterization advice http://stackoverflow.com/questions/24316705/how-to-draw-a-smooth-circle-with-cashapelayer-and-uibezierpath
        hourLayer.rasterizationScale = UIScreen.main.scale;
        hourLayer.shouldRasterize = true
        
        self.view.layer.addSublayer(hourLayer)
        // time it takes for hour hand to pass through 360 degress
        rotateLayer(currentLayer: hourLayer,dur:43200)
        
        // Minutes
        let minuteLayer = CAShapeLayer()
        minuteLayer.frame = newView.frame
        let minutePath = CGMutablePath()
        
        minutePath.move(to: CGPoint(x:newView.frame.midX, y:newView.frame.midY))
        minutePath.addLine(to: CGPoint(x:time.m.x, y:time.m.y))
        minuteLayer.path = minutePath
        minuteLayer.lineWidth = 3
        minuteLayer.lineCap = kCALineCapRound
        minuteLayer.strokeColor = UIColor.white.cgColor
        
        minuteLayer.rasterizationScale = UIScreen.main.scale;
        minuteLayer.shouldRasterize = true
        
        self.view.layer.addSublayer(minuteLayer)
        rotateLayer(currentLayer: minuteLayer,dur: 3600)
        
        // Seconds
        let secondLayer = CAShapeLayer()
        secondLayer.frame = newView.frame
        
        let secondPath = CGMutablePath()
        secondPath.move(to: CGPoint(x:newView.frame.midX, y:newView.frame.midY))
        secondPath.addLine(to: CGPoint(x:time.s.x, y: time.s.y))
        
        
        secondLayer.path = secondPath
        secondLayer.lineWidth = 1
        secondLayer.lineCap = kCALineCapRound
        secondLayer.strokeColor = UIColor.red.cgColor
        
        secondLayer.rasterizationScale = UIScreen.main.scale;
        
        secondLayer.shouldRasterize = true
        self.view.layer.addSublayer(secondLayer)
        rotateLayer(currentLayer: secondLayer,dur: 60)
        let centerPiece = CAShapeLayer()
        
        let circle = UIBezierPath(arcCenter: CGPoint(x:newView.frame.midX,y:newView.frame.midX), radius: 4.5, startAngle: 0, endAngle: endAngle, clockwise: true)
        // thanks to http://stackoverflow.com/a/19395006/1694526 for how to fill the color
        centerPiece.path = circle.cgPath
        centerPiece.fillColor = UIColor.white.cgColor
        self.view.layer.addSublayer(centerPiece)
        
        
    }
    
    
    
}


// MARK: Retrieve time
func ctime ()->(h:Int,m:Int,s:Int) {
    
    var t = time_t()
    time(&t)
    let x = localtime(&t) // returns UnsafeMutablePointer
    
    return (h:Int(x!.pointee.tm_hour),m:Int(x!.pointee.tm_min),s:Int(x!.pointee.tm_sec))
}
// END: Retrieve time

// MARK: Calculate coordinates of time
func  timeCoords(x:CGFloat,y:CGFloat,time:(h:Int,m:Int,s:Int),radius:CGFloat,adjustment:CGFloat=90)->(h:CGPoint, m:CGPoint,s:CGPoint) {
    let cx = x // x origin
    let cy = y // y origin
    var r  = radius // radius of circle
    var points = [CGPoint]()
    var angle = degree2radian(6)
    func newPoint (t:Int) {
        let xpo = cx - r * cos(angle * CGFloat(t)+degree2radian(adjustment))
        let ypo = cy - r * sin(angle * CGFloat(t)+degree2radian(adjustment))
        points.append(CGPoint(x: xpo, y: ypo))
    }
    // work out hours first
    var hours = time.h
    if hours > 12 {
        hours = hours-12
    }
    let hoursInSeconds = time.h*3600 + time.m*60 + time.s
    newPoint(t: hoursInSeconds*5/3600)
    
    // work out minutes second
    r = radius * 1.25
    let minutesInSeconds = time.m*60 + time.s
    newPoint(t: minutesInSeconds/60)
    
    // work out seconds last
    r = radius * 1.5
    newPoint(t: time.s)
    
    return (h:points[0],m:points[1],s:points[2])
}
// END: Calculate coordinates of hour

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 -= 1
    }
    return points
}

func secondMarkers(ctx:CGContext, x:CGFloat, y:CGFloat, radius:CGFloat, sides:Int, color:UIColor) {
    // retrieve points
    let points = circleCircumferencePoints(sides:sides,x:x,y:y,radius:radius)
    // create path
    let path = CGMutablePath()
    // determine length of marker as a fraction of the total radius
    var divider:CGFloat = 1/16
    for p in points.enumerated() {
        if p.offset % 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
        path.move(to: CGPoint(x: p.element.x, y: p.element.y))
        path.addLine(to: CGPoint(x: xn, y: yn))
        path.closeSubpath()
        path.closeSubpath()
        // add path to context
        ctx.addPath(path)
    }
    // set path color
    let cgcolor = color.cgColor
    ctx.setStrokeColor(cgcolor)
    ctx.setLineWidth(3.0)
    ctx.strokePath()
    
}
func drawText(rect:CGRect, ctx:CGContext, x:CGFloat, y:CGFloat, radius:CGFloat, sides:NumberOfNumerals, color:UIColor) {
    
    // Flip text co-ordinate space, see: http://blog.spacemanlabs.com/2011/08/quick-tip-drawing-core-text-right-side-up/
    ctx.translateBy(x: 0.0, y: rect.height)
    ctx.scaleBy(x: 1.0, y: -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: sides.rawValue,x: x,y: y,radius: radius-inset,adjustment:270)
    // multiplier enables correcting numbering when fewer than 12 numbers are featured, e.g. 4 sides will display 12, 3, 6, 9
    let multiplier = 12/sides.rawValue
    
    for p in points.enumerated() {
        if p.offset > 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: "DamascusBold", size: radius/5)
            // create a dictionary of attributes to be applied to the string
            let attr:CFDictionary = [NSFontAttributeName:aFont!,NSForegroundColorAttributeName:UIColor.white] as CFDictionary
            // create the attributed string
            let str = String(p.offset*multiplier)
            let text = CFAttributedStringCreate(nil, str as CFString!, 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
            ctx.setLineWidth(1.5)
            // set the drawing mode to stroke
            ctx.setTextDrawingMode(.stroke)
            // 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
            ctx.textPosition = CGPoint(x: xn, y: 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)
        }
    }
    
}

enum NumberOfNumerals:Int {
    case two = 2, four = 4, twelve = 12
}

class View: UIView {
    
    
    override func draw(_ rect:CGRect)
        
    {
        
        // obtain context
        let ctx = UIGraphicsGetCurrentContext()
        
        // decide on radius
        let rad = rect.width/3.5
        
        let endAngle = CGFloat(2*M_PI)
        
        // add the circle to the context
        ctx?.addArc(center: CGPoint(x:rect.midX, y:rect.midY), radius: rad, startAngle: 0, endAngle: endAngle, clockwise: true)
        
        
        // set fill color
        ctx?.setFillColor(UIColor.gray.cgColor)
        
        // set stroke color
        ctx?.setStrokeColor(UIColor.white.cgColor)
        
        // set line width
        ctx?.setLineWidth(4.0)

        // use to fill and stroke path (see http://stackoverflow.com/questions/13526046/cant-stroke-path-after-filling-it )
        
        // draw the path
        ctx?.drawPath(using: .fillStroke)
        
        
        secondMarkers(ctx: ctx!, x: rect.midX, y: rect.midY, radius: rad, sides: 60, color: UIColor.white)
        
        drawText(rect:rect, ctx: ctx!, x: rect.midX, y: rect.midY, radius: rad, sides: .twelve, color: UIColor.white)
        
        
        
        
    }
}

PlaygroundPage.current.liveView = ViewController().view

Note: size of hands needs fixing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment