Make App Pie

Training for Developers and Artists

Swift WatchKit: Selecting With Multiple Rows in Apple Watch

row data mapping illustration

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

2015-08-26_06-31-28

Now select the Row table controller. The Allow Selection Checkbox is checked.

2015-08-26_06-33-49

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. row data mapping 1We 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:

2015-08-26_06-30-47

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

row data mapping pointer 1How 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:

2015-08-26_07-51-17

Selection Option 2: Add to the Model

row data rowData arrayOur 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.

2015-08-26_07-51-17

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

row data mapping classThe 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

2015-08-26_08-42-36

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

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 )

Twitter picture

You are commenting using your Twitter 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: