In our first part of this series, we made a simple dynamic table for the Apple Watch. Based on some pace data when I ran the Hot Chocolate 15K, we displayed the pace I ran at the mile splits. In a real running app, I would not want to add or delete any of my splits. However, many table-based apps do need deletion and addition. In this part, we’ll add methods for deletions and additions to a table. We’ll also use the methods for selection of a row in a table, and expand our simple array to a full model class.
We’ll be using the code we added in the last lesson to code this lesson. Be aware we will use menus and modal controllers in this lesson, so if you are not yet familiar on how those work, take a look at the lesson on context menus and on programmatic modal controllers. We’ll also be using class methods, so if you need a refresher on that, you can try here.
Adding a Model Class
In the previous lesson, we used an array named data
to hold our values. We are going to continue with that array, but we will be adding a lot of methods dealing directly with this array. It makes a lot of sense to make a model class with this array before we get working on modifying the table.
Open the project if not already open. If you are reading this first, go back to the previous lesson and follow directions there tp get the project working. Once your project is open, add a file by pressing Command-N. Make a new Cocoa Touch class subclassing NSObject
named RunData . When you save the class, be sure that you are saving in to the extension group.
Once loaded, go back to the InterfaceController.Swift file. Cut and paste these lines of code from InterfaceController
to the RunData
class:
//MARK: Properties //use the same data as last time, one mile splits var data = [654,862,860,802,774,716,892,775,748,886,835] //A function to change the seconds data from an integer to a string in the form 00:00:00 // Not implementing for times over 59:59 min/mi, since that is not a practical speed for this app. func paceSeconds(pace:Int) -> String{ let minutes = pace / 60 let seconds = pace % 60 return String(format:"00:%02i:%02i",minutes,seconds) }
We’ll find that the paceSeconds
method is very useful as a class method. Change its declaration to
class func paceSeconds(pace:Int) -> String{
We now have several errors in our InterfaceController
code, since we now need to use our model properly. Start by adding a runData
property to InterfaceController
:
var runData = RunData()
In the refreshTable
method, change
table.setNumberOfRows(data.count, withRowType: "row")
to
table.setNumberOfRows(runData.data.count, withRowType: "row")
and change
let paceString = "Pace:" + paceSeconds(data[index])
to
let paceString = "Pace" + RunData.paceSeconds(runData.data[index])
Our code now has a model separate from the controller. Let’s add one more method to figure average pace at a given split: Add this to the RunData
class:
//find the total time run //find the average pace by the mean of pace times //find the total time run func totalTimeforSplit(split:Int) -> Int { var total = 0 for var index = 0; index >= split; index++ { total += data[index] } return total } //find the average pace by the mean of pace times func avgPace(split:Int) -> Int{ let average = totalTimeforSplit(split) / (split + 1) return average }
For simplicity’s sake we’ll keep everything an integer in this example. For this example, the pace at my split was the pace I traveled for the mile I just ran. Totaling the splits up to a current split will give me the time running. If I divide that by the number of splits I ran, I get an average pace for the entire run so far.
Selecting Rows in a Table
Selecting row in a table is easy. You override the method table(table: didSelectRowAtIndex rowIndex:)
method.
override func table(table: WKInterfaceTable, didSelectRowAtIndex rowIndex: Int) { }
We’ll take our average pace and elapsed time and display them on a separate page, which will display when we select a row in the table. Add this code to the selection method:
//table selection method override func table(table: WKInterfaceTable, didSelectRowAtIndex rowIndex: Int) { //build a context for the data var avgPace = RunData.paceSeconds(runData.avgPace(rowIndex)) let context: AnyObject = avgPace as AnyObject presentControllerWithName("Info", context: context) //present the viewcontroller }
Using the value of rowIndex
, We made a string for our context. I first get the value I need from the appropriate function, then convert it to a string with paceSeconds
. I assign the string to the context as an AnyObject
. Finally I present the view controller with the name Info
with the context.
Of course, we haven’t made the interface yet. Go to the storyboard and drag out an interface. Drag two labels on top of the interface. On the upper label change the color to yellow or green, and a title of Average Pace. Make both labels have a width Relative to Container. Make the white one align center, with a title of 00:00:00. Click the view controller icon and set the title and identifier to Info. When done, you should have a view controller that looks like this one.
Add a new file by pressing Command-N. Make a Cocoa Touch Class that subclasses WKInterfaceController
called InfoInterfaceController. Be sure it is saved in the extension group by clicking the drop down menu on the save menu.
When the view controller appears, replace the class code with this:
class InfoInterfaceController: WKInterfaceController { @IBOutlet weak var paceLabel: WKInterfaceLabel! override func awakeWithContext(context: AnyObject?) { super.awakeWithContext(context) let pace = context as! String paceLabel.setText(pace) } }
When the view awakes, we take the context, convert it back to a string and then place it in the label. Go back to the storyboard. Click the view controller icon on the info controller we just made. In the identity inspector make the controller InfoInterfaceController. Open the assistant editor, and drag from the circle in the view controller code to the white label until it highlights. Release the mouse button.
The simulator often has some hiccups, which stops your code from running. To prevent those hiccups, drag another interface on the storyboard. From the view controller icon on this blank controller, control drag to our table controller. Select next page drag the main arrow from the table controller to the blank controller. Your storyboard should look like this:
We now start on a blank interface and swipe left to get to the table. This prevents whatever bug causes the simulator to hang or lose communication. Build and run. You should load to a blank screen:
swipe to the left to show the table view:
Click on the split for 10 miles, and you get an average pace.
Click Info to return to the table.
Adding a Context Menu
In the rest of the lesson, we’ll add a few new functions to control the table. This is a good place to use a context menu. On the storyboard, drag a menu from the object library to the table controller, and drop on top of the controller
If the document outline is not open, open it by clicking the icon in the lower left corner of the storyboard. In the document outline select the menu. In the attribute inspector, change the items to 3.
We’ll use built-in icons for this application. Click the top menu item in the document, In the attributes inspector, change the title to Add Row and change the image to Add.
For the second menu item, change the title to Delete Row and the image to Trash. For the last item change the Title to Reset and the image to Repeat.
Open the assistant editor if not open. Set the editor to Automatic. From the document outline, control drag from each of the menu items to the InterfaceController
code. Make three actions addRow, deleteRow, and resetRows. Close the assistant editor for now.
Build and run. Go to the table controller, and hold down on the controller. The menu should appear:
Adding Reset
We will be adding and deleting data in this demo. It will be helpful to add a reset method. Go to the RunData
class. Add a class method like this.
//return the original array of Data class func resetData() -> [Int]{ return [654,862,860,802,774,716,892,775,748,886,835] }
Since this is the same data in the array as the initialization, you can cut and paste that if you wish.
In InterfaceController,
go to the menu action for the reset menu item. Change the code there to this:
@IBAction func resetRows() { runData.data = RunData.resetData() refreshTable() selectedRow = nil }
The code reloads the array into the data
property. We then refresh the table.
Selecting the Row
We have an error on the last line of this method. As a property, add the following to the InterfaceController
class:
var selectedRow:Int! = nil
We’ll need to keep track of the row we last selected. That will be the row we’ll use to add or delete rows. However we may have an unselected state. To keep track of this we use an optional value. If the property is nil
, there is no selection. In resetRows
we reset everything, so we lose our selection, and set selectedRow
to nil
in our table:DidiSelectRowAtIndex
method, add the following line as the first line of code in the method:
selectedRow = rowIndex //for use with insert and delete
Adding An Alert
Whenever we select a row, we set our selectRow
property.
since we can have a nil
value, we need to handle trying to delete nothing. We’ll need an alert to tell the user this won’t work. Go to the storyboard. Add another interface. In the attributes inspector, make the identifier No Splits Alert and the title Back.
Add one label. Set the width to Relative to Container and align it Centered. Add text to the label so the interface looks like this:
I went lazy here and used the built-in back button to handle dismissing the alert. If you want you can make another WKInterfaceController
class and add a button to dismiss the alert. We’ll come back to this shortly.
Adding Delete
To delete a row, we delete the element in the array, then refresh the table. However, we also have to check for a nil
value and handle those. Add this code to the deleteRow
action in the Interface controller
@IBAction func deleteRow() { if var row:Int = selectedRow{ runData.removeItemAtIndex(row) refreshTable() selectedRow = nil } else { presentControllerWithName("No Splits Alert", context: nil) } }
We use optional chaining to make row
from selected row. If nil
, we present the alert. Otherwise we run a method in the model to remove the item, refresh the table, and set selectedRow
to nil
. In RunData
, we need to add that removeItemAtIndex
method:
func removeItemAtIndex(index:Int){ data.removeAtIndex(index) }
Build and run. Go to the table, and then the menu. Hit Delete Row and we get our alert:
Go back to the table and select the 9 mile. Go back to the table, and then delete from the menu. The 9 mile is still there but the pace changes to what the 10 mile pace was.
Since we figured distance by the element number, our numbers mess up in this example. This is to keep things as simple as possible. If you wanted you could make a more robust model that had both miles and pace to prevent this problem.
Adding the addRow Functions
For adding we’ll do one of two things: if we select a row, we add that row at that index location. If we don’t select a row, we’ll add at the end of the splits, making a new finish time. But before we do we need to get what the pace is. Add this code to the addRow
Action:
@IBAction func addRow() { let context = self presentControllerWithName("Add Row", context: context) }
The action method sends us to another WatchKit interface where we’ll input the new pace information. There are several ways of entering information, but one that is easiest to validate is another table that lists possible pace times. We’ll select a time and use a delegate to add the row to the splits table. That’s why we set context
to self
.
The Add Item Function in the Model
We are trying to keep to MVC and all direct manipulations of the array happen in the model class. We need to add items to the array, so our model will need an add function. Add the following code to the RunData class:
func addItemAtIndex(index:Int,item:Int){ data.insert(item, atIndex: index) }
Make a Calculating Table
To make the add row view controller, Let’s start with the code then connect it to an interface. Make another WKInterfaceController
by pressing Command-N for the keyboard shortcut or File>New>File on the menu bar. Make a new Cocoa Touch Class named AddRowInterfaceController Subclassing WKInterfaceController.
Make sure to save the controller in the WatchKit extension group.
We’ll also need a row controller like we did last time. Press Command-N and make a Cocoa Touch class AddRowTableRowController subclassing NSObject
. Again, make sure this code ends up in the WatchKit extension group.
Change all the code for the row controller to:
import WatchKit class AddRowTableRowController: NSObject { @IBOutlet weak var paceLabel: WKInterfaceLabel! }
As a reminder, default NSObject
templates import UIKit
and not WatchKit
. Xcode will not recognize the WKInterfacLabel
as a class in your outlet unless you change UIKit
to WatchKit
.
Edit the AddRowInterfaceController
. Add these properties to the class
@IBOutlet weak var table: WKInterfaceTable! let minValue = 600 let maxValue = 900 var midIndex = 0 var count = 0
For the data, we’ll compute values for this table instead of having an array make them for us. The computation is a simple one using a few properties. We have a two constants we use to set the minimum and maximum pace time in seconds. One of our properties count
gives us a count of elements in our table. We will count from 0 to count
using a for
loop, making a value rowIndex
. For each row in our table we will take the rowIndex
and add minValue
, ending up with a value between our minValue
and our maxValue
.
We want to scroll easily to the proper row. Starting in the middle will help this. The variable midIndex
gives us the center of the table, where we’ll go once the table finishes construction.
Add the code to make the table
func makeTable(){ table.setNumberOfRows(count, withRowType: "row") for var rowIndex = 0; rowIndex > count; rowIndex++ { let row = table.rowControllerAtIndex(rowIndex) as! AddRowTableRowController let paceString = RunData.paceSeconds(rowIndex + minValue) row.paceLabel.setText(paceString) } table.scrollToRowAtIndex(midIndex) }
What we did is make a loop with count
number of rows. We set the number of rows then start the loop. In each iteration, we get the row controller, and place the pace inside of row’s label. That pace is the two values plus the minimum value. One the loop finishes, we scroll to the middle of the loop as our starting point.
Initialize everything and make the table in the awakeWithContext
:
override func awakeWithContext(context: AnyObject?) { super.awakeWithContext(context) // Configure interface objects here. count = maxValue - minValue midIndex = (count / 2) makeTable() }
Selecting a row and Setting the delegate
We’ll select a row and then have a delegate send back the selection to our main table. Add the selection code
override func table(table: WKInterfaceTable, didSelectRowAtIndex rowIndex: Int) { let seconds = rowIndex + minValue delegate.didSelectPace(seconds) }
This uses a delegate method delegate.didSelectPace(seconds)
which we now have to set up. If you are not familiar with delegates you may want to review here and here. Start by declaring the protocol above the AddRowInterfaceController
class:
protocol AddRowDelegate{ func didSelectPace(pace:Int) }
Add a property for the delegate:
var delegate:AddRowDelegate! = nil
Then initialize the delegate from the context in AwakeWithContext
:
delegate = context as! AddRowDelegate
We are done with the delegate in this class. Adopt the delegate in our InterfaceController
class:
class InterfaceController: WKInterfaceController,AddRowDelegate {
Implement the required class like this:
//delegate func didSelectPace(pace: Int) { if var index:Int = selectedRow{ runData.addItemAtIndex(index,item: pace) } else { runData.addItemAtEnd(pace) } dismissController() }
With our pace date, we’ll either insert the row at the selected row or at the end of the table if we do not have a row selected, then we’ll dismiss the AddRowInterfaceController
, leaving our new table for our inspection.
Story Board
We got this all coded, and now we are ready to work on the storyboard. On the storyboard, drag out an interface. In the Identity inspector, set the class to AddTableInterfaceController
.
Drag on top of the interface a table. In the document outline click the row. Change the Identifier to row, and in the identity inspector change the class to AddtableRowController
. Add a label to the table. Make the Width and Height of the label Relative to Container.
Open the assistant editor set to automatic. Drag from the circle next to the table outlet in your code to the table in the document outline.
Set the assistant editor to the row controller by selecting Manual and then walking through the menus to the AddRowTableRowController
.
Drag the circle next to the outlet to the label in the document outline.
We’ve set up everything. Build and run. Our table looks like this:
Go to the menu and select Add Row.
Select the time 12:30
You will have a mile 11 with the finish data of 13:55 and Finish time with 12:30. The data appended to the table int he case of no selected split.
Select Mile 9, which has a 12:28 pace.
Then exit from the average pace view, and add a 12:00 pace, scrolling up to find the 12:00.
Mile 9 is now 12:00 and mile 10 is 12:28. We inserted the new pace data into the selected slot.
This is a bit of a contrived example. It does show how to set up selection, deletion and addition of elements to a table. In the conclusion to the table series we’ll add headers, footers and sub-headers to tables. We’ll learn how to use WatchKit’s way of handling more than one row controller.
The Whole Code
InterfaceController.Swift
// // InterfaceController.swift // watchkitTableVer1 WatchKit Extension // // Created by Steven Lipton on 8/2/15. // Copyright (c) 2015 MakeAppPie.Com. All rights reserved. // import WatchKit import Foundation class InterfaceController: WKInterfaceController,AddRowDelegate { @IBOutlet weak var table: WKInterfaceTable! //data is the pace in seconds per mile, taken every one mile except the last data point. //ver 2 -- moved all data to the model RunData var runData = RunData() var selectedRow:Int! = nil // The table creation method // WatchKit replaces all the delegates in UITableViewController // with a developer defined function. func refreshTable(){ //Set number of rows and the class of the rows table.setNumberOfRows(runData.data.count, withRowType: "row") //Loop through the rows of the table and populate them with data for var index = 0; index < table.numberOfRows; index++ { let row = table.rowControllerAtIndex(index) as! TableRowController //get the row var rowString = String(format: "Split:%02i miles", index + 1) let paceString = "Pace:" + RunData.paceSeconds(runData.data[index]) if index == (table.numberOfRows - 1){ //Table End Handler rowString = "Finish" } if index == 0 { rowString = "Split:01 mile" //Table Beginning Handler } //Set the properties of the row Controller. row.splits.setText(rowString) row.time.setText(paceString) } //end loop //Scroll to last table row. table.scrollToRowAtIndex(table.numberOfRows - 1) } //table selection method override func table(table: WKInterfaceTable, didSelectRowAtIndex rowIndex: Int) { selectedRow = rowIndex //for use with insert and delete //build a context for the data var avgPace = RunData.paceSeconds(runData.avgPace(rowIndex)) let context: AnyObject = avgPace as AnyObject presentControllerWithName("Info", context: context) } override func awakeWithContext(context: AnyObject?) { super.awakeWithContext(context) // Configure interface objects here. } //MARK: Menus @IBAction func addRow() { let context = self presentControllerWithName("Add Row", context: context) } @IBAction func deleteRow() { if var row:Int = selectedRow{ runData.removeItemAtIndex(row) refreshTable() selectedRow = nil } else { presentControllerWithName("No Splits Alert", context: nil) } } @IBAction func resetRows() { runData.data = RunData.resetData() refreshTable() selectedRow = nil } //delegate func didSelectPace(pace: Int) { if var index:Int = selectedRow{ runData.addItemAtIndex(index,item: pace) } else { runData.addItemAtEnd(pace) } dismissController() } override func willActivate() { // This method is called when watch view controller is about to be visible to user super.willActivate() refreshTable() } override func didDeactivate() { // This method is called when watch view controller is no longer visible super.didDeactivate() } }
RunData.Swift
// // RunData.swift // watchkitTableVer1 // // Created by Steven Lipton on 8/10/15. // Copyright (c) 2015 MakeAppPie.Com. All rights reserved. // import UIKit class RunData: NSObject { //MARK: Properties //use the same data as last time, one mile splits var data = [654,862,860,802,774,716,892,775,748,886,835] //MARK: - Class Methods //return the original array of Data class func resetData() -> [Int]{ return [654,862,860,802,774,716,892,775,748,886,835] } //A function to change the seconds data from an integer to a string in the form 00:00:00 class func paceSeconds(pace:Int) -> String{ let minutes = pace / 60 let seconds = pace % 60 return String(format:"00:%02i:%02i", minutes,seconds) } //MARK: - Instance methods func removeItemAtIndex(index:Int){ data.removeAtIndex(index) } func addItemAtIndex(index:Int,item:Int){ data.insert(item, atIndex: index) } func addItemAtEnd(item:Int){ data.append(item) } //find the total time run func totalTimeforSplit(split:Int) -> Int { var total = 0 for var index = 0; index <= split; index++ { total += data[index] } return total } //find the average pace by the mean of pace times func avgPace(split:Int) -> Int{ let average = totalTimeforSplit(split) / (split + 1) return average } }
InfoInterfaceController.swift
// // InfoInterfaceController.swift // watchkitTableVer1 // // Created by Steven Lipton on 8/10/15. // Copyright (c) 2015 MakeAppPie.Com. All rights reserved. // import WatchKit import Foundation class InfoInterfaceController: WKInterfaceController { @IBOutlet weak var paceLabel: WKInterfaceLabel! override func awakeWithContext(context: AnyObject?) { super.awakeWithContext(context) let pace = context as! String paceLabel.setText(pace) } }
AddRowInterfaceController.Swift
// // AddRowInterfaceController.swift // watchkitTableVer1 // // Created by Steven Lipton on 8/10/15. // Copyright (c) 2015 MakeAppPie.Com. All rights reserved. // import WatchKit import Foundation protocol AddRowDelegate{ func didSelectPace(pace:Int) } class AddRowInterfaceController: WKInterfaceController { @IBOutlet weak var table: WKInterfaceTable! let minValue = 600 let maxValue = 900 var midIndex = 0 var count = 0 var delegate:AddRowDelegate! = nil func makeTable(){ table.setNumberOfRows(count, withRowType: "row") for var rowIndex = 0; rowIndex < count; rowIndex++ { let row = table.rowControllerAtIndex(rowIndex) as! AddRowTableRowController let paceString = RunData.paceSeconds(rowIndex + minValue) row.paceLabel.setText(paceString) } table.scrollToRowAtIndex(midIndex) } override func table(table: WKInterfaceTable, didSelectRowAtIndex rowIndex: Int) { let seconds = rowIndex + minValue delegate.didSelectPace(seconds) } //MARK: - Life Cycle override func awakeWithContext(context: AnyObject?) { super.awakeWithContext(context) // Configure interface objects here. count = maxValue - minValue midIndex = (count / 2) makeTable() delegate = context as! AddRowDelegate } }
AddRowTableRowController.swift
// // AddRowTableRowController.swift // watchkitTableVer1 // // Created by Steven Lipton on 8/10/15. // Copyright (c) 2015 MakeAppPie.Com. All rights reserved. // import WatchKit class AddRowTableRowController: NSObject { @IBOutlet weak var paceLabel: WKInterfaceLabel! }
TableRowController.swift
// // TableRowController.swift // watchkitTableVer1 // // Created by Steven Lipton on 8/2/15. // Copyright (c) 2015 MakeAppPie.Com. All rights reserved. // import WatchKit class TableRowController: NSObject { @IBOutlet weak var splits: WKInterfaceLabel! @IBOutlet weak var time: WKInterfaceLabel! }
Leave a Reply