Make App Pie

Training for Developers and Artists

Swift Watchkit: Headers Footers and More — Multiple Row Types in Apple Watch Tables

2015-08-19_10-10-23

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:

2015-08-17_05-48-09

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.

2015-08-20_05-38-52

If not open, open the document outline. You will see the table in the document outline. If not selected, select it.

2015-08-20_05-41-29

In the attributes inspector, change the number of rows from 1 to 4,

2015-08-17_07-02-21

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:

2015-08-17_07-03-17

In the document outline, you will see the four row controllers:

2015-08-17_07-16-28

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.

2015-08-17_07-23-54

  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:

2015-08-17_07-24-51

 Drag into each of the four controllers on the storyboard a label. For each label, change the width to Relative to Container. 

2015-08-18_05-45-50

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.

2015-08-18_05-47-42

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.

2015-08-18_05-54-27

For the Head, Subhead and Foot’s group change the Height to Size to Fit Content .

2015-08-20_05-13-49

When done, your table should look like this.

2015-08-20_05-24-21

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.

2015-08-18_06-16-36

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.

2015-08-19_10-15-15

scroll down to see the footer.

2015-08-20_06-27-52

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:

  1. Make a header row
  2. Loop through the following
    1. At some condition, make a sub header
    2. Make a Row from data
  3. 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.

2015-08-20_06-39-51

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.

2015-08-19_10-10-23

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!

}

3 responses to “Swift Watchkit: Headers Footers and More — Multiple Row Types in Apple Watch Tables”

  1. […] the last lesson we created a multi-row table. However, we can only view the table, not select from the table. […]

  2. :) Thank you for your post!
    where is the project source code download link?

    1. For a variety of reasons, mostly to do that I am a one man shop. I have not been able to maintain source code downloads, except for the whole code at the bottom of the post. SO my download is a cut and paste into xcode. I hope that will be changing in the future, but I can give no guarantees.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: