Some tables are boring. In our multi-part look at the table view in Apple Watch, We’ve looked at tables with only one kind of row type. However, tables can be lot more than just one row. We might have a header row, or a footer row, we may have sub rows to do some grouping or subtotals. In this lesson we’ll learn how to add those rows into a table.
In the first lesson of the table series we introduced the setNumberowRows:withRowType
method to make a table with a single row type. For example in the second lesson where we manipulated table data, we had this:
table.setNumberOfRows(runData.data.count, withRowType: "row")
We used the count
property of an array with our data to present and give one row type. For a multiple row types we use a different method:
table.setRowTypes(rowTypes)
where rowtypes
is an array of strings identifying row types. Row types are the row identifiers in the storyboard for row controllers in a table. I could, for example, have a table set like this in my storyboard:
I would take each of the groups in this table and identify them as a part of the table: Header, Sub Head, Row, and Footer.
If I had 4 data points like this:
var data = [740,745,750,740]
I would make a row header like this to show the correct type of row by the an element in row type.
var rowTypes = ["Header","Sub Header", "Row","Row","Sub Header", "Row","Row","Footer"]
Swift would know how many rows we have here since it is rowTypes.count
. If you are sharp and have followed along, you’ll notice the problem there is with this setup. In our previous lessons, we would get a row with a loop like this:
for var index = 0; index < table.numberOfRows; index++ { let row = table.rowControllerAtIndex(index) as! TableRowController //get the row let dataString = String(format:"%02",data[index]) //Set the properties of the row Controller. row.label.setText(dataString) } //end loop
We get a row, and downcast it to the correct row controller class. We take the data in the array, index
to place the data in the row controller’s label. This works fine with a single row type table. Add more than one row type and things get a bit dicey. We don’t know what class or row controller to use, and we don’t know where the data is since there is not a one to one correspondence with the index. In the rowTypes array above, the value of data[0]
is in rowtype[2]
Some people will only use a header and a footer.
var rowTypes = ["Header","Row","Row","Row","Row","Footer"]
For those cases you could put some code before and after the loop to handle the header and footer. Subheads and sub footers are not so easy though. How do you know when they exist,and what do you do when they show up in the array? They’ll also mess up correspondence to the data array even more than before.
For all these cases it’s easier to have a switch..case
in our loop to parse the type. Read the rowtype
array, and then react accordingly. In the rest of this lesson, we’ll explore ways to be flexible enough to handle anything thrown in our direction.
Set up the project
For this part of the lesson we’ll start from scratch. Open Xcode and press Command-Shift-N to make a new iOS Single View Project. Name the Project WatchKitMultiRow with Swift as the language. Save the project where you want.
Once the project loads, go to Editor>Add Target… Add a WatchKit App Target. In the next screen, check off the notifications, and activate the target
Add the Model and Model Methods
Go to the extension group in the navigator, and select the InterfaceController.swift. Add the following two lines just under the interface controller’s class declaration:
var rowTypes = ["Header","Sub Header", "Row","Row","Sub Header", "Row","Row","Footer"] var data = [740,745,750,740]
Row one is our row types array, and line two is our data. We’ll add a few methods to use in our headers and footers:
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 var minutes = (pace - (hours * 3600 )) / 60 let seconds = pace % 60 return String(format:"%02i:%02i:%02i",hours, minutes,seconds) }
The first method avgData
finds the average of the range of data. The paceSeconds
method formats the number of seconds we use as data into a string of the type HH:MM:SS.
Set Up the Storyboard
Go to the storyboard in the App Extension. Find the table controller. Drag the controller on the interface.
If not open, open the document outline. You will see the table in the document outline. If not selected, select it.
In the attributes inspector, change the number of rows from 1 to 4,
This is badly labeled. Rows is not the number of rows in your table. This is the number of row types you will use in the table. In the storyboard you now have four groups on your table labeled Table Row:
In the document outline, you will see the four row controllers:
Click on the top Table Row Controller in the document outline. In the attribute inspector, change the Identifier for the row controller to Header. Also deselect the Selectable check box.
It will change in the document outline to Header. Change the rest of the table row controllers to Subhead, Row and Footer. For Subhead and Row, leave the Selectable Box checked. For Footer, uncheck it. Selectable lets the user select the row and then run code on it. We do not want that for our header and footer. Later on, we will add actions with the subhead and row.
Your document outline should look like this:
Drag into each of the four controllers on the storyboard a label. For each label, change the width to Relative to Container.
Change the text on the top label to Head,then the next three to SubHead, Row, and Foot. In the font attribute, change the font on the Head and Foot to Headline.
Change the font on the SubHead to Subhead. Change the colors of the group to red for the header, green for the subheader, and blue for the footer.
For the Head, Subhead and Foot’s group change the Height to Size to Fit Content .
When done, your table should look like this.
Add TableRowControllers
We need a class for the four row controllers. On the drop-down menu, go to File>New>File or Tap Command-N on the keyboard. Make a new cocoa touch class HeadTableRowController subclassing NSObject. Save the file in the WatchKit extension group.
In the code change import UIKit
to import WatchKit.
Repeat this three more times, making a SubHeadTableRowController, RowTableRowController,and FootTableRowController.
Go back to the storyboard. In the document outline select the Header table row controller. In the identity inspector, change the class to HeadTableRowController. Open the assistant editor. In. the assistant editor you will have to manually specify the HeadTableRowController.
If not visible, open the outline for the Header row so you can see the label. This is one of those times it is much easier to select from the outline than the objects on the storyboard. Control drag the label to the row controller class. Make an outlet Named label.
Do the same for the other three row controllers, each time making a outlet named label.
Select the interface controller for the table in the document outline. Make sure the identity inspector has InterfaceController for the class. If not, change it. Select the table and set the assistant editor to Automatic. Control drag the table to the class, making an outlet table.
Close up the assistant editor.
Iteration 1: Add the Table Creation Method
We are going to show two different methods for creating the table. The first assumes you set rowTable
manually. Go to the InterfaceController
class. Add this code to the class:
func refreshTable(){ var dataIndex = 0 table.setRowTypes(rowTypes) for var rowIndex = 0; rowIndex < rowTypes.count; rowIndex++ { } }
Line 3 sets this as a multi-row controller table. As in earlier lessons, we’ll loop through an array and set each element of the array. Unlike previous lessons, we are not iterating through the data array but the rowtypes
array. We’ll need some place marker for where we are in the data and thus we have a variable dataIndex
to keep track.
Each row type has its own way to present data on its own controller. Using rowTypes[rowIndex]
, we’ll make a row with the right controller then present the data. This is a good place to use a switch
statement. Inside the for
loop add this code:
switch rowTypes[rowIndex]{ case "Header": let row = table.rowControllerAtIndex(rowIndex) as! HeadTableRowController row.label.setText(String(format:"Count: %i",data.count)) case "Sub Header": let row = table.rowControllerAtIndex(rowIndex) as! SubHeadTableRowController let avg = avgData(data, start: 0, end: dataIndex) row.label.setText(String(format: "Avg Pace: %i", avg)) case "Row": let row = table.rowControllerAtIndex(rowIndex) as! RowTableRowController row.label.setText(String(format: "Pace %i Sec", data[dataIndex++])) case "Footer": let row = table.rowControllerAtIndex(rowIndex) as! FootTableRowController let avg = avgData(data, start: 0, end: data.count - 1) row.label.setText(String(format: "Avg Pace: %i", avg)) default: print("Not a valid row type: " + rowTypes[rowIndex] ) }
Note the common code here, for the proper row type we get a row in that row table controller class. Then we use the methods there to populate the row. The header will have a count of our data, the subhead the current average pace of the run, the row the current pace data and the footer the overall average. Call the method in the willActivate
method:
override func willActivate() { // This method is called when watch view controller is about to be visible to user super.willActivate() refreshTable() }
Build and run.
scroll down to see the footer.
Iteration 2: Add a Flexible 2-pass Method
The code above is great if you know what your row types are when coding. In our code so far we know we have four data points and two subheadings. We rarely know that. We’ll need a more flexible solution to the problem than the code above. We have to create that rowTypes
array before we make the table.
The New Model
We have two arrays at the moment: rowtypes
and data
. As discussed before, these do not have a 1:1 index relationship. The rowindex[2]
is where data[0]
is. Our table would be a lot easier to handle if rowIndex
has the same index as our data. We do that by making another array. Let’s call it rowData
. This array rowData
will store whatever data we need for that row.
Add this to our code property declarations
var rowData:[Int] = []
We’ll make this a integer array for now. we’ll store what we are displaying in the labels for the row in it. In our next lesson we’ll see why we may want to make this something more.
The build method
Since we have rowData
, we build our table logically in rowType
and rowData
. We have to do the following:
- Make a header row
- Loop through the following
- At some condition, make a sub header
- Make a Row from data
- Make a Footer
At each of these steps, we’ll append a row type to the rowtypes
array and row data to the rowData
array. Add this method:
func addRowWithType(type:String,withData data:Int){ rowTypes = rowTypes + [type] //append the rowtype array rowData = rowData + [data] //append the rowData array }
We use this in the buld methd. Add the following method for our build of the table:
func buildTable(){ //clear the arrays //make a header //loop through the data for var index = 0; index < data.count; index++ { //if we are on an even row except 0, add a subhead/foot // add the row data } //add the footer }
This is the outline we made above. What we will now do is flesh out this structure. Add to this method to clear the arrays:
//clear the arrays rowTypes = [] rowData = []
Add the header, with the number of data items as our data
//make a header addRowWithType("Header", withData: data.count)
Inside the loop we have the header and subhead. In this example, the header is a summary row between every two rows. Change the loop to this:
//loop through the data let subHeadInterval = 2 for var index = 0; index < data.count; index++ { //if we are on an interval row except 0, add a subhead/foot if index % subHeadInterval == 0 && index != 0{ addRowWithType("Subhead", withData: avgData(data, start: index - subHeadInterval, end: index - 1)) } // add the row data addRowWithType("Row", withData: data[index]) }
To make this more flexible, we’ll use a constant subHeadInterval
to tell us how many rows to display before displaying a subhead. We use the modulus operator %
to tell us when we get to that row. In that subhead we figure the average pace for the last split. For every time through the loop, we add a row.
Last we have the footer. Add this for the footer in our method:
//add the footer addRowWithType("Footer", withData: avgData(data, start: 0, end: data.count - 1))
The Refresh Table Method
We have two arrays with a common index. Use those arrays to make a table. Much of this is the same as before. Add another method to our code:
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])) case "Subhead": let row = table.rowControllerAtIndex(rowIndex) as! SubheadTableRowController row.label.setText("Avg Pace: " + paceSeconds(rowData[rowIndex])) case "Row": let row = table.rowControllerAtIndex(rowIndex) as! RowTableRowController row.label.setText(paceSeconds(rowData[rowIndex])) case "Footer": let row = table.rowControllerAtIndex(rowIndex) as! FooterTableRowController row.label.setText("Pace: " + paceSeconds(rowData[rowIndex])) default: print("Not a value row type: " + rowTypes[rowIndex] ) } } }
This is very similar to the refreshTable()
we already wrote. The difference is the lack of calculations — that’s all done in buildTable.
Here we only format and display our results.
change the willActivate()
to this
//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() }
Build and run. We get similar results to our first attempt.
then change the data
//var data = [740,745,750,740] var data = [740,745,750,740,760,765,770,755]
This would have messed up refreshTable
. but refreshBuildTable
works fine. Build and run. You have a longer table.
There’s a few things about this table we still need to do. I’d like to be able to know what mile the splits are for in my sub head. I’d also like to be able to display a lot more data about my run at every row. We’ll do that in our next lesson, where we’ll learn how to select in a multi-row table, and use a custom data type that give us a lot more flexibility in our information.
The Whole Code
InterfaceController.swift
// // 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 InterfaceController: WKInterfaceController { @IBOutlet weak var table: WKInterfaceTable! var rowData:[Int] = [] 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){ rowTypes = rowTypes + [type] //append the rowtype array rowData = rowData + [data] //append the rowData array } func buildTable(){ //clear the arrays rowTypes = [] rowData = [] //make a header addRowWithType("Header", withData: data.count) //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)) } // add the row data addRowWithType("Row", withData: data[index]) } //add the footer addRowWithType("Footer", withData: avgData(data, start: 0, end: data.count - 1)) } 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])) case "Subhead": let row = table.rowControllerAtIndex(rowIndex) as! SubheadTableRowController row.label.setText("Avg Pace: " + paceSeconds(rowData[rowIndex])) case "Row": let row = table.rowControllerAtIndex(rowIndex) as! RowTableRowController row.label.setText(paceSeconds(rowData[rowIndex])) case "Footer": let row = table.rowControllerAtIndex(rowIndex) as! FooterTableRowController row.label.setText("Pace: " + paceSeconds(rowData[rowIndex])) default: print("Not a value row type: " + rowTypes[rowIndex] ) } } } //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() } }
HeaderTableRowController.Swift
import WatchKit class HeaderTableRowController: NSObject { @IBOutlet weak var label:WKInterfaceLabel! }
SubheadTableRowController.Swift
import WatchKit class SubheadTableRowController: NSObject { @IBOutlet weak var label:WKInterfaceLabel! }
RowTableRowController.Swift
import WatchKit class RowTableRowController: NSObject { @IBOutlet weak var label:WKInterfaceLabel! }
FooterTableRowController.Swift
import WatchKit class FooterTableRowController: NSObject { @IBOutlet weak var label:WKInterfaceLabel! }
Leave a Reply