There’s a legacy from WatchOS1 which is not only frustrating but deceptive. While one would think that a watch would have easy to use built in timers, that is far from the case. WatchOS2 changed this situation slightly, but still makes timekeeping not as easy as one would think. There is a timer in WatchKit, but it is mere window dressing. Core to understanding timers is the same as understand them for iOS: NSTimer
. In this lesson, we’ll make an app to explore all the varieties of timers and when each is useful.
Set Up the Project
Make a new WatchOS project called WatchHIITDemo. Use the iOS App with WatchKit App template and set the language to Swift. Check off notifications and glances. Go to the watch app interface.storyboard. Drag a button to the storyboard. Set its vertical alignment to Bottom. Change the title to Start/Stop. Set a background color of yellow(#FFFF00) and a dark text color for the text color. Drag a label to the storyboard. Set its title to Stopped. Change the font to System at 22 point. Set the Vertical Alignment and Horizontal Alignment to Center.
Add a Watch Timer
Watchkit has a built in timer of class WKInterfacetimer
. You’ll find it in the object library looking like this.
Drag a timer to the storyboard. Set the horizontal and vertical alignment to center. Set the font to System 22 point to match the label. Drag the timer under the Stopped label.
Select the timer to see its attributes in the inspector.
Near the top you will find a check box for Enabled. Make sure this is off. When checked, it starts the timer. We’ll use the button to start and stop the timer, leave it unchecked. Surrounding the enabled button is several selections for the format. Timers use NSDate
to store a date. We can use anything that an NSDate
can display as our timer. Play around and select a few different formats. You’ll notice a box Preview Secs which lets you select a time in seconds to show in the timer.
Change the attribute to have hours, minutes and seconds only in positional format as illustrated above. We must set this here since WatchKit will not allow us to set format programmatically.
Add Groups and Another Timer
Scroll through the object library and find Group. Drag the group to the storyboard. In the attribute inspector, You’ll find an attribute Layout.
Click this and you’ll see two options, Horizontal and Vertical. This is the core of the group object. Basic group objects don’t do much on their own. They act as layout containers for other objects, much like stack views in iOS. If we want two objects next to each other, we can make them a horizontal layout. Set the layout to Horizontal.
Drag a label and a timer into the group. Drag the label to the left of the group. They line up on the same row.Set te label text to Elapsed. Change the horizontal alignment on the new timer to Right Our storyboard looks like this:
The center elements are a little too close to the button. We can use a group to position them better using group’s Insets attribute. Drag another group to the storyboard. Set the vertical and horizontal alignment to Center. Change the Layout to Vertical. Drag the Stopped label and the larger timer to the group. Select the group. In the attributes inspector, change insets to Custom. You get new fields to add space in points to the group.
Set the bottom to to 15 points. The group moves up 15 points.
Connect the Outlets and Actions
Open the assistant editor with the watch view controller selected on the storyboard. The watch extension opens in the assistant editor. If you wish, you can close the attributes inspector to give yourself some space.
Control drag from the Elapsed label and make a label outlet named elapsedLabel. Control drag from the Stopped label and make a label outlet named statusLabel. Control drag from the top timer and make a timer outlet named workoutTimer. Control drag from the center timer and make a timer outlet named intervalTimer. You should have the following outlets in your code.
@IBOutlet var elapsedLabel: WKInterfaceLabel! @IBOutlet var statusLabel: WKInterfaceLabel! @IBOutlet var workoutTimer: WKInterfaceTimer! @IBOutlet var intervalTimer: WKInterfaceTimer!
Button Outlets and Actions in WatchOS
There’s a difference between iOS and WatchOS in naming objects with both actions and outlets. Objects in WatchKit have no sender
parameter in their actions. This presents a problem. Since we have no parameters for the action, If we name an outlet and an action for the same object with the same name, the compiler will give an error that we used the same identifier twice. To work around this, I add Action
to the end of the action. Control drag from the button to the code. Make an outlet named startStopButton.
@IBOutlet var startStopButton: WKInterfaceButton!
Control drag again from the button to the code. This time select action in the popup. Name the action startStopAction. You get an action.
@IBAction func startStopAction() { }
You can name the outlet and action whatever you like, but don’t name them the same as in iOS.
Using the WKInterfaceTimer
Under the action, add the following code:
//basic wkTimer func wkTimerReset(timer:WKInterfaceTimer,interval:NSTimeInterval){ timer.stop() let time = NSDate(timeIntervalSinceNow: interval) timer.setDate(time) timer.start() }
We’re making a function so we can reset and start both timers. The WKINterfaceTimer
class has three methods: stop
,start
and setDate
which I demonstrate in this method. This class is really a souped-up label with a timer in the background. The timer constantly runs. The start
and stop
methods start and stop updating to the label. The setDate
method sets the timer. If the value of the timer’s parameter is positive, it becomes a countdown timer. If the value is negative, it counts up like a stopwatch.
The parameter’s value for setTime
is type NSDate
. We’ve used the NSDate
initializer timeIntervalSinceNow
to get a date interval
seconds in the future, then set the date in the timer. Once set, we start up the timer.
You’ll notice we really didn’t need to start and stop the timer since they are only cosmetic. The only real work is done by setDate
. I did that here to demonstrate the functions.
Change the startStopAction like this:
func startStopAction(){ isStarted = !isStarted //toggle the button if isStarted{ startStopButton.setTitle("Stop") wkTimerReset(workoutTimer,interval: 0.0) //counts up if zero or less wkTimerReset(intervalTimer,interval:15.0) // counts down if greater than zero } else { startStopButton.setTitle("Start") workoutTimer.stop() intervalTimer.stop() } }
Above the outlets, make a property for our button toggle
var isStarted = false
Launch the phone and watch simulators. Let them load completely. If you have no easy way to launch the simulator, check the directions here so you have an easy link to the simulator. This will prevent timeout problems when loading and running the simulator. When the watch face shows on the simulator, set the simulator to run the watch app.
Build and run.
After the app loads, press start and your watch counts down 15 seconds, then stops. Meanwhile the elapsed timer continues to count up.
You can stop it by pressing the stop button.
Using a NSTimer
We’ve found the one big problem with the WatchKit timer: it does nothing but count. It’s a automatic label. To get events on a timer we need to use the NSTimer
object. These objects create their own timing loop. They can be used in two ways: as a one shot timer and as a repeating timer. We set a time in seconds for the loop to complete, and give the loop a function to run when it completes the loop. Using our current WKInterfaceTimer
setup, we can fire a timer when the run is complete.
Add the following function to the code:
func loopTimer1(interval:NSTimeInterval){ //NSTimer to end at event. if timer.valid { timer.invalidate() } timer = NSTimer.scheduledTimerWithTimeInterval(interval, target: self, selector: "loopTimer1DidEnd:", userInfo: nil, repeats: false) wkTimerReset(intervalTimer, interval: interval) wkTimerReset(workoutTimer, interval: 0) }
The last two lines are familiar. Those reset the watch timers. The if
clause checks if the timer is running, which we get from the valid
property. If we are running a timer loop on timer
, shut it down. If not, we start the timer with the class method scheduledTimerWithTimeInterval
. Its first parameter is a NSTimeInterval
telling the timer how many seconds to run before running the ending function. The next two parameters target
and selector
give the location of that method. The function self.loopTimer1didEnd
runs when the timer is done. Note the colon at the end of the selector. This means there is a parameter on this function, which is the timer. Therefore we’ll need function loopTimer1DidEnd(timer:NSTimer) in our code. The next parameter userInfo
is of type AnyObject?
. This allows us to pass data to our selector as we’ll see shortly. Finally we have a Bool
parameter indicating if we stop or repeat the timer after timing to interval. In this iteration we have a timer that ends when the watch’s intervalTimer
ends.
We need that terminating function from the selector. Add the following code under loopTimer1
:
func loopTimer1DidEnd(timer:NSTimer){ statusLabel.setText("Stopped") startStopButton.setTitle("Start") isStarted = false intervalTimer.stop() workoutTimer.stop() timer.invalidate() }
This does what we expect. it shuts down everything but the elapsed timer. That isn’t new, but now we can control events and change our button and label to reflect the end of the interval. If you have a single event, such as a simple countdown timer, this is a good strategy.
Comment out the code in startStopAction
and replace it with this:
//NSTimer for event isStarted = !isStarted //toggle the button if isStarted{ statusLabel.setText("Run") startStopButton.setTitle("Stop") loopTimer1(myInterval) } else { startStopButton.setTitle("Start") workoutTimer.stop() intervalTimer.stop() timer.invalidate() //stop timer }
We’ve set up this button action to toggle on and off the timer. We’ll call the function loopTimer1
to start and use NSTimer
‘s invalidate
method to stop the timer.
Build and run.
Repeating Timers for Updating
While nice, what if I want the countdown timer to reset itself and start the countdown again? For that we need a repeating timer.
Add another method to the class:
func loopTimer2(interval:NSTimeInterval){ //NSTimer to end at event. if timer.valid { timer.invalidate() } timer = NSTimer.scheduledTimerWithTimeInterval(interval, target: self, selector: "loopTimer2DidEnd:", userInfo: interval, //pass data to selector repeats: true) //repeat the interval wkTimerReset(intervalTimer, interval: interval) wkTimerReset(workoutTimer, interval: 0) }
This code is not much different than the last timer. The first difference is setting repeats
to true
. At the end of each loop, we run the termination selector then start again. The second difference is assigning a value to userInfo
. Termination functions have a parameter of the timer. The parameter userInfo
in this initializer sets the NSTimer
property userInfo
. We can pass data from the loopTimer2
function to the terminating function loopTimer2DidEnd
through userInfo
.
Add this code for the terminating function.
func loopTimer2DidEnd(timer:NSTimer){ isRunning = !isRunning wkTimerReset(intervalTimer, interval: timer.userInfo as! NSTimeInterval) //using userinfo data if isRunning { statusLabel.setText("Run") }else{ statusLabel.setText("Walk") } }
In the third line we use timer.userInfo as! NSTimeInterval
to pass the value of the timer interval to the function. The property userInfo
is of type AnyType?
so we can pass anything through and then downcast it in the terminating function. For more than one data value, you can pass a dictionary of [String:AnyType]
or a class through.
We’ve changed the whole structure of the termination function. It no longer does stuff to end the loop. Instead it runs code to change the loop when it reaches the specified time interval. Like our previous example, we count on the timer the length of a workout interval, then run this code to reset the clock. In many applications this is a an efficient way of updating: run for a specified interval, check the current status outside of the clock and update the clock accordingly. Since WatchOS1 uses power and time to update the WKInterfaceTimers
fro a NSTimer
running n the phone, this is a very popular way of updating timer objects on a watch. In WatchOS2, it’s not as much of an issue with the extension running on the watch.
We also toggle between running and walking. To do this we use a common element of these types of loops: a flag. Flags are often a property of the class, allowing us to have a status for the clock outside of the timer loop. Here they are telling us if the loop is walking or running. Add the property isRunnig
to the top of the class.
var isRunning = true
In startStopAction
, comment out loopTimer1
, then add our new timer
//loopTimer1(myInterval) loopTimer2(myInterval)
We’ll need to change the status label as we get different activites. Comment out statusLabel.setText("Run"){
in startStopAction
. Add this underneath it:
if isRunning {
statusLabel.setText(“Run”)
}else{
statusLabel.setText(“Walk”)
}
Build and Run. The clock counts down, then changes from run to walk and back again. For many events where you want to update your timer on a periodic basis, this is a good low power strategy. A transit app like My Bus Times Can’t talk to the bus database every second without draining power quickly. Instead it uses this strategy and checks on the bus arrival every twenty seconds and updates the arrival time.
Repeating timers without WKInterfaceTimer
The last type of timer we’ll look at is the same as the iOS equivalent. Instead of using a WKInterfaceTimer
, we use labels. We’ll set a timer interval for timer
smaller than the time measurement. Each time interval we update the labels and check for any events in the termination function, which is better described as an update loop. Add the following function to the class:
func loopTimer3(interval:NSTimeInterval, timerInterval:NSTimeInterval){ //NSTimer to update at event if timer.valid { timer.invalidate() } timer = NSTimer.scheduledTimerWithTimeInterval(timerInterval, //set from parameter to a short time. target: self, selector: "loopTimer3DidEnd:", userInfo: interval, //pass data to selector repeats: true) wkTimerReset(intervalTimer, interval: interval) wkTimerReset(workoutTimer, interval: 0) }
The only change between this code and the last version is including the timerInterval
parameter, then using timerInterval
as the time of our updates. Usually it’s a constant, but for you to play around with the value, I’ve made it a parameter. Depending on the application I use around 10% of the smallest unit displayed. This app goes to 1 second so I’ll be rough in my measurements and use a quarter of a second for updates which we’ll see later. I’m not using 0.1 seconds because less updates requires less computing power. You’ll need to find the right balance between processing, accuracy and smooth updating.
Our update code is a lot more complex. Add the following code:
func loopTimer3DidEnd(timer:NSTimer){ //infinite loop with events in the selector //uses labels instead of witch kit timers workoutTime += timer.timeInterval //increment count up timers intervalTime -= timer.timeInterval //decrement count down timers if intervalTime <= 0 { //the workout interval is over //switch activities intervalTime = timer.userInfo as! NSTimeInterval isRunning = !isRunning wkTimerReset(intervalTimer, interval: timer.userInfo as! NSTimeInterval) if isRunning { statusString = "Run " }else{ statusString = "Walk " } } statusLabel.setText(statusString + formatTimeInterval(intervalTime)) elapsedLabel.setText(formatTimeInterval(workoutTime)) }
WE are no longer using this as a termination function. it runs continuously and need to keep track of our timers on its own. I’m using two variables workoutTime
and intervalTime
to do this. To run our elapsed time counter, I add the length of time between updates which I have in the timeInterval
property of NSTimer
. To count down, I subtract. If my countdown timer reaches 0 or less, our workout interval is over and we do much of the same as we did in loopTimer2DidEnd
. For demonstration purposes I left a wkTimerReset
for the run/walk timer, but I also did something else. I set a string instead of the label to switch between running and walking. After the if
clause is the reason why. I’m changing my label to hold the time, and appending the status in front of it. We’ll need a string conversion function, which we’ll get to shortly.
To get rid of many of our errors, We’ll need to add the properties for the counters and string as properties:
var statusString = "Stopped" var workoutTime:NSTimeInterval = 0.0 var intervalTime:NSTimeInterval = 0.0
We also have to add the formatting function. Sadly there is no factory formatter for NSTimeInterval
as there is for NSDate
. Fortunately the function is not difficult for HH:MM:SS presentation of seconds. Add this function:
//format seconds into strings of hh:mm:ss func formatTimeInterval(timeInterval:NSTimeInterval) -> String { let secondsInHour = 3600 let secondsInMinute = 60 var time = Int(timeInterval) let hours = time / secondsInHour time = time % secondsInHour let minutes = time / secondsInMinute let seconds = time % secondsInMinute return String(format:"%02i:%02i:%02i",hours,minutes,seconds) }
We do a series of divisions and remainders to get the time in components. Change the button action to run this timer with a 0.25 second update:
//loopTimer1(myInterval) //loopTimer2(myInterval) loopTimer3(myInterval, timerInterval: 0.25)
Also add the updated statusString here:
if isRunning { statusLabel.setText("Run") statusString = "Run " }else{ statusLabel.setText("Walk") statusString = "Walk " } Our last step is to initialize the countdown timer from our constant. Change <code>awakefromContext</code> to this: override func awakeWithContext(context: AnyObject?) { super.awakeWithContext(context) intervalTime = myInterval }
Build and Run. Start the watch and see what happens.
Stop the watch and notice the timers. In all the cases, they don’t time well together. Both the resolution of your timing loop and the starting of different timing loops at different times lead these to not agree with each other. I did this for comparison purposes, but you can see its a bad idea to have too may timers running at the same time. I could probably have my two labels more in sync if I used a 0.1 second interval.
If you need constant updating of a lot of your app, this is the way to do it. If you have less needs of updates, you’ll get better efficiency from one of the other ways.
The final case made the timer not stop and reset like the other cases but pause. What if you wanted to stop here? IN the next installment, we’ll discuss adding menus and navigation to an app to let you do just that.
The Whole Code
// // InterfaceController.swift // WatchHIITDemo WatchKit Extension // // Created by Steven Lipton on 2/18/16. // Copyright © 2016 MakeAppPie.Com. All rights reserved. // import WatchKit import Foundation class InterfaceController: WKInterfaceController { //MARK: Outlets and Properties let myInterval:NSTimeInterval = 15.0 //Timer Properties //While I didnt in the tutorial for simplicity, note that I made these private. //Generally this is a good practice. Nothing but the class should mess //with your timer's workings. private var isStarted = false private var isRunning = true private var timer = NSTimer() private var statusString = "Stopped" private var workoutTime:NSTimeInterval = 0.0 private var intervalTime:NSTimeInterval = 0.0 //Outlets @IBOutlet var elapsedLabel: WKInterfaceLabel! @IBOutlet var statusLabel: WKInterfaceLabel! @IBOutlet var workoutTimer: WKInterfaceTimer! @IBOutlet var intervalTimer: WKInterfaceTimer! @IBOutlet var startStopButton: WKInterfaceButton! //MARK: - Actions @IBAction func startStopAction() { /* //iteration 1 -- straight WKInterfaceTimer isStarted = !isStarted //toggle the button if isStarted{ startStopButton.setTitle("Stop") statusLabel.setText("Run") wkTimerReset(workoutTimer,interval: 0.0) //counts up if zero or less wkTimerReset(intervalTimer,interval:myInterval) // counts down if greater than zero } else { startStopButton.setTitle("Start") statusLabel.setText("Stopped") workoutTimer.stop() intervalTimer.stop() } */ //NSTimer for event isStarted = !isStarted //toggle the button if isStarted{ // Set the status label // statusLabel.setText("Run") if isRunning { statusLabel.setText("Run") statusString = "Run " }else{ statusLabel.setText("Walk") statusString = "Walk " } startStopButton.setTitle("Stop") //loopTimer1(myInterval) //Stop the counter at end of timer interval //loopTimer2(myInterval) //Repeat the counter at end of timer interval loopTimer3(myInterval, timerInterval: 0.25) //Use small intervals and stop } else { startStopButton.setTitle("Start") workoutTimer.stop() intervalTimer.stop() timer.invalidate() //stop timer } } func wkTimerReset(timer:WKInterfaceTimer,interval:NSTimeInterval){ timer.stop() //stop display of WKInterfaceTimer let time = NSDate(timeIntervalSinceNow: interval) timer.setDate(time) timer.start() //srat display of WKInterfaceTimer } func loopTimer1(interval:NSTimeInterval){ //NSTimer to end at event. if timer.valid { //kill a running timer before starting a time timer.invalidate() } timer = NSTimer.scheduledTimerWithTimeInterval(interval, target: self,//target and selector give location of code selector: "loopTimer1DidEnd:", //to execute when timer done userInfo: nil, repeats: false) wkTimerReset(intervalTimer, interval: interval) wkTimerReset(workoutTimer, interval: 0) } func loopTimer1DidEnd(timer:NSTimer){ statusLabel.setText("Stopped") startStopButton.setTitle("Start") isStarted = false intervalTimer.stop() workoutTimer.stop() timer.invalidate() } func loopTimer2(interval:NSTimeInterval){ //NSTimer to end at event. if timer.valid { timer.invalidate() } timer = NSTimer.scheduledTimerWithTimeInterval(interval, target: self, selector: "loopTimer2DidEnd:", userInfo: interval, //pass data to selector repeats: true) //repeat the interval wkTimerReset(intervalTimer, interval: interval) wkTimerReset(workoutTimer, interval: 0) } func loopTimer2DidEnd(timer:NSTimer){ isRunning = !isRunning wkTimerReset(intervalTimer, interval: timer.userInfo as! NSTimeInterval) if isRunning { statusLabel.setText("Run") }else{ statusLabel.setText("Walk") } } func loopTimer3(interval:NSTimeInterval, timerInterval:NSTimeInterval){ //NSTimer to update at event if timer.valid { timer.invalidate() } timer = NSTimer.scheduledTimerWithTimeInterval(timerInterval, //set from parameter to a short time. target: self, selector: "loopTimer3DidEnd:", userInfo: interval, //pass data to selector repeats: true) wkTimerReset(intervalTimer, interval: interval) wkTimerReset(workoutTimer, interval: 0) } func loopTimer3DidEnd(timer:NSTimer){ //infinite loop with events in the selector //uses labels instead of workoutTime += timer.timeInterval intervalTime -= timer.timeInterval if intervalTime <= 0 { //the workout interval is over //switch activities intervalTime = timer.userInfo as! NSTimeInterval isRunning = !isRunning wkTimerReset(intervalTimer, interval: timer.userInfo as! NSTimeInterval) if isRunning { statusString = "Run " }else{ statusString = "Walk " } } statusLabel.setText(statusString + formatTimeInterval(intervalTime)) elapsedLabel.setText(formatTimeInterval(workoutTime)) } //format seconds into string of hh:mm:ss func formatTimeInterval(timeInterval:NSTimeInterval) -> String { let secondsInHour = 3600 let secondsInMinute = 60 var time = Int(timeInterval) let hours = time / secondsInHour time = time % secondsInHour let minutes = time / secondsInMinute let seconds = time % secondsInMinute return String(format:"%02i:%02i:%02i",hours,minutes,seconds) } override func awakeWithContext(context: AnyObject?) { super.awakeWithContext(context) intervalTime = myInterval } override func willActivate() { // This method is called when watch view controller is about to be visible to user super.willActivate() } override func didDeactivate() { // This method is called when watch view controller is no longer visible super.didDeactivate() } }
Leave a Reply