nevan.net


Make a Table in watchOS Using WKInterfaceTable

In this post I will show you how to implement a table in watchOS. I’ll highlight some of the differences with tables compared to iOS, like setting up row contents in advance and the use of row controllers.

To create a list of items in watchOS you use the WKInterfaceTable class. This class is roughly equivalent to UITableView in UIKit but has some important differences. If you’ve used UITableView for a while, the differences will be surprising, but for new developers the API is far simpler.

The steps required to implement a WKInterfaceTable are:

  • Set up a table in Interface Builder
  • Create a subclass for the table’s rows
  • Tell the table how many rows it has
  • Tell the table the name of its row identifier
  • Set the row contents

Differences between WKInterfaceTable and UITableView

To configure the contents of a UITableView, you set a delegate and data source and give the table information when it requests the number of rows and the row contents. You can create the table in Interface Builder or instantiate it in code.

To create a WKInterfaceTable, you only have the option to use Interface Builder. This is the same as all of watchOS’s interface objects. You have to tell the table in advance how many rows it has and what their contents will be. In watchOS they are part of the table’s setup while in iOS they are queried lazily after the table has been created.

The other change is that there’s no WKInterfaceTableController as an equivalent to UITableViewController. All of the controller methods have been subsumed into the general WKInterfaceController class, which handles cell selection callbacks.

Because you set up all table cells before the interface appears, Apple recommends only using a small number of cells. While you can have a table with thousands of rows in iOS, Apple suggests that you limit the number to around 20 in watchOS. Contrary to this recommendation is the Music app on Apple Watch, which can contain many hundreds of items in the Album list. When scrolling through this list you will see a way that Apple reduces the burden of navigating a long list: A box with the index letter of the current region appears after scrolling for a few seconds and further scrolls advance the list to the following letter. There are no explicit sections in WKInterfaceTable but the table in Music is acting like there are.

Tables on watchOS lack many of the more advanced (and space-consuming) features of iOS tables, like headers, sections, accessory views and Core Data integration.

In this tutorial, you will set up a table-based interface, first with just a single row with one label, then using a row controller and model object to supply data.

Basic WKInterfaceTable

You’re going to create a watchOS app which contains a table with just one item. This will demonstrate how to instantiate a WKInterfaceTable on Apple Watch.

Create an empty project with a Watch app

In Xcode, create a new project (⇧⌘-N) using the iOS Application template for a Single View Application. Call it “Names” and set it to use Swift and target iPhone. You’re not going to use any of the iPhone files created, but will be working exclusively in an Apple Watch target. To create that target, select File->New->Target and under watchOS->Application, select WatchKit App. Call the target “Watch Names” and uncheck the checkboxes to add a Notification, Glance or Complication. This will give you two extra groups (folders) in Xcode, “Watch Names” and “Watch Names Extension”. The extension contains the classes (the brains) and “Watch Names” contains the interface (the looks) in a single Storyboard file. The part with the interface is generally called the “WatchKit App” and the extension is called the “WatchKit Extension”. Open the Interface.storyboard file and you should see one scene, currently empty.

Add a WKInterfaceTable and configure it

You’re going to add a table to the Storyboard, add a label to the table’s single row and configure the label to display the word “Hello”.

With the Interface.storyboard file open, find the objects library (⌃-⌥-⌘-3) and search for table. Click once on the table to see its description:

A table displays one or more rows of data. The table’s row templates define the types of rows that can appear in the table. Your app extension provides the row data dynamically using the WKInterfaceTable API.

Drag the table onto the Storyboard scene and position it at the top of the interface (it will probably default to this position anyway). Expand the hierarchy in the Document Outline and you will see the following:

- Interface Controller
    - Table
        - Table Row Controller
            - Group

The surprising object here is the Table Row Controller. The color in Interface Builder suggests that it’s a WKInterfaceController subclass (similar to a view controller) but in reality it’s a simple NSObject subclass which you create yourself. The row controller is responsible for defining the interface objects for a row. You will use a custom row controller to allow customisation of both the interface objects in the row and their contents. Before you do that, you’ll set up one row without using a custom row controller.

Search for and drag a WKInterfaceLabel from the object library into the table row controller’s group. Every row controller has a group object to contain it’s interface objects. Change the title text of the label to say “Hello” and then Build and Run your app. You can ignore any warnings Xcode gives you for the moment. When the watch app installs, you should see a table with a single row containing the word Hello. Note that this may not work in the current beta of Xcode. I’ve files a radar about creating static tables. If the table doesn’t show for you (black screen), keep going and I’ll show you how to create a full table.

Use a custom row controller to create a table

To create a more dynamic table, you need to have a custom row controller. Select the “Watch Names Extension” group and create a new file (⌘-N). Under watchOS->Source, select the WatchKit Class template and call it “NameRowController”, selecting NSObject as the Subclass of and Swift as the language. Ensure that the file’s target is the Watch Names Extension (the target where the Watch App’s class files go). This row controller will be a way to access and update the row’s label.

Open the Storyboard file again and select the Table Row Controller. You need to change the row controller’s class to the one we just created. Open the Identity Inspector (⌘⌥-3) and set the Class to NameRowController. It should auto-complete. Open the Assistant Editor (⌘-⌥-Return) and if Xcode doesn’t open your NameRowController subclass, open it manually there. Create an outlet called “nameLabel” by ⌃-dragging from the label to inside the NameRowController class. Be careful that you are not dragging from the group. If you do, the option to create an outlet won’t appear. Either select the label before dragging or drag from the label in the Document Outline.

Your class should now look like this:

import WatchKit

class NameRowController: NSObject {

    @IBOutlet var nameLabel: WKInterfaceLabel!
}

with a filled circle beside the IBOutlet to indicate that it’s properly connected.

Custom row controller’s aren’t identified in code by their class, instead they use an identifier string which is set in Interface Builder. With the Name Row Controller selected, open the Attributes Inspector (⌘-⌥-4) and set the Identifier field to NameRowControllerIdentifier. Normally you would give the identifier the same name as the class, but it’s important to know that the class name and identifier are different and must both be set for the table to work. If you forget to set the Identifier, you’ll get a warning like this:

Name Row Controller is missing an identifier.

Add data to the table

Once the row controller is in place you need to configure each row’s data. In the InterfaceController class, create an array of strings to serve as a model object:

  let names = [“Matthew”, “Mark”, “Luke”, “John”]

Create an outlet for the table called “table” by ⌃-dragging from Interface Builder into the InterfaceController class.

Now tell the table how many rows it will display and what their type will be. Add this to the awakeWithContext() method:

table.setNumberOfRows(names.count, withRowType: “NameRowControllerIdentifier”)

Note that you have to set the row type here even though you’ve also set it in the Storyboard. After receiving setNumberOfRows(_:withRowType) the table instantiates all the needed instances of your row controller class and stores them internally, ready for you to finish their configuration.

If you Build and Run at this stage, a table will appear with four rows, but the rows will not yet be configured with the names array.

To configure the row contents, request the row controller for each row from the table using rowControllerAtIndex(_:). This returns an instance of your custom row controller class. Use the nameLabel property that you created to directly set the label’s text:

    for (index, name) in names.enumerate() {
        let row = table.rowControllerAtIndex(index) as! NameRowController
        row.nameLabel.setText(name)
    }

This loops through the model array, using the index to reference the correct row. Since rowControllerAtIndex() returns AnyObject, it needs to be cast to your row controller class. There is no other way to get rows from the table. For example, WKInterfaceTable doesn’t provide a rows property as an array of row controllers.

The table is now fully set up. Build and Run to check that it’s working.

Better interface for row controller

Accessing the view object directly in the row controller can be avoided by using a String object to hold the title. Use willSet to update the label and set the label to private to disallow access from outside.

@IBOutlet private var label: WKInterfaceLabel!

var text: String = “” {
    willSet {
        self.nameLabel.setText(newValue)
    }
}

Now you can also access the row’s title using row.text while providing a cleaner interface and ensuring control over the display.

Final Notes

There are no special requirements for what class the row controller should be. You can compile code that uses an NSArray subclass, although most will crash at run time.

Because the table is always created in Interface Builder, if you try to access it in the init method, you will get an unexpected nil crash. It’s available in awakeWithContext(_:) or willActivate().

There’s no equivalent to UITableView’s reloadData() method. Instead you call setNumberOfRows(_:withRowType:) again, or use `insertRowsAtIndexes(_: withRowType:)

You can have any number of interface elements in your row controller and they can be of any type. For example, you could have a table of date interface objects, or images, or images with labels. Each one will have a corresponding outlet in the row controller to configure it.

Create a table that doesn’t respond to taps (either by highlighting or sending a message to the controller) by de-selecting “Selectable” for the row controller in Interface Builder.

Summary

Set up up a basic table

  1. Create a new project
  2. Add a watch app target (no notification, complication or glance)
  3. Open the watch’s Storyboard
  4. Open the object library (⌃-⌥-⌘-3)
  5. Search for table in the object library and drag one onto the UI
  6. Drag a label onto the table’s group
  7. Change the label text to “Hello”
  8. Build and run. You should see a table with one row. (Or maybe not. See above).

Make the table display model data

  1. Create a new NSObject subclass called “NameRowController.swift”
  2. Change the subclass of the row controller in IB to NameRowController
  3. Drag out an outlet from the row’s label to the subclass called “name”
  4. Change the identifier to “NameRowControllerIdentifier”
  5. Create an outlet from the main interface class to the table
  6. Create an array of strings to serve as the model
  7. In the main interface’s -awakeWithContext(_:) function, set up the table
  8. Tell the table how many rows and what type it has using setNumberOfRows(_:withRowType:)
  9. Set the content of each row using rowControllerAtIndex(_:)
  10. Build and run. A table appears

Respond to taps

  1. Implement table(_:, didSelectRowAtIndex:) in the interface controller
  2. Print the selected row’s title in the console (using the index and model)