Before you can actually go for a run in TapALap, you need a way to create a track. You’ll allow your users to specify a name for the track, as well as how long a lap is. Once the track is complete, you’ll need to send it back to the Go Running interface controller, and for that, you’ll create a protocol. First, let’s get the interface created for creating tracks.
The main screen you need to deal with is the track configuration screen. You’ll use it to name a track as well as configure how long a lap on it is. You already have a class, TrackConfigurationInterfaceController, as well as an interface controller in your storyboard, for this task, so let’s get started. To configure how long a track is, you’re going to use a new user interface to this app: WKInterfacePicker. Given that most tracks express their distance in terms of how many laps make one mile (or appropriate local unit of distance), you’ll ask the user to input how many laps add up to a distance that they also specify. This two-picker approach will allow for odd track configurations, like my local gym, in which seven laps adds up to one mile. For now we’ll assume miles, but in a later chapter you’ll learn more about making apps work for users in all countries.
Open Interface.storyboard in Xcode and find the empty track configuration interface controller. First, you’ll add a button to it that you’ll use for setting the track’s name. Since the track doesn’t yet have a name, you’ll set the title of the button to something instructive: “Tap to Set Name.” Next, add a picker, a label, and then another picker. Set the text of the label to “equals” and then add all three items to a horizontal group. You don’t want everything to be the same size, so set the width of the first picker to 0.25 relative to its container, the width of the label to 0.3, and the width of the second picker to 0.45. Set the height of the two pickers to 44 and the vertical alignment of the label to Center, and this group is done. For the pickers, there are some additional properties to set. Since you have two of them onscreen at once, you’ll need to know which one has focus—that is, which one will move when the user moves the Digital Crown. In Xcode’s Attributes Inspector, set the Indicator style of both to Shown While Focused. Then, set the Focus style of the first picker to Outline with Caption and the second to Outline. You’ll read more about the caption later.
Finally, you’ll need a button that saves the track. Add a button at the bottom with the title “Save.” When this is done, you should have an interface controller laid out like so:
With your user interface laid out, you can create outlets for your interface objects in the interface controller class. Open TrackConfigurationInterfaceController.swift in Xcode and add outlets and actions for everything you’ll need:
| @IBOutlet var nameButton: WKInterfaceButton! |
| @IBOutlet var lapsPicker: WKInterfacePicker! |
| @IBOutlet var distancePicker: WKInterfacePicker! |
| |
| @IBAction func nameButtonPressed() { |
| } |
| @IBAction func lapsPickerDidChange(selectedIndex i: Int) { |
| } |
| @IBAction func distancePickerDidChange(selectedIndex i: Int) { |
| } |
| @IBAction func saveButtonPressed() { |
| } |
Return to the storyboard and connect the outlets: nameButton to the top button, lapsPicker to the first picker, which you’ll use to set the number of laps, and distancePicker to the second picker, which you’ll use to set the total distance. Connect the name button to the nameButtonPressed method and the save button to the saveButtonPressed method. Next, you need to connect the pickers to their action methods. Whenever the user adjusts the picker, the method will be called with the index of the picker item the user has selected (more on picker items later). Because you’ve given the index the external parameter name selectedIndex, this will look a little different in Interface Builder. Since Interface Builder uses the Objective-C versions of the methods, it uses the Swift-to-Objective-C-translated name of the method, so connect the laps picker to the one called lapsPickerDidChangeWithSelectedIndex: and the distance picker to the one called distancePickerDidChangeWithSelectedIndex:. These names are automatically generated from your Swift code, even though you didn’t type “With” anywhere.
With your outlets connected, but before you implement anything, let’s think about when this screen is going to show up. When the user first starts TapALap, he won’t have any tracks configured, so he’ll tap the Selected Track button on the Go Running interface controller. Ideally, this button will take him to a screen where he can select from a list of saved tracks, but since he won’t have any, you can jump straight to adding a new one. Let’s set that up now and come back to this screen when you need it.
Open GoRunningInterfaceController.swift. You haven’t done much with this screen, so let’s add some additional functionality. First, you need a way to save the currently selected track, so you’ll add an optional variable for it:
| var selectedTrack: Track? |
If the user has yet to select a track, you’ll need to tell him that he needs to select a track, so you’ll modify the startRunButtonPressed method to ensure that one is selected. First, however, you need to modify the declaration of Track itself, changing it from a struct to a class. Open Track.swift and make the change (aside from changing “struct” to “class,” you’ll need to add an initializer, since only structs get initializers created for free):
| class Track { |
| let name: String |
| let lapDistance: Double // in meters |
| |
| init(name: String, lapDistance: Double) { |
| self.name = name |
| self.lapDistance = lapDistance |
| } |
| } |
Now that you’ve changed Track to a class, you can implement your startRunButtonPressed to ensure that the user has selected one:
| @IBAction func startRunButtonPressed() { |
| guard let track = selectedTrack else { |
| presentAlertControllerWithTitle("Error", |
| message: "You need to select a track to run on!", |
| preferredStyle: .Alert, |
| actions: [ |
| WKAlertAction(title: "OK", |
| style: .Default, |
| handler: {}) |
| ]) |
| |
| return |
| } |
| |
| let names = ["RunTimer"] |
| |
| WKInterfaceController.reloadRootControllersWithNames(names, |
| contexts: [track]) |
| } |
To do this, you use a simple guard statement. If the user has not selected a track, then you’ll use an alert controller to display an error message to him. The OK button of the alert controller is represented by a WKAlertAction instance, and you give it an empty closure for its handler since you don’t need to do anything when the user taps it—the alert controller is automatically dismissed. At the end of the method, instead of passing nil as the context to the Run Timer interface controller, you’ll pass the selected track. This is why you needed to make Track a class; since the type of the contexts array is [AnyObject], you can use only classes with it. You’ll see passing context objects again later in this chapter. Now, if the user tries to start a run without selecting a track, he’ll see this alert controller:
Next, you need to make the track name and lap distance labels display accurate data for the selected track. You haven’t made outlets for them yet, so do that now:
| @IBOutlet weak var trackNameLabel: WKInterfaceLabel! |
| @IBOutlet weak var trackDistanceLabel: WKInterfaceLabel! |
Open the storyboard and connect the labels to those outlets. Next, you’ll need to fill them in with data. Open GoRunningInterfaceController.swift again and add a updateTrackLabels method, as well as an NSLengthFormatter:
| lazy var distanceFormatter = NSLengthFormatter() |
| |
| func updateTrackLabels() { |
| if let track = selectedTrack { |
| trackNameLabel.setText(track.name) |
| |
| trackDistanceLabel.setText( |
| distanceFormatter.stringFromMeters(track.lapDistance)) |
| } |
| else { |
| trackNameLabel.setText("None") |
| trackDistanceLabel.setText(nil) |
| } |
| } |
When you call this method, it’ll update your labels, whether or not you have a track selected. You’ll call it whenever the interface controller is about to appear to ensure that you always have the correct data onscreen:
| override func willActivate() { |
| super.willActivate() |
| |
| updateTrackLabels() |
| } |
Now that you’ve added this method, if you build and run the app, you’ll see None under Selected Track, instead of the canned data you added in the storyboard:
This screen is looking good! When the user starts your app and tries to start a run, he’s prompted to create a track. When he taps to select a track, the track configuration screen appears, with the UI you created. Next, you need to tackle what happens when the user configures his track—you’ll need to come back to this screen with a track. To that end, let’s set up a new protocol for track selection. In Objective-C, you might be tempted to name a protocol something like TrackSelectionDelegate, but in Swift, the name should really involve what the type does. Name it TrackSelectionReceiver, to indicate that a type that conforms to it receives the track.
Open TrackConfigurationInterfaceController.swift and add the protocol declaration. You’ll need just one method, receiveTrack(_:):
| protocol TrackSelectionReceiver: class { |
| func receiveTrack(track: Track) |
| } |
You mark the protocol as class so that it applies only to classes—later, you’ll need to use some class-specific behavior in WatchKit. With this protocol, you’ll be able to send the track from the track configuration screen to the Go Running screen. Let’s set up the relationship between the screens now. Add a new property to TrackConfigurationInterfaceController called trackReceiver:
| weak var trackReceiver: TrackSelectionReceiver? |
To set this variable, you’ll implement awakeWithContext(_:) and try to convert the context parameter to a TrackSelectionReceiver:
| override func awakeWithContext(context: AnyObject?) { |
| super.awakeWithContext(context) |
| |
| if let receiver = context as? TrackSelectionReceiver { |
| self.trackReceiver = receiver |
| } |
| } |
Now, when you have a track, you’ll just call receiveTrack(_:) on the trackReceiver to return to the previous screen. Let’s implement that in GoRunningInterfaceController.swift by declaring that the class conforms to the TrackReceiver protocol and implementing the method:
| class GoRunningInterfaceController: WKInterfaceController, TrackSelectionReceiver { |
| |
| func receiveTrack(track: Track) { |
| selectedTrack = track |
| updateTrackLabels() |
| dismissController() |
| } |
| |
| // The rest of the class follows… |
This method will use the track you supplied, as well as dismiss the track configuration interface controller. Before it’s called, however, you’ll need to create an actual Track to send it.
To create a track, you need to know two things: its name and how long one lap is. You’ve created outlets for all of your UIs, so let’s write the code to get that data from your UI and save it as a Track. You’ll start with the track’s name. When the user taps the name button, you’ll use WatchKit’s text input controller to allow him to enter a name. Since there’s no keyboard on the watch, you’ll prepopulate the text input with some suggested names, as well as allow the user to dictate his response. Still in TrackConfigurationInterfaceController.swift, let’s implement nameButtonPressed to do this:
| var selectedName: String? |
| |
| @IBAction func nameButtonPressed() { |
| let suggestedTrackNames = ["Gym", "School", "Park", "Trail", "Neighborhood"] |
| |
| presentTextInputControllerWithSuggestions(suggestedTrackNames, |
| allowedInputMode: .Plain) { results in |
| guard let name = results?.first as? String else { return } |
| |
| self.nameButton.setTitle(name) |
| |
| self.selectedName = name |
| } |
| } |
Start with our suggested track names, which you pass into presentTextInputControllerWithSuggestions(_:allowedInputMode:). Setting the allowed input mode to .Plain prevents the user from selecting emoji for a track name. The completion handler you pass in has one parameter, which you’ve named results. It’s of type [AnyObject]?, so you need to make sure it contains a String you can use as a name. It could be nil if the user cancelled or contain an NSData object filled with image data if the user selected an emoji image. Assuming you have a name, set it as the title of your name button. Save it as selectedName, a property added here so you can reference the name later. Build and run, and you can see the text entry yourself. Tap the microphone to open the dictation window:
Once the user has selected or dictated a name, you return to the configuration screen. Next, you need to handle the track length selection. Before you can handle the user selecting picker items to choose the track length, you need to add those items to the pickers. You’ll do that in awakeWithContext(_:):
1: | override func awakeWithContext(context: AnyObject?) { |
- | super.awakeWithContext(context) |
- | |
- | if let receiver = context as? TrackSelectionReceiver { |
5: | self.trackReceiver = receiver |
- | } |
- | |
- | // Add 1-10 laps for laps picker |
- | let lapItems: [WKPickerItem] = (1 ... 10).map { i in |
10: | let pickerItem = WKPickerItem() |
- | pickerItem.title = "(i)" |
- | pickerItem.caption = (i == 1) ? "Lap" : "Laps" |
- | return pickerItem |
- | } |
15: | |
- | lapsPicker.setItems(lapItems) |
- | |
- | // Add 0.5 - 5 miles for total distance picker |
- | var distanceItems: [WKPickerItem] = [] |
20: | |
- | let distanceFormatter = NSLengthFormatter() |
- | distanceFormatter.numberFormatter.minimumFractionDigits = 1 |
- | distanceFormatter.numberFormatter.maximumFractionDigits = 1 |
- | |
25: | for i in 0.5.stride(to: 5.5, by:0.5) { |
- | let pickerItem = WKPickerItem() |
- | pickerItem.title = distanceFormatter.stringFromValue(i, |
- | unit: .Mile) |
- | |
30: | distanceItems.append(pickerItem) |
- | } |
- | |
- | distancePicker.setItems(distanceItems) |
- | |
35: | // Set values based on initial picker values. |
- | lapsPickerDidChange(selectedIndex: 0) |
- | distancePickerDidChange(selectedIndex: 0) |
- | } |
The pickers are initialized with WKPickerItem instances. You start on line 9 by creating an array of them with their title property set to the numbers 1 to 10. The title is displayed inside the picker as the user scrolls the Digital Crown. The caption is displayed in a green bubble outside the picker and describes the thing the user is selecting. You set it to “Lap” or “Laps” to inform the user that here he’s selecting the number of laps. With this array in place, you call setItems(_:) on the picker on line 16 to send the values to the picker for selection.
You handle the distance selection similarly, but you use the stride(to:by:) method on line 25 to generate values from 0.5 miles to 5.0 miles, with a value at every 0.5-mile mark. With an NSLengthFormatter, you set the title appropriately and then finally call setItems(_:) on your distance picker. You don’t set the caption variable here because the title includes the unit of measure, so it should be pretty clear to the user what he’s setting. Finally, you call your callback methods, lapsPickerDidChange(selectedIndex:) and distancePickerDidChange(selectedIndex:), with your initial values. Unlike pickers on iOS, pickers on watchOS start with the first item selected, so you call these callbacks to set your initial state.
Whenever the user adjusts the values in the pickers, you’ll receive the callbacks again with the index of the selected item. Let’s implement those now:
| var selectedLaps: Int? |
| var selectedDistance: Double? |
| |
| @IBAction func lapsPickerDidChange(selectedIndex i: Int) { |
| selectedLaps = i + 1 |
| } |
| |
| @IBAction func distancePickerDidChange(selectedIndex i: Int) { |
| // Convert from miles to meters |
| selectedDistance = Double(i + 1) / 2.0 * 1609.34 |
| } |
First, you create some storage for the selected values: selectedLaps and selectedDistance. Then, in each method, you use the i parameter to get the actual selected value. For laps, you can simply add one: the value at index 0 is 1 lap. For distance, you convert the selected distance (in miles) to meters for storage. With all of your values now saved, you can finally create a Track when the user taps the Save button:
| @IBAction func saveButtonPressed() { |
| guard let name = selectedName else { |
| presentAlertControllerWithTitle("Error", |
| message: "You must select a name for your track.", |
| preferredStyle: .Alert, |
| actions: [ |
| WKAlertAction(title: "OK", |
| style: .Default, |
| handler: {}) |
| ]) |
| |
| return |
| } |
| |
| guard let laps = selectedLaps, distance = selectedDistance else { |
| fatalError("No laps/distance selected. Double-check your implementation" |
| + " of awakeWithContext(_:)!") |
| } |
| |
| let lapDistance = distance / Double(laps) |
| |
| let track = Track(name: name, lapDistance: lapDistance) |
| |
| trackReceiver?.receiveTrack(track) |
| } |
In this method, you ensure that you have a name, a number of laps, and a distance, showing an alert to the user if he hasn’t selected a name. Since you called your picker callbacks in awakeWithContext(_:), you should already have values for the number of laps and the distance; if you don’t, you’ll call fatalError and crash, because something has obviously gone wrong.
In possession of a name, the number of laps, and the distance of those laps, you can now create a Track. Once you do, you can pass it to the track receiver, which will handle dismissing this interface controller and using the track for your run.
18.217.104.118