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 UnsafeMutablePointer
    
    return (h:Int(x.memory.tm_hour),m:Int(x.memory.tm_min),s:Int(x.memory.tm_sec))
}
Either way is fine and you might well find ways to refine the Cocoa approach to abbreviate it.

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:
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)

Endorse on Coderwall

Comments

Post a Comment