Last active
May 23, 2020 02:00
-
-
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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()) | |
} | |
} |
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
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.