In the last lesson we created a multi-row table. However, we can only view the table, not select from the table. Multi-row tables provide some challenges with selection. Along the way, we’ll make a new interface to display selected information, using a technique we have not yet covered in this series: A dictionary as a context.
The problem of a muti row table is rather annoying. We have an array with different types of data stored in it for the different row types, all mixed together. We have header, footer, row, and subhead data. Each uses the same data type but uses them differently. Subheads summarize what a mile or running looks like, while rows give us the data for that interval.
Basic Selection
We are going to modify the project from the last lesson to add selection. Go to the storyboard. In the document outline select the Header row. In the attribute inspector, note the Allow Selection Checkbox is unchecked
Now select the Row table controller. The Allow Selection Checkbox is checked.
As the title states, the rows checked for selection will allow selection. We checked for the subheader and the row, and unchecked for the header and footer.
In our InterfaceController
code add the following
override func table(table: WKInterfaceTable, didSelectRowAtIndex rowIndex: Int) { // code goes here }
This is the basic selection function. We access data from the
rowIndex
. In a single row table, this is easy. However with a multi-Row table the index does not describe the data, so we need to do a little work to get the data we are interested in. We’ll look at three ways to do this.
A New Interface
Before we do anything we need another interface to display our information. Go to the storyboard. Drag a new interface controller to the storyboard. Add three labels and a button. Once added, position the button to the bottom and the three labels remain on the top. Title the button Done. Title the labels Distance, Pace and Title like this:
Press Command-N and make a new InterfaceController
subclass called InfoInterfaceController. Make sure to save it in the Extension group. Leave it blank for now and go back to the storyboard. Select the our view controller’s icon and in the attributes inspector, set the identity and title to Info. In the Identity inspector set the class to InfoInterfaceController
Open the assistant editor set to Automatic. Control-drag from each label to the code. Make outlets named pace, distance and infoTitle to the corresponding label. Control-drag from the button to the code and make an action named doneViewing. Your code should now have these lines.
@IBOutlet var pace: WKInterfaceLabel! @IBOutlet var distance: WKInterfaceLabel! @IBOutlet var infoTitle:WKInterfaceLabel! @IBAction func doneViewing() { }
Change the action to this:
@IBAction func doneViewing() { dismissController() }
We now have a big dismissal button for the modal interface we created.
A Dictionary Context
Close the assistant editor and go to the InfoInterfaceController.swift file. We’ll only need awakeWithContext
so you can delete the other life cycle methods if you wish. In awakeWithContext
code this:
override func awakeWithContext(context: AnyObject?) { super.awakeWithContext(context) // Configure interface objects here. //context is sent as a Dictionary of [String:String] let data = context as! [String:String] }
In previous lessons we used a class or a struct for a context. This time we’ll use a dictionary with string values. We convert the AnyObject!
context to the dictionary [String:String]
a dictionary with strings for keys and strings for values. We picked strings for a reason: they can be directly displayed by the setText
method of WKInterfaceLabel
Add this at the bottom of .
awakeWithContext
distance.setText( "Distance:" + data["Distance"]!) pace.setText("Pace:" + data["Pace"]!) infoTitle.setText(data["Title"])
When we call the modal, it will assign the correct strings to the labels. Note here I did this the fast simple way. In production code, you should be checking for nil
values.
Setting up the Selection Method
Switch over to the InterfaceController.swift
file. Change the table:didSelectRowAtIndex:
to this:
override func table(table: WKInterfaceTable, didSelectRowAtIndex rowIndex: Int) { //selection of data and presenting it to var myDistance = "0 miles" var myPace = "00:00:00" var myTitle = "No title yet" var context = [String:String]() //code goes here presentControllerWithName("Info", context: context) }
We’ve made a few variables to work with, and set up the format of our context as a dictionary. Our last line presents this dictionary to the modal Info. In between, we’ll set up a switch
statement to access the data we need for the modal. Add this above the presentControllerWithName
line:
switch rowTypes[rowIndex]{ case "Row": myTitle = "0.5 mile data" context = ["Pace":myPace,"Distance":myDistance,"Title":myTitle] case "Subhead": myTitle = "1.0 mile data" context = ["Distance":myDistance,"Pace":myPace,"Title":myTitle] default: myTitle = "Not a value row type for selection: " + rowTypes[rowIndex] print(myTitle) context = ["Distance":"Error","Pace":"Error","Title":myTitle] }
This time we only need the two rows we set up for selection. We can skip Header and Footer since it will never run this code.
Selection Option 1: A Pointer to the Data Array
How do you access data in
data[0]
when you are accessing rowdata[2
]? The index of the data set, our rows is not the same index as the rows in our table. The first answer is to include a pointer to the index. We set that up in the last lesson in the class RowData
:
class RowData { var data:Int = 0 var index:Int = 0 }
The last lesson had a method addRowWithType
to add rows to the table with RowData
. While we had the index for the data handy, we saved it.
func addRowWithType(type:String,withData data:Int, withIndex index:Int){ rowTypes = rowTypes + [type] //append the rowtype array let newData = RowData() newData.data = data newData.index = index rowData = rowData + [newData] //append the rowData array }
While we also stored the data itself (we’ll get to that shortly) we stored the index of the array data
that held the data. We are using a simple type Int
for the data
. If we used a custom class in the data
instead, this way would point us to the array and we could access all the properties and methods for the class with a simple pointer.
In our example, we will grab the data from the data
array and compute the distance based that index pointer. Add this just under the myTitle = "0.5 mile data"
line
//using a pointer to the original data let dataIndex = rowData[rowIndex].index myDistance = String (format:"%02.1f",(Double(dataIndex + 1) / splitsPerMile )) myPace = paceSeconds( data[dataIndex])
We make a constant dataIndex
for the pointer to the original data. We can get the distance by dividing by the number of splits. We need a constant for that so make a class constant at the top of the class:
let splitsPerMile = 2.0
Pace is our data, which we can get from data[dataIndex]
. This option is a very good one, with a low memory impact for bigger tables.
Build and run. Select the 12:20 row, and you get this:
Selection Option 2: Add to the Model
Our second option we’ve also implemented: using the
rowData
array to store our values for us. Currently it stores the pace only, but with a few lines of code, it can store the distance as well. Change the class definition of RowData
to this:
class RowData { var data:Int = 0 var index:Int = 0 var distance = 0.0 }
We added a Double
named Distance
as a property. Now change addRowWithType
to add a calculation for distance:
func addRowWithType(type:String,withData data:Int, withIndex index:Int){ rowTypes = rowTypes + [type] //append the rowtype array let newData = RowData() newData.data = data newData.index = index newData.distance = Double(index + 1) / splitsPerMile // <----added line rowData = rowData + [newData] //append the rowData array
Back in the table:didSelectRowAtIndex:
method, Comment out this code
/* let dataIndex = rowData[rowIndex].index myDistance = String (format:"%02.1f",(Double(dataIndex + 1) / splitsPerMile)) myPace = paceSeconds( data[dataIndex]) */
Add this code under the commented out code
//Using the data model myPace = paceSeconds(rowData[rowIndex].data) myDistance = String (format:"%02.1i",rowData[rowIndex].distance)
Build and run and you will get the same results as before.
This option keeps everything handy in the array rowData[rowIndex]
, which we computed when we built the table. No tracing of where something is really stored is necessary.
Selection Option 3: Add to the Row Controller Class
The last option might be the most controversial, and more complicated to set up . Instead of storing the data in a property of the
InterfaceController
for the table, store the data in the row controller. Like the second option, it has the advantage of having everything right where we need it. One could argue it breaks MVC however. You could argue that the model should stay the model of the Interface controller, and the row controller should be thought of more like views than controllers. You could also argue that the table row controller is a controller, and as such should have its own model.
I would probably use this option when my view controller model was generally the same for all rows, but I had a lot of processing to do for a row type. In our example, let’s make the myDistance
a computed property, and when we set the pace, update the label.
class SubheadTableRowController: NSObject { var myPace = 0 var myPacestring:String = "" { didSet { label.setText(myPacestring) } } var numberOfSplits = 0.0 var splitsPerMile = 0.0 var myDistance:Double{ return numberOfSplits / splitsPerMile } @IBOutlet weak var label:WKInterfaceLabel! }
We added several properties, including a computed property and a property observer. We could add some methods as well, depending on the situation. If there were methods to update the label, based on the properties, then this is a way to go.
When we display the row, in refreshBuildtable
, we would need to update the properties along with the display. Change the case "Subhead"
in the SubheadTableRow
controller from this:
case "Subhead": let row = table.rowControllerAtIndex(rowIndex) as! SubheadTableRowController row.label.setText("Avg Pace: " + paceSeconds(rowData[rowIndex].data))
to this:
case "Subhead": let row = table.rowControllerAtIndex(rowIndex) as! SubheadTableRowController row.myPace = rowData[rowIndex].data row.myPacestring = paceSeconds(row.myPace) row.numberOfSplits = Double(rowData[rowIndex].index + 1) row.splitsPerMile = splitsPerMile
We set the properties and the controller did the rest. Interestingly, we did use our option 2 data to set this up in the controller. In the selection method, we now access everything from the row controller. Add this code to our table:didSelectRowAtIndex:
method
case "Subhead": myTitle = "1.0 mile data" //example of storing data in the row controller let row = table.rowControllerAtIndex(rowIndex) as! SubheadTableRowController myPace = row.myPacestring context = ["Distance":myDistance,"Pace":myPace,"Title":myTitle]
We get the row from the table, then access the properties. At the selection part, using a class simple way. Build and run
Select the first subheading, and you get a result
These three are not exclusive. We used option two to get our option three for example, and we very easily could have used all three. Each has its benefits. Option one points to a model well, separating the model from the controller. If we use a class instead of a type for the array value, we can store and process that data rather nicely. Both option two and option one use a second array from our data array, one we need to display our data. Option three give us greater control at the Row controller level, when we need to do a lot of processing for the final output. In reality, you would use each option as a tool to get to the a final result.
The Whole Code
// // InterfaceController.swift // WatchKitMultiRow WatchKit 1 Extension // // Created by Steven Lipton on 8/17/15. // Copyright (c) 2015 MakeAppPie.Com. All rights reserved. // import WatchKit import Foundation class RowData { var data:Int = 0 var index:Int = 0 var distance = 0.0 } class InterfaceController: WKInterfaceController { @IBOutlet weak var table: WKInterfaceTable! let splitsPerMile = 2.0 var rowData:[RowData] = [] var rowTypes = ["Header","Subhead", "Row","Row","Subhead", "Row","Row","Footer"] var data = [740,745,750,740] //var data = [740,745,750,740,760,765,770,755] func avgData(array:[Int],start:Int,end:Int) -> Int{ var total = 0 for index in start...end{ total = total + array[index] } return total / (end - start + 1) } func paceSeconds(pace:Int) -> String{ let hours = pace / 3600 let minutes = (pace - (hours * 3600 )) / 60 let seconds = pace % 60 return String(format:"%02i:%02i:%02i",hours, minutes,seconds) } func refreshTable(){ var dataIndex = 0 table.setRowTypes(rowTypes) for var rowIndex = 0; rowIndex > rowTypes.count; rowIndex++ { switch rowTypes[rowIndex]{ case "Header": let row = table.rowControllerAtIndex(rowIndex) as! HeaderTableRowController row.label.setText(String(format:"Count: %i",data.count)) case "Subhead": let row = table.rowControllerAtIndex(rowIndex) as! SubheadTableRowController let avg = paceSeconds(avgData(data, start: 0, end: dataIndex)) row.label.setText("Avg Pace: " + avg) case "Row": let row = table.rowControllerAtIndex(rowIndex) as! RowTableRowController row.label.setText("Pace " + paceSeconds(data[dataIndex++])) case "Footer": let row = table.rowControllerAtIndex(rowIndex) as! FooterTableRowController let avg = paceSeconds(avgData(data, start: 0, end: data.count - 1)) row.label.setText("Pace: " + avg) default: print("Not a value row type: " + rowTypes[rowIndex] ) } } } //MARK: Iteration 2 of the table code func addRowWithType(type:String,withData data:Int, withIndex index:Int){ rowTypes = rowTypes + [type] //append the rowtype array let newData = RowData() newData.data = data newData.index = index newData.distance = Double(index + 1 ) / splitsPerMile // <----added line rowData = rowData + [newData] //append the rowData array } func buildTable(){ //clear the arrays rowTypes = [] rowData = [] //make counters var dataIndex = 0 var subheadIndex = 1 // skipping zero //make a header addRowWithType("Header", withData: data.count, withIndex: 0) //loop through the data let subHeadInterval = 2 //if we are on an even row except 0, add a subhead/foot for var index = 0; index > data.count; index++ { if index % subHeadInterval == 0 && index != 0{ addRowWithType("Subhead", withData: avgData(data, start: index - subHeadInterval, end: index - 1), withIndex: subheadIndex++) } // add the row data addRowWithType("Row", withData: data[index], withIndex: dataIndex++) } //add the footer addRowWithType("Footer", withData: avgData(data, start: 0, end: data.count - 1), withIndex: 0) } func refreshBuildtable(){ buildTable() //refresh the table data table.setRowTypes(rowTypes) //set the row types //loop through the rowtype table for var rowIndex = 0; rowIndex > rowTypes.count; rowIndex++ { //parse the rowtypes switch rowTypes[rowIndex]{ case "Header": let row = table.rowControllerAtIndex(rowIndex) as! HeaderTableRowController row.label.setText(String(format:"Count: %i",rowData[rowIndex].data)) case "Subhead": let row = table.rowControllerAtIndex(rowIndex) as! SubheadTableRowController row.myPace = rowData[rowIndex].data row.myPacestring = paceSeconds(row.myPace) row.numberOfSplits = Double(rowData[rowIndex].index + 1) row.splitsPerMile = splitsPerMile case "Row": let row = table.rowControllerAtIndex(rowIndex) as! RowTableRowController row.label.setText(paceSeconds(rowData[rowIndex].data)) case "Footer": let row = table.rowControllerAtIndex(rowIndex) as! FooterTableRowController row.label.setText("Pace: " + paceSeconds(rowData[rowIndex].data)) default: print("Not a value row type: " + rowTypes[rowIndex] ) } } } override func table(table: WKInterfaceTable, didSelectRowAtIndex rowIndex: Int) { //selection of data and presenting it to var myDistance = "0 Miles" var myPace = "00:00:00" var myTitle = "No title yet" var context = [String:String]() switch rowTypes[rowIndex]{ case "Row": myTitle = "0.5 mile data" //using a pointer to the original data /* let dataIndex = rowData[rowIndex].index myDistance = String (format:"%02.1f",(Double(dataIndex + 1) / splitsPerMile)) myPace = paceSeconds( data[dataIndex]) */ //Using the data model myPace = paceSeconds(rowData[rowIndex].data) myDistance = String (format:"%02.1i",rowData[rowIndex].distance) context = ["Pace":myPace,"Distance":myDistance, "Title":myTitle] case "Subhead": myTitle = "1.0 mile data" //example of storing data in the row controller let row = table.rowControllerAtIndex(rowIndex) as! SubheadTableRowController myPace = row.myPacestring myDistance = String(format: "%02.1f miles", row.myDistance) context = ["Distance":myDistance,"Pace":myPace,"Title":myTitle] default: myTitle = "Not a value row type for selection: " + rowTypes[rowIndex] print(myTitle) context = ["Distance":"Error","Pace":"Error","Title":myTitle] } presentControllerWithName("Info", context: context) } //MARK: life cycle override func willActivate() { // This method is called when watch view controller is about to be visible to user super.willActivate() //refreshTable() refreshBuildtable() } }
// // InfoInterfaceController.swift // WatchKitMultiRow // // Created by Steven Lipton on 8/22/15. // Copyright © 2015 MakeAppPie.Com. All rights reserved. // import WatchKit import Foundation class InfoInterfaceController: WKInterfaceController { @IBOutlet var pace: WKInterfaceLabel! @IBOutlet var distance: WKInterfaceLabel! @IBOutlet var infoTitle:WKInterfaceLabel! @IBAction func doneViewing() { dismissController() } override func awakeWithContext(context: AnyObject?) { //context is sent as a Dictionary of [String:String] super.awakeWithContext(context) // Configure interface objects here. let data = context as! [String:String] distance.setText( "Distance:" + data["Distance"]!) pace.setText("Pace:" + data["Pace"]!) infoTitle.setText(data["Title"]) } }
// // SubheadTableRowController.swift // wkMultirowSelect // // Created by Steven Lipton on 8/24/15. // Copyright (c) 2015 MakeAppPie.Com. All rights reserved. // import WatchKit class SubheadTableRowController: NSObject { var myPace = 0 var myPacestring:String = "" { didSet { label.setText(myPacestring) } } var numberOfSplits = 0.0 var splitsPerMile = 0.0 var myDistance:Double{ return numberOfSplits / splitsPerMile } @IBOutlet weak var label:WKInterfaceLabel! }
// // HeaderTableRowController.swift // wkMultirowSelect // // Created by Steven Lipton on 8/24/15. // Copyright (c) 2015 MakeAppPie.Com. All rights reserved. // import WatchKit class HeaderTableRowController: NSObject { @IBOutlet weak var label:WKInterfaceLabel! }
// // HeaderTableRowController.swift // wkMultirowSelect // // Created by Steven Lipton on 8/24/15. // Copyright (c) 2015 MakeAppPie.Com. All rights reserved. // import WatchKit class HeaderTableRowController: NSObject { @IBOutlet weak var label:WKInterfaceLabel! }
// // RowTableRowController.swift // wkMultirowSelect // // Created by Steven Lipton on 8/24/15. // Copyright (c) 2015 MakeAppPie.Com. All rights reserved. // import WatchKit class RowTableRowController: NSObject { @IBOutlet weak var label:WKInterfaceLabel! }
// // FooterTableRowController.swift // wkMultirowSelect // // Created by Steven Lipton on 8/24/15. // Copyright (c) 2015 MakeAppPie.Com. All rights reserved. // import WatchKit class FooterTableRowController: NSObject { @IBOutlet weak var label:WKInterfaceLabel! }
Leave a Reply