Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Format realtime stopwatch timer to the hundredth using Swift

I have an app using an NSTimer at centisecond (0.01 second) update intervals to display a running stopwatch in String Format as 00:00.00 (mm:ss.SS). (Basically cloning the iOS built-in stopwatch to integrate into realtime sports timing math problems, possibly needing millisecond accuracy in the future)

I use (misuse?) the NSTimer to force-update the UILabel. If the user presses Start, this is the NSTimer code used to start repeating the function:

displayOnlyTimer = NSTimer.scheduledTimerWithTimeInterval(0.01, target: self, selector: Selector("display"), userInfo: nil, repeats: true)

And here is the function that is executed by the above NSTimer:

func display() {
    let currentTime = CACurrentMediaTime() - timerStarted + elapsedTime

    if currentTime < 60 {
        timeDisplay.text = String(format: "%.2f", currentTime)
    }else if currentTime < 3600 {
        var minutes = String(format: "%00d", Int(currentTime/60))
        var seconds = String(format: "%05.2f", currentTime % 60)
        timeDisplay.text =  minutes + ":" + seconds
    }else {
        var hours = String(format: "%00d", Int(currentTime/3600))
        var minutes = String(format: "%02d", (Int(currentTime/60)-(Int(currentTime/3600)*60)))
        var seconds = String(format: "%05.2f", currentTime % 60)
        timeDisplay.text =  hours + ":" + minutes + ":" + seconds
    }
}

There will be at least 2 display links running at the same time. Will this method be too inefficient once all other elements are in play?

The display is then updated without using NSTimer when the user presses stop/pause/reset. I didn't find anything that directly translated into Swift. I'm fairly certain I'm using an inefficient method to force update the text UILabel quickly in the UIView.

More Details: I'm working on less messy code for the running timer format (mm:ss.SS). I will update this once more when I've finished that.

UPDATE: Thanks to Rob and jtbandes for answering both of my questions (formatting method and display update method). It was easy to replace the NSTimer (see above) with CADisplayLink():

displayLink = CADisplayLink(target: self, selector: Selector("display"))
        displayLink.addToRunLoop(NSRunLoop.currentRunLoop(), forMode: NSRunLoopCommonModes)

And then replace all instances in code of

displayOnlyTimer.invalidate() 

with

displayLink.paused = true 

(this will pause the display link from updating)

like image 547
mothy Avatar asked Jul 13 '15 04:07

mothy


2 Answers

For rapid UI updates you should use a CADisplayLink. Anything faster than the display refresh rate is a waste of processing power since it physically cannot be displayed. It also provides a timestamp of the previous frame so you can try to predict when the next frame will be.

You're calculating CACurrentMediaTime() - timerStarted + elapsedTime multiple times. I would recommend doing it only once and saving it in a local variable.

Consider using NSDateComponentsFormatter. Try to reuse one instance of the formatter rather than creating a new one each time (which is usually the most expensive part). Overall, the less string manipulation you can do, the better.

You can check CACurrentMediaTime at the beginning and end of your display method to see how long it takes. Ideally it should be much less than 16.6ms. Keep an eye on the CPU usage (and general power consumption) in the Xcode debug navigator.

like image 159
jtbandes Avatar answered Nov 04 '22 09:11

jtbandes


I was solving the same problem today and found this answer. The Rob's and jtbandes' advices are helped a lot and i was able to assemble the clean and working solution from around the internet. Thanks you guys. And thanks to mothy for the question.

I've decided to use CADisplayLink because there is no point in firing timer's callback more often than the screen updates:

class Stopwatch: NSObject {
    private var displayLink: CADisplayLink!
    //...

    override init() {
        super.init()

        self.displayLink = CADisplayLink(target: self, selector: "tick:")
        displayLink.paused = true
        displayLink.addToRunLoop(NSRunLoop.mainRunLoop(), forMode: NSRunLoopCommonModes)
        //...
    }
    //...
}

I'm tracking time by incrementing the elapsedTime variable by displayLink.duration each tick:

var elapsedTime: CFTimeInterval!

override init() {
    //...
    self.elapsedTime = 0.0
    //...
}

func tick(sender: CADisplayLink) {
    elapsedTime = elapsedTime + displayLink.duration
    //...
}

Time-formatting is done through NSDateFormatter:

private let formatter = NSDateFormatter()

override init() {
//...
        formatter.dateFormat = "mm:ss,SS"
}

func elapsedTimeAsString() -> String {
        return formatter.stringFromDate(NSDate(timeIntervalSinceReferenceDate: elapsedTime))
}

The UI can be updated in the callback closure which Stopwatch calls on every tick:

var callback: (() -> Void)?

func tick(sender: CADisplayLink) {
    elapsedTime = elapsedTime + displayLink.duration

    // Calling the callback function if available
    callback?()
}

And that's all you need to do in the ViewController to utilize the Stopwatch:

let stopwatch = Stopwatch()
stopwatch.callback = self.tick

func tick() {
   elapsedTimeLabel.text = stopwatch.elapsedTimeAsString()
}

Here is the gist with the full code of Stopwatch and usage guide: https://gist.github.com/Flar49/06b8c9894458a3ff1b14

I hope that this explanation and gist will help others who will stumble upon this thread in the future with the same problem :)

like image 37
Eugene Tartakovsky Avatar answered Nov 04 '22 10:11

Eugene Tartakovsky