One of the more confusing parts of TapALap is the track selection and configuration process, which makes it an ideal candidate for the iPhone instead. The user should be able to do everything he needs before he runs on the watch, since he might install the app and leave his phone behind—for instance, in a gym locker room—but there are some things you can leave to the user’s iPhone instead: renaming tracks, editing lap distance, or deleting tracks. You’ll use the WatchConnectivity framework to synchronize your track data from watchOS to iOS and use the iPhone app for more advanced editing. Before you can get into WatchConnectivity, you’ll need to add a way to persist track data in TapALap so that your tracks last for more than one run.
Once again, you’re faced with the question of how to store your data. Since you’re going to have a small amount of data—most users won’t have hundreds of tracks configured—you can use the user defaults system as a generic key-value store and save your tracks there. Open up Track.swift and add a method to save tracks to disk. You can’t save them directly to NSUserDefaults, so instead create a dictionaryRepresentation method to convert them to a Dictionary and then an init(dictionaryRepresentation:) method to create a Track from the saved dictionary:
| func dictionaryRepresentation() -> [String: AnyObject] { |
| return ["Name": name, "LapDistance": lapDistance] |
| } |
| |
| init?(dictionaryRepresentation dictionary: [String: AnyObject]) { |
| guard let name = dictionary["Name"] as? String, |
| lapDistance = dictionary["LapDistance"] as? Double |
| else { |
| return nil |
| } |
| |
| self.name = name |
| self.lapDistance = lapDistance |
| } |
With this code in place, you can now convert your Track objects to dictionaries and save them in NSUserDefaults, as well as load them back:
| static func savedTracks() -> [Track] { |
| guard let trackDictionaries = NSUserDefaults.standardUserDefaults() |
| .objectForKey("SavedTracks") as? [[String: AnyObject]] |
| else { return [] } |
| |
| return trackDictionaries.flatMap(Track.init) |
| } |
| |
| func save() { |
| var tracks = Track.savedTracks() |
| |
| guard !tracks.contains(self) else { return } |
| |
| tracks.append(self) |
| |
| let trackDictionaries: [[String: AnyObject]] = tracks.map { |
| $0.dictionaryRepresentation() |
| } |
| |
| do { |
| let newContext = ["Tracks": trackDictionaries] |
| |
| try WCSession.defaultSession().updateApplicationContext(newContext) |
| } |
| catch let error { |
| NSLog("Error saving tracks to application context: (error).") |
| } |
| |
| NSUserDefaults.standardUserDefaults().setObject(trackDictionaries, |
| forKey: "SavedTracks") |
| } |
In these methods, you use map and flatMap to painlessly convert between types. flatMap is especially useful, because the init(dictionaryRepresentation:) method you wrote could return nil if the dictionary doesn’t contain the right values; in that case, flatMap simply discards the nil values and returns an array of Track objects that were successfully created. In save, once you have an array of Dictionary objects representing your Tracks, you can use WatchConnectivity by calling the WCSession’s updateApplicationContext(_:) method. This will send the dictionaries to the phone, where you’ll access them later.
You need to make one more change for this to compile: before you can use contains(_:) on the existing array to see if the track is already saved, Track needs to conform to the Equatable protocol; to tell if the object already exists in the array, you need to know if it’s the same as any object in the array. A Track is equal to another track if their names and lap distances match, so to conform to Equatable, all you need to do is implement ==(lhs:rhs:). Let’s do that now:
| func ==(lhs: Track, rhs: Track) -> Bool { |
| return lhs.name == rhs.name && lhs.lapDistance == rhs.lapDistance |
| } |
| |
| extension Track: Equatable {} |
Now you can support saving your tracks and reusing them. Next, let’s make it possible for the user to switch between saved tracks when the app starts up. Open GoRunningInterfaceController.swift and make a couple of changes to support selecting between a list of tracks. Specifically, if you have saved tracks, you should show a list of tracks to choose from instead of the track configuration screen. You can make that change in trackButtonPressed:
| @IBAction func trackButtonPressed() { |
| if Track.savedTracks().isEmpty { |
| presentControllerWithName("TrackConfiguration", context: self) |
| } |
| else { |
| presentControllerWithName("TrackSelection", context: self) |
| } |
| } |
If the list of saved tracks is empty, you’ll jump right to the track configuration interface. Otherwise, you’ll display an interface controller with the identifier TrackSelection, which will display a list of tracks to choose from. Let’s create that interface controller next.
To choose between tracks, you’ll need a new interface controller. Open Interface.storyboard and drag out a new interface controller, giving it the title “Tracks” and the identifier “TrackSelection.” Add a table to it and then a button. In the table, add two labels, making the first use the Headline font style and the second use the Caption 2 font style, as well as aligned to the right. Give them the titles “Name” and “Distance,” which is what they’ll be used for. Give the button the title “Add New.” This is the basic layout of the screen: each row represents a track, and the Add New button will create a new track. You can set up that segue now—Control-drag from the button to the track configuration screen and choose the modal segue type. Select the segue and give it the identifier “AddNewTrack” so you can reference it from code. With that, your UI is complete. It should look like the following image:
Now you need a class to put your code into. Create a new interface controller class and name it TrackSelectionInterfaceController. Before you write any code, connect your user interface so you don’t forget. You’ll need an outlet for the table and a row controller with outlets for the two labels you created:
| class TrackRowController: NSObject { |
| |
| @IBOutlet weak var nameLabel: WKInterfaceLabel! |
| @IBOutlet weak var distanceLabel: WKInterfaceLabel! |
| |
| } |
| |
| // Inside of the TrackSelectionInterfaceController |
| @IBOutlet weak var table: WKInterfaceTable! |
Head back to the storyboard. You need to change the class of your new interface controller to TrackSelectionInterfaceController, change the class of the table row controller to TrackRowController, and connect your three outlets to the appropriate interface objects. You also need to give the row controller an identifier. Set it to “TrackRow” so you can create a row later. With your outlets connected, you can now put data into your table.
First, create a tracks computed property that returns the tracks you saved into NSUserDefaults but sorted by name. Then create a length formatter for use in the table. You actually load the data in loadTableData, which you also call in willActivate to ensure you always have an up-to-date list of tracks. Finally, loadTableData creates a row controller for each track and configures the row with the track’s name and lap distance. Pretty straightforward! Next, you’ll implement what happens when the user taps a row.
When the user taps a table row, watchOS will call table(_:didSelectRowAtIndex:) on your interface controller. When that happens, you’ll need to do something with the track. You can reuse the TrackSelectionReceiver protocol for this and send the track back to the Go Running interface controller the same way the track configuration page does:
| weak var trackReceiver: TrackSelectionReceiver? |
| |
| override func awakeWithContext(context: AnyObject?) { |
| super.awakeWithContext(context) |
| |
| if let receiver = context as? TrackSelectionReceiver { |
| self.trackReceiver = receiver |
| } |
| } |
| |
| override func table(table: WKInterfaceTable, |
| didSelectRowAtIndex rowIndex: Int) { |
| trackReceiver?.receiveTrack(tracks[rowIndex]) |
| } |
Just as you would on the track configuration screen, you define a trackReceiver property and attempt to set it in awakeWithContext(_:). When the user taps a row, you call receiveTrack(_:) on the receiver, which takes care of dismissing this screen. So far, so good! Next, you’ll take care of the Add button.
Not only will your TrackSelectionInterfaceController have a TrackSelectionReceiver to send selected tracks to, it will itself conform to the protocol to receive tracks from the track configuration screen. This way, when a user selects Add New Track and then creates a new track, you can handle the new track and pass it back to the Go Running interface controller:
| class TrackSelectionInterfaceController: WKInterfaceController, |
| TrackSelectionReceiver { |
| |
| override func contextForSegueWithIdentifier( |
| segueIdentifier: String) -> AnyObject? { |
| if segueIdentifier == "AddNewTrack" { |
| return self |
| } |
| |
| return nil |
| } |
| |
| func receiveTrack(track: Track) { |
| dismissController() |
| |
| track.save() |
| loadTableData() |
| trackReceiver?.receiveTrack(track) |
| |
| } |
Here, in contextForSegueWithIdentifier(_:), you pass self to register yourself as the receiver for the new track. When the user configures his new track, you’ll get it in receiveTrack(_:) and then pass it back to the Go Running interface controller. The user will see both screens dismiss and his new track will be selected.
With these changes, you can now persist your track data across launches and have multiple tracks that you keep track of, and you’re all set to go. But what if you want to rename a track, edit its lap distance, or even delete it altogether? For that, you’ll use TapALap’s as-yet-unused iPhone app, and you’ll use WatchConnectivity to send the data back and forth.
3.136.234.44