WatchOS Complications Don’t Have to Be Complicated, Part 2 – Implementing Basic Functionality

WatchOS complications are a valuable way to provide information-rich widgets for your user’s watch face. Last time, I went over families, templates, and how to plan your complication development. In this post, we’ll get our hands dirty with implementing the basic functionality of a complication and describe the API along the way.

As with the first post, the details here are specific to my experience with WatchOS 6 and Xcode 11. If this API changes in the future, some of things may be invalidated.

Complication Data Source

If you created your project with the “Include Complication” option selected, you probably already have a ComplicationController.swift file in your Watch Kit Extension. The ComplicationController class inherits from CLKComplicationDataSource and acts as the entry point for the application for everything complication related.

The autogenerated file may include function stubs for deprecated methods, so before you get started, read through this post and explore some of the documentation.

I always like to check off the easiest box first to get a sense of progress. The easiest place to start in the ComplicationController is the getPrivacyBehavior(for:withHandler:) function. This function describes whether or not your complication will show data on a user’s always-on display (the default is “on”). You can choose to pass .hideOnLockScreen to the handler parameter to avoid a user’s private data being out in the open when they rest their wrist on their desk.

The implementation here can be as simple as one line: handler(.hideOnLockScreen). Or if you’d like to specify certain states that handle privacy behavior differently, this would be the function to describe that logic.

Creating Complication Previews

The watch face customization preview allows users to get a feel for what your complication will look like and provide. They’ll see it when scrolling through the list of all their apps that offer complications.

One reason to do this next is that it allows us to experiment with different designs and complication concepts prior to doing any heavy lifting. You can build out the static previews in the getLocalizableSampleTemplate(for:withHandler:) function.

Be sure to circle back to these previews after finishing the actual implementation to make sure your preview matches your complication. It would be a strange user experience to be advertised one feature and then never see it. It blows my mind that Apple’s ClockKit allows developers to do this.

The getLocalizableSampleTemplate(for:withHandler:) function will be called once per complication family. It expects that you construct a template and pass it to the handler parameter. You can pass nil to the handler for unsupported complication families.

This lends itself nicely to a basic switch statement on the provided complication’s family property. In each case of the switch statement, we will construct a template and pass it to the handler. It’s unfortunate that the ClockKit API doesn’t offer a better object hierarchy to allow this to be abstracted better, but regardless, the function skeleton below should fit any application.


func getLocalizableSampleTemplate(for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationTemplate?) -> Void) {
    switch complication.family {
        case .modularLarge: // Example
            let template = CLKComplicationTemplateModularLargeStandardBody()
            template.headerTextProvider = CLKSimpleTextProvider(text: “Sample Text)
            template.headerImageProvider = CLKImageProvider(onePieceImage: UIImage(named: “Image_Name”)!)
            template.body1TextProvider = CLKSimpleTextProvider(text: “Body 1 sample text)
            template.body2TextProvider = CLKSimpleTextProvider(text: “Body 2 sample text”)
            handler(template)
        case .[FAMILY_NAME]: // Skeleton
            let template = CLKComplicationTemplate[FAMILY_NAME][…]()
            template.[…]Provider = CLK[…]Provider([…])
            …
            handler(template)
        …
        default:
            handler(nil)
    }
}

Filling in the blanks of a template for each case is as simple as doing what Xcode’s type-ahead suggests:

  1. To get a list of possible values for template, type “CLKComplication” followed by the family name. Xcode should offer a list of templates. Select one.
  2. On a new line, type “template.” When you hit the dot, Xcode will suggest properties for the template. You will need to assign a provider to each of the required provider properties (and likely the optional ones as well). When you select a property from the type-ahead suggestions, make note of the expected type of that property.
  3. Once you have template.someProvider =, start typing the type of the property, and Xcode will offer various subclasses of it. In previews, you generally just want to hardcode some text or image in the providers, so select simple providers that allow for that (such as CLKSimpleTextProvider or CLKFullColorImageProvider).
  4. Call the default constructor of your selected class and give it your sample data.
  5. Repeat this process for the remaining properties and family cases to complete the previews.
  6. Run the app to your watch, and after it boots, return to the home watch face and enter complication selection mode.
  7. Select a complication slot that matches one of the supported families and scroll to find your app’s name. You should see the preview data that you provided above. (If you go back to your watch face, you’ll just see the app logo. We’ll fix that in the next section.)

It’s important to note that the watch caches these preview states pretty aggressively, so any time you make changes, you’ll want to use a fresh install of the app to clear the cache and see the most up-to-date iteration.

Complication Timelines

A timeline is a description of which complication template states to display at a given time. The simplest version of this is a timeline that says, “Display this template until told otherwise.” Slightly more advanced timelines fit the pattern of: “Display this template until time T, then show a different template until…”

Timelines are essential because your complication will be running in the background and not getting continuously refreshed by WatchOS. On top of that, there is a general uncertainty around when and how frequently your complications will be refreshed. This means that you can’t count on always getting to say exactly how a template should look at any given moment. We need to plan ahead and schedule future entries on the timeline.

Current Timeline Entry

The most important entry in a timeline is the entry for right now. That’s why ClockKit breaks it out into a separate function: getCurrentTimelineEntry(for:withHandler:). If you don’t provide a current entry, the complication will display its “no data” state (which is generally just the app icon).

The getCurrentTimelineEntry(for:withHandler:) function will look very similar to the previews function. The only major difference between the two is that you will likely need to fetch some data to use. This may be from an outside source (like a network request) or may just be app state information.

This part will be completely domain dependent. I would suggest breaking this piece out into a manager class of some sort because the ComplicationController file inherently gets verbose, so the less domain logic included the better.

The only other real difference is that here we construct timeline entries rather than templates. Entries are templates paired with a date; in the case of the current timeline entry, the date will always be right now (Date()). You can likely reuse most of the structure from your preview function and then tweak it to meet the states that you need. See my skeleton below and note how similar its structure is.

Now that we are working with dynamic timeline entries, we are not locked into a single template. If certain templates are better suited for displaying one app state over another, then you can freely switch up the template provided at any moment based on that state.


func getCurrentTimelineEntry(for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationTimelineEntry?) -> Void) {
    var entry: CLKComplicationTimelineEntry? = nil
    let complicationInfo = ComplicationDataManager.getComplicationInfo()
      
    switch complication.family {
    case .modularLarge: // Example
        let template = CLKComplicationTemplateModularLargeStandardBody()
        template.headerTextProvider = CLKSimpleTextProvider(text: complicationInfo.header)
        template.headerImageProvider = CLKImageProvider(onePieceImage: UIImage(named: complicationInfo.imageName)!)
        template.body1TextProvider = CLKSimpleTextProvider(text: complicationInfo.body1)
        template.body2TextProvider = CLKSimpleTextProvider(text: complicationInfo.body2)

        entry = CLKComplicationTimelineEntry(date: Date(), complicationTemplate: template)
    case .[FAMILY_NAME]: // Skeleton
        let template = CLKComplicationTemplate[FAMILY_NAME][…]()
        template.[…]Provider = CLK[…]Provider([…])
        …
        entry = CLKComplicationTimelineEntry(date: Date(), complicationTemplate: template)
    …
     default:
        break
    }
    handler(entry)
}

Future Complication States

Future entries are easy to implement in the getTimelineEntries(for:after:limit:withHandler:) function, based on what we’ve done above. Not every app will have a reason to provide future entries, but if you can look ahead to future states in any way, it’s probably a good idea to be prepared in case WatchOS doesn’t refresh your complication when you think it should.

The process of implementing the getTimelineEntries(for:after:limit:withHandler:) function is essentially the same as setting up the current entry. The only differences will be application state logic and assigning the entry.

Rather than always using the current date in your entry constructor, you can use any date that comes after the after parameter. Also, you can pass an array of entries to the handler in this function. Your entries should be in ascending order, and each should be at least a minute apart. Don’t bother creating more entries than the limit parameter specifies, as they will simply be ignored.

Lifecycle

The final step is to make sure that your app reliably updates your complications. We do this by implementing a background refresh loop. This process will schedule when the app should wake from the background to refresh complication states (and other app data, if you want).

First we’ll write a function that can schedule a background refresh with WatchOS. It should look like the function below. This can live anywhere in your codebase that you will have access to from the ExtensionDelegate.


func scheduleNextReload() {
    let refreshTime = Date().advanced(by: Constants.backgroundRefreshInterval)
    WKExtension.shared().scheduleBackgroundRefresh(
        withPreferredDate: refreshTime,
        userInfo: nil,
        scheduledCompletion: { _ in }
    )
}

It’s not guaranteed that the app will refresh at the preferred date, but the OS does its best to target that time. The variables that play into the actual refresh time are opaque and complex (this topic probably warrants its own explanation by somebody much smarter than I). Regardless, the date should be frequent enough to keep your data up to date, but not so frequent that you will exhaust your background processing budget provided by the OS. Add a call to this function in the applicationDidFinishLaunching() function to bootstrap the refresh loop.

When the application wakes for a background refresh, its entry point will be the WKApplicationRefreshBackgroundTask case of handle(backgroundTasks:) in the ExtensionDelegate. The first thing you’ll want to do is call your scheduling function. This ensures that no matter what happens in the background, you can count on getting another shot at it soon.

After scheduling the next reload, you can ask the ComplicationController to reload any complications actively on the user’s watch face with the code below. You may also want to call this function any time your app resigns active to guarantee that when the user lands on their watch face, they will see up-to-date data.


func reloadActiveComplications() {
    let server = CLKComplicationServer.sharedInstance()

    for complication in server.activeComplications ?? [] {
        server.reloadTimeline(for: complication)
    }
}

Wrap Up

Once the background refresh lifecycle is in place, you should be able to launch the app, select your complication as we did before, and see it update according to your app state. With that, you have all of the basic functionality of a complication in place. This workflow will build clean and reliable complications ready to be shipped.

But what if you want more complex functionality or more interesting designs? My next and final post in this series will dig into how you can improve your complications to rival the user experience of stock Apple widgets.


WatchOS Complications Don’t Have to Be Complicated

  1. Families, Templates, & Initial Setup
  2. Implementing Basic Functionality
  3. Advanced Features: Gauges and Text Providers