Type Safety for Interface Controller Identifiers in WatchKit

I’ve been working with WatchKit lately and was rewriting some old code for better navigation handling.

When creating a watchOS app using WatchKit, we use Interface Builder for creating WKInterfaceController.

For identifying that particular screen while handling the navigation programmatically, we provide the custom class for it and the identifier associated with that screen.

Here, we use hard-coded strings for the identifier, and I sometimes mistype them in code while presenting/pushing the controller or reloading them. So I used a more type-safe approach of using enums and an extension on WKInterfaceController that I want to share.

Workout App Clone

I’m creating a clone of the workout app on the Apple watch. I start with the first screen, which lists the type of workouts in a carousel style.

Let’s name this controller as WorkoutListInterfaceController.

When clicking on any of the activities, it opens a timer screen which opens three paged screens. Those screens contain the details of the workout, controls for it, and the now playing screen.

I name the timer controller as WorkoutCountdownInterfaceController.

The page style screens can be named as WorkoutControlsInterfaceController, WorkoutStatisticsInterfaceController, and NowPlayingInterfaceController respectively.

These will be the identifiers for defining the WKInterfaceController in Interface Builder and identifying them while writing code.

Using Hardcoded Strings

When you click on a particular workout, it reloads the screen with a single screen controller for showing the timer.

The timer screen after the countdown reloads the screen with page-based controllers.

For that, we use the method— reloadRootPageControllers(withNames:contexts:orientation:pageIndex:)

The standard approach would be calling this function as -

WKInterfaceController.reloadRootPageControllers(withNames: ["WorkoutControlsInterfaceController", "WorkoutStatisticInterfaceController", "NowPlayingInterfaceController"], contexts: [context], orientation: .horizontal, pageIndex: 1)

And oops. This gives an error-

Error — interface does not define view controller class WorkoutStatisticInterfaceController

The error happened because I mistyped the name of the identifier. And the probability of such mistakes happening increases when you have 10–20 of such controllers.

Reloading Controller Using Enum

So, let’s try a more type-safe way using enum!

I start with an enum InterfaceIdentifier and add identifiers as cases.

enum InterfaceControllerName: CustomStringConvertible {
    case workoutList
    case workoutCountdown
    case workoutControls
    case workoutStatistics
    case nowPlaying
}

extension InterfaceControllerName {
    var description: String {
        self.rawValue.capitalized + "InterfaceController"
    }
}

If I have to be honest, the primary reason for working on making navigation easier was to get the dot notation.

I wrote a method to reload one single root screen and page-paged screens that take InterfaceControllerName as a parameter.

extension WKInterfaceController {

    /// Pushes a new interface controller onto the screen.
    /// - Parameters:
    ///   - name: The case of the interface controller you want to display from `InterfaceControllerName`.
    ///   In your storyboard, the name of an interface controller is stored in the object’s Identifier property, which is located in the attributes inspector.
    ///   - context: An object to pass to the new interface controller.
    ///   Use the object in this parameter to communicate important information to the new interface controller, such as the data to display or any relevant state information.
    func push(withName name: InterfaceControllerName, context: Any = [:]) {
        DispatchQueue.main.async {
            self.pushController(withName: name.description, context: context)
        }
    }

    /// Presents a single interface controller modally.
    /// - Parameters:
    ///   - name: The case of the interface controller you want to display from `InterfaceControllerName`.
    ///   In your storyboard, the name of an interface controller is stored in the object’s Identifier property, which is located in the attributes inspector.
    ///   - context: An object to pass to the new interface controller.
    ///   Use the object in this parameter to communicate important information to the new interface controller, such as the data to display or any relevant state information.
    func present(withName name: InterfaceControllerName, context: Any = [:]) {
        DispatchQueue.main.async {
            self.presentController(withName: name.description, context: context)
        }
    }

    /// Presents a page-based interface modally.
    /// - Parameters:
    ///   - names: An array of `InterfaceControllerName`, each of which contains the case of an interface controller you want to display in the page-based interface.
    ///   In your storyboard, the name of an interface controller is stored in the object’s Identifier property, which is located in the attributes inspector.
    ///   The order of the strings in the array is used to set the order of the corresponding interface controllers.
    ///   - contexts: An array of context objects to pass to the new interface controllers.
    ///   Use the objects in this array to communicate important information to the new interface controllers, such as the data to display or any relevant state information.
    func present(withNames names: [InterfaceControllerName], contexts: Any = [:]) {
        DispatchQueue.main.async {
            self.presentController(withNames: names.map { $0.description }, contexts: [contexts])
        }
    }

    /// Loads the specified interface controller as the main controller.
    /// - Parameters:
    ///   - name: The case of the interface controller you want to display from `InterfaceControllerName`.
    ///   In your storyboard, the name of an interface controller is stored in the object’s Identifier property, which is located in the attributes inspector.
    ///   - context: An object to pass to the new interface controller.
    ///   Use the object in this parameter to communicate important information to the new interface controller, such as the data to display or any relevant state information.
    func reload(withName name: InterfaceControllerName, context: Any = [:]) {
        DispatchQueue.main.async {
            Self.reloadRootPageControllers(withNames: [name.description], contexts: [context], orientation: .horizontal, pageIndex: 0)
        }
    }

    /// Loads the specified interface controllers and rebuilds the app’s page-based interface for horizontal scrolling orientation.
    /// - Parameters:
    ///   - names: An array of `InterfaceControllerName`, each of which contains the case of an interface controller you want to display in the page-based interface.
    ///   In your storyboard, the name of an interface controller is stored in the object’s Identifier property, which is located in the attributes inspector.
    ///   The order of the strings in the array is used to set the order of the corresponding interface controllers.
    ///   - contexts: An array of context objects to pass to the new interface controllers.
    ///   Use the objects in this array to communicate important information to the new interface controllers, such as the data to display or any relevant state information.
    ///   - pageIndex: The index of the page that the system displays in the page-based interface.
    func reload(withNames names: [InterfaceControllerName], context: Any = [:], pageIndex: Int) {
        DispatchQueue.main.async {
            Self.reloadRootPageControllers(withNames: names.map { $0.description }, contexts: [context], orientation: .horizontal, pageIndex: pageIndex)
        }
    }
}

Usage Examples

I used three examples in my sample project:

  • For going from the workout list screen to the timer screen, I use —
reload(withName: .workoutCountdown)
  • For proceeding from the timer screen to the main workout screens, I use —
reload(withNames: [.workoutControls, .workoutStatistics, .nowPlaying], context:
context, pageIndex: 1)
  • For ending the workout and going back to the home screen, I use —
reload(withName: .workoutList, context: context)

Conclusion

I feel this eliminated all the bugs related to mistyping identifiers, and I also love dot notation for getting the list of all available controllers.