Swift: How to draw a clock face using CoreGraphics and CoreText (Part 2: Animating with CABasicAnimation)
Retrieving the time
The first step in the process of creating a working clock is to retrieve the hours, seconds and minutes. This information will be our data and can be created in at least two ways. The first is through the use of NSDate and NSDateFormatter:func time ()->(h:Int,m:Int,s:Int) { // see here for details about date formatter https://developer.apple.com/library/ios/documentation/Cocoa/Conceptual/DataFormatting/Articles/dfDateFormatting10_4.html#//apple_ref/doc/uid/TP40002369 let dateFormatter = NSDateFormatter() let formatStrings = ["hh","mm","ss"] var hms = [Int]() for f in formatStrings { dateFormatter.dateFormat = f let date = NSDate() if let formattedDateString = dateFormatter.stringFromDate(date).toInt() { hms.append(formattedDateString) } } return (h:hms[0],m:hms[1],s:hms[2]) }The second is using C, which in this instance is less laborious:
func ctime ()->(h:Int,m:Int,s:Int) { var t = time_t() time(&t) let x = localtime(&t) // returns UnsafeMutablePointerEither way is fine and you might well find ways to refine the Cocoa approach to abbreviate it.return (h:Int(x.memory.tm_hour),m:Int(x.memory.tm_min),s:Int(x.memory.tm_sec)) }
Drawing time
To begin, the time data must be converted into a visual representation. This means we need to calculate the coordinates at which the hands are to be drawn from and to. The from is simple, it is the centre of the clock, which is also the centre of the UIView subclass. To calculate the "to position", I convert hours and minutes to a seconds representation and find the position along the circumference of a circle divided into 60 positions evenly spaced.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]) }
The drawing can then be done using three CAShapeLayers added to the UIView subclass containing our clock face from last time. The advantage of a CAShapeLayer is that a path can be drawn directly to the layer allowing the design of the hands to be flexible should we wish to be adventurous with their design in future updates.
For convenience each CAShapeLayer has a frame the same size as the view in which the clock is contained and the hands are drawn at the correct positions starting at the centre of the layer (which is also the centre of the clock). Here is one example:
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)
Animation time
As you see above, the layer has a frame and a path. When it comes to animating, the frame will rotate around the CAShapeLayer's anchor point (default is 0.5, 0.5) and the drawn path will move with it like so:
I'm keeping the animation as simple as possible, so CABasicAnimation will be all we need to achieve this:
I'm keeping the animation as simple as possible, so CABasicAnimation will be all we need to achieve this:
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") }After the creation of each CAShapeLayer the rotateLayer method is triggered like this:
rotateLayer(hourLayer,dur:43200)where the duration (dur) value is the time that hand takes to complete 360 degrees.
Conclusion
There are many areas where the code (available here as a Gist) could be refined to give greater accuracy and enhanced graphically, there's also much more that could be explained if time allowed but hopefully this makes a contribution of some sort to understanding CABasicAnimation and CAShapeLayer in relation to Swift when working with circular interfaces.Further reading
Animations explained (objc.io)
thank you
ReplyDelete