9.
Complications
Written by Scott Grosch
Exploring the sample
Please build and run the TideWatch app from this chapter’s starter materials. After a moment, you’ll see the current tide conditions at the Point Reyes tide station in California.
Tap the station name to pick a new location:
Even though the app is amazingly useful, as designed, your customers have to open the app to find out what the current water level is. Wouldn’t it be great if they could see the information right on their watch face?
Complication data source
When you create a watchOS project, Xcode will generate ComplicationController.swift. For the sample project, I’ve moved that file into the Complications folder. Also, I removed everything except the one method required by CLKComplicationDataSource. Most of the boilerplate code is unnecessary.
The current timeline entry
When watchOS wants to update the data displayed for your complication, it calls currentTimelineEntry(for:). You’re expected to return either the data to display right now or nil if you can’t provide any data.
If you can’t provide a data point for the current time, then watchOS will look in your extension’s Assets.xcassets bundle. You’ve likely noticed that there’s a Complication folder inside the asset bundle, which you haven’t used before. When currentTimelineEntry(for:) returns nil, watchOS will use the appropriately named image from the asset bundle if it exists.
The simulator’s default watch face is Meridian, which uses the .graphicCircular complication family for most configurable complications. For your first foray into supporting complications in your app, replace the method body with:
// 1
guard complication.family == .circularSmall else {
return nil
}
// 2
let template = CLKComplicationTemplateGraphicCircularStackText(
line1TextProvider: .init(format: "Surf's"),
line2TextProvider: .init(format: "Up!")
)
// 3
return .init(date: Date(), complicationTemplate: template)
That’s quite a bit of code to tell somebody to surf! Here’s what’s happening:
- If the Apple Watch is showing a complication family type you don’t support, then you return
nil. - Then, you create a complication template of the appropriate type and configure the text to display.
- You return a
CLKComplicationTimelineEntrythat specifies the time of the data point and the template to display. The date specified should never be in the future, but it may be in the past.
In step two, notice the template takes two text providers. This is because each template uses different textual or graphical elements. Consult the documentation for the various templates to determine which is appropriate for your needs.
Switch the active scheme to TideWatch –> WatchKit –> App –> (Complication) and then build and run again. Using the complication scheme ensures that your supported families are used without caching. The scheme will also launch the simulator directly to the watch face and give your app a small amount of background processing time.
Tap and hold on the watch face, and the editor will appear:
Note: The simulator sometimes has issues bringing up the editor. If the edit button doesn’t appear, quit and restart the simulator, or use a physical device instead.
Tap Edit, then swipe left two times so you can pick the complication you want to replace:
Tap whichever circular complication you wish to replace, other than the top image of the Earth, to see the list of complications you may choose from:
Scroll until you see your app listed so you can choose the shiny new complication you just created. You don’t see your app listed? Oh no! What went wrong?
CLKComplicationDataSource has an optional method named complicationDescriptors(). Unfortunately, it’s not optional. If you don’t provide the method, you won’t see your complication listed.
Note: The previous version of watchOS looked in Info.plist for the supported complications. That’s why the method is optional. Don’t use the Info.plist anymore, per Apple’s recommendation.
Add the following method:
func complicationDescriptors() async -> [CLKComplicationDescriptor] {
return [
// 1
.init(
// 2
identifier: "com.raywenderlich.TideWatch",
// 3
displayName: "Tide Conditions",
// 4
supportedFamilies: [.graphicCircular]
)
]
}
There’s quite a bit happening in the code:
- You provide an array of
CLKComplicationDescriptoritems as the return value for this method. Each descriptor appears in the list of complications to choose from. - Each complication you support should have a unique name. Ensure that the names are deterministic and don’t change between app launches.
- The
displayNameis what the user sees when choosing a complication from the list that your app supports. - Complications provide an array of the families they support.
Build and run again. This time, when you scroll, you’ll see your complication listed as an option to pick.
Note: The app name displayed in the list is based on the Display Name set on your TideWatch WatchKit App target.
You’re making progress, but what’s up with the -- in the circle? Why isn’t it showing the message you specified in the timeline?
Sample data
The current timeline entry is neither displayed in this list nor the Watch app on your iPhone. When asking for the current timeline data, your app may have to perform an expensive operation or run something asynchronously.
Instead, you use a sample set of data to make the display happen immediately and avoid potential side effects in your app. The CLKComplicationDataSource provides another optional — yah, not really optional IMHO — method called localizableSampleTemplate(for:) that watchOS calls when the Apple Watch needs to display the complication selector in the list.
Implement the method as shown below:
func localizableSampleTemplate(
for complication: CLKComplication
) async -> CLKComplicationTemplate? {
// 1
guard
complication.family == .graphicCircular,
let image = UIImage(named: "tide_rising")
else {
return nil
}
// 2
let tide = Tide(entity: Tide.entity(), insertInto: nil)
tide.date = Date()
tide.height = 24
tide.type = .high
// 3
return CLKComplicationTemplateGraphicCircularStackImage(
line1ImageProvider: .init(fullColorImage: image),
line2TextProvider: .init(format: tide.heightString())
)
}
Here’s a breakdown of the method’s three key elements:
- It ensures the family is one that you support. Also, it makes sure you can load the default image to display in the complication preview.
- Then, it generates sample tide data. The app includes the
TideCore Data model you’re using. By inserting intonil, you prevent actual Core Data updates from occurring. - For the template, you display the image you loaded in step one on top of the tide height. Core Data/Tide+Extension.swift provides a helper method,
heightString(unitStyle:), to properly format the height in the user’s locale.
Build and run again. This time, when you try to select the complication, you’ll see a much better display:
The app uses meters to store all heights. Using a MeasurementFormatter and Measurement<UnitLength>, you ensure a localized display is available on the watch face. You want to surf in a 78.7 -foot high wave, right?
Tap the row to select the complication and then go back to the Apple Watch’s home screen. You’ll see your complication displays, but still with no data:
Updating the complication’s data
When people first learn about complications, the missing “Ah-ha!” moment is that the Apple Watch will only attempt to update the complication on the watch face when you specify that new data is available. Imagine the battery drain if watchOS had to query your complication every second to see if a new data point was available?
Telling watchOS there’s new data
Open CoOpsApi.swift, and you’ll see getLowWaterHeights(for:), the method the app calls when it needs to download new tide data. Using the Combine framework allows for a very clean data download pipeline.
At the top of the file, add an import for ClockKit:
import ClockKit
Back down in getLowWaterHeights(for:), scroll to add(predictions:to:in:), which the app calls when new data has been successfully downloaded and decoded from the network. Right after add(predictions:to:in:), add:
DispatchQueue.main.async {
let server = CLKComplicationServer.sharedInstance()
server.activeComplications?.forEach {
server.reloadTimeline(for: $0)
}
}
Once your Core Data model updates, you tell watchOS that it needs to reload the whole timeline for any complication currently on the watch face. Depending on your app and its data model, reloading the entire timeline might not be the most efficient option.
If the existing data in your complication’s timeline is still valid, and you’re simply adding new data, you should instead call extendTimeline(for:).
Note: If you’ve already exceeded your app’s budgeted execution time, then calls to either method won’t perform any action.
Providing data to the complication
Switch back to Complications/ComplicationController.swift and replace the body of currentTimelineEntry(for:) with:
// 1
guard
complication.family == .graphicCircular,
let tide = Tide.getCurrent()
else {
return nil
}
// 2
let template = CLKComplicationTemplateGraphicCircularStackImage(
line1ImageProvider: .init(fullColorImage: tide.image()),
line2TextProvider: .init(format: tide.heightString())
)
// 3
return .init(date: tide.date, complicationTemplate: template)
Here’s what’s happening with the new method:
- If watchOS asks for a family you don’t support, or there’s no current data to display, then the method returns
nil. - In the second step, you create the appropriate graphic and text template, just like you did for the sample data.
- For the timeline, specify the date of the data you’re providing, as well as the template.
The sample project provides helper methods on the Tide object since the focus of this chapter isn’t how to handle Core Data but rather how to update your complication properly. Feel free to investigate Core Data/Tide+Extension.swift at your leisure.
Build and run again. Give it a moment to download data from the network and then switch back to the watch face. You’ll see real data displayed now:
Supporting multiple families
While you now have a fully functional app with complication support, it’s pretty limited. For your customers to use your complication, they must use one of the watch faces that supports .graphicCircular. Whenever you’re designing complications for the Apple Watch, you should strive to support every type of family you can.
Recall that you specified a single family to the supportedFamilies parameter when you generated CLKComplicationDescriptor in complicationDescriptors(). While it’s just a few keystrokes for you to add the rest of the types, or even simply specify CLKComplicationFamily.allCases, you still have to handle each distinct template type.
Most of the resources you’ll see online tell you to simply create a switch statement, against the family, in each method to determine what actions to take. While you could do that, please don’t. The controller will become massively bloated and incredibly hard to maintain.
Factory Method design pattern
There’s a common design pattern, called Factory Method, which you can implement to great effect. Create a new file in Complications called ComplicationTemplateFactory.swift. Consider the code you’ve written so far, and you can likely see some common patterns that you’ll need to replicate across each family.
You needed to take the following steps, just for a single supported family:
- Generate the current timeline entry.
- Generate the sample template.
- Generate the text which displays the tide height.
- Generate the image which represents the tide type.
A protocol is a great way to represent those actions.
Current timeline entry
Start by adding the following code to your new file:
import ClockKit
protocol ComplicationTemplateFactory {
func template(for waterLevel: Tide) -> CLKComplicationTemplate
}
Regardless of type, any family you support needs a way to convert from your Tide data model to a CLKComplicationTemplate. The code for each type of family will be distinct, so you can’t provide a default implementation.
Samples
Samples, however, can use a default implementation. Add the following line to your protocol:
func templateForSample() -> CLKComplicationTemplate
Consider what localizableSampleTemplate(for:) currently does. It generates a fake Tide entry, creates the template for the correct family and then returns that template. You’ve just specified that any class, which conforms to ComplicationTemplateFactory, knows how to generate a template based on real data. Why not pass that method some fake data instead?
Add the follow to the end of the file, after the protocol definition:
extension ComplicationTemplateFactory {
func templateForSample() -> CLKComplicationTemplate {
let tide = Tide(entity: Tide.entity(), insertInto: nil)
tide.date = Date()
tide.height = 24
tide.type = .falling
return template(for: tide)
}
}
Recall that protocols allow for default implementations of their methods by providing the method in an extension for the protocol. By implementing templateForSample() as a default implementation, you’ve ensured that every single complication family you support will already know how to generate a localizable sample.
Tide height text
Text providers support both a short and long version of the text. Right now, your code simply shows the height, but you can do better than that.
Add another method to the protocol:
func textProvider(for waterLevel: Tide, unitStyle: Formatter.UnitStyle) -> CLKSimpleTextProvider
Then provide a default implementation in the extension:
// 1
func textProvider(
for waterLevel: Tide,
unitStyle: Formatter.UnitStyle = .short
) -> CLKSimpleTextProvider {
// 2
let shortText = waterLevel.heightString(unitStyle: unitStyle)
// 3
let longText = "\(waterLevel.type.rawValue.capitalized), \(shortText)"
// 4
return .init(text: longText, shortText: shortText)
}
Here’s a code breakdown:
- The text provider will generate the appropriate verbiage based on a provided
Tide. Depending on the type of family, you might want to display the height in different lengths. You’ll default to.short. - The short text will simply be the tide’s height.
- The long text will be the tide’s type, followed by its height.
- You pass both types of text to
CLKSimpleTextProvider.
When you use the two-parameter version of CLKSimpleTextProvider, the Apple Watch will choose which text to display based on the configuration of the family. If the longer text fits, that will display. If the complication in use is too narrow, then the shorter version will show.
Why the unitStyle parameter? While you don’t know exactly how much text will fit in any given complication, you do know the type you’re working with. If, for example, you’re displaying against .graphicCircle, you wouldn’t want a height string that spells out the distance. However, you might want to use a longer formant when using the .extraLarge family.
Tide image
Images are as easy to support as text. Add two more protocol methods:
func fullColorImageProvider(for waterLevel: Tide) -> CLKFullColorImageProvider
func plainImageProvider(for waterLevel: Tide) -> CLKImageProvider
While the sample app doesn’t differentiate between full color and plain images, the factory you’re designing here will be reusable across all your apps.
The default implementations are quite simple. Add these methods to the extension:
func fullColorImageProvider(for waterLevel: Tide) -> CLKFullColorImageProvider {
.init(fullColorImage: waterLevel.image())
}
func plainImageProvider(for waterLevel: Tide) -> CLKImageProvider {
.init(onePieceImage: waterLevel.image())
}
You would likely provide two separate image generation methods on the Tide object in your production app.
Templates by family
Please create a Templates folder group inside Complications. Inside Templates, you’ll create a file per family that you support. Start by creating GraphicCircular.swift and filling it with:
import ClockKit
struct GraphicCircular: ComplicationTemplateFactory {
func template(for waterLevel: Tide) -> CLKComplicationTemplate {
return CLKComplicationTemplateGraphicCircularStackImage(
line1ImageProvider: fullColorImageProvider(for: waterLevel),
line2TextProvider: textProvider(for: waterLevel)
)
}
}
Since GraphicCircular conforms to the ComplicationTemplateFactory which you just implemented, you already have a ton of functionality. The only piece you need to handle is the creation of the actual template.
All template(for:) has to do is return the appropriate CLKComplicationTemplate. In the code above, you can see that you simply copied the CLKComplicationTemplateGraphicCircularStackImage you were already using.
Next, you’ll need to implement a method to determine which template family struct to use. Create ComplicationTemplates.swift in Complications with:
import ClockKit
// 1
enum ComplicationTemplates {
// 2
static func generate(
for complication: CLKComplication
) -> ComplicationTemplateFactory? {
// 3
switch complication.family {
case .graphicCircular: return GraphicCircular()
// 4
default:
return nil
}
}
}
Here’s what the code does:
- When an object’s implementation only contains
staticmethods, you use anenumto prevent accidental instantiation. - Given a
CLKComplication, you return the appropriate factory method. - By looking at the
family, you return the appropriatestructwhich implementsComplicationTemplateFactory. - If the given complication family isn’t supported, you return
nil.
Updating the complication controller
Now that you’ve implemented the factory pattern, it’s time to put it to use. Edit ComplicationController.swift again to take advantage of your hard work.
First, replace the body of currentTimelineEntry(for:) with:
guard
// 1
let factory = ComplicationTemplates.generate(for: complication),
// 2
let tide = Tide.getCurrent()
else {
return nil
}
// 3
let template = factory.template(for: tide)
return .init(date: tide.date, complicationTemplate: template)
Here’s a step-by-step explanation:
- By calling the factory generation method, you determine whether the provided complication is supported. No more looking at family types in the complication controller.
- If there’s not a current data point to display, then there’s nothing else to do.
- Using the
factorygenerated in step one, you create the appropriate template for the giventide.
The localizableSampleTemplate(for:) becomes incredibly compact now. Replace the method’s body with this statement:
return ComplicationTemplates.generate(for: complication)?.templateForSample()
Because you had generate(for:) return nil when a family isn’t supported, you can use a null chain operation. If the family isn’t supported, template will set to nil. If it is, then you’ll assign the actual template sample.
But…why?
If it’s not clear why you added the extra level of indirection, imagine your manager tells you that now you must support the .graphicBezel complication family. How much effort will that take? Not much!
There are only three quick steps required. First, add .graphicBezel to supportedFamilies in complicationDescriptors() of ComplicationController.swift:
supportedFamilies: [.graphicCircular, .graphicBezel]
Next, add a new entry to switch in ComplicationTemplates.swift:
case .graphicBezel: return GraphicBezel()
Ignore the compiler error telling you that GraphicBezel() doesn’t exist. Finally, create GraphicBezel.swift in Templates:
import ClockKit
struct GraphicBezel: ComplicationTemplateFactory {
func template(for waterLevel: Tide) -> CLKComplicationTemplate {
let circularTemplate = CLKComplicationTemplateGraphicCircularImage(
imageProvider: fullColorImageProvider(for: waterLevel)
)
return CLKComplicationTemplateGraphicBezelCircularText(
circularTemplate: circularTemplate,
textProvider: textProvider(for: waterLevel, unitStyle: .long)
)
}
}
When generating a complication, the CLKComplicationTemplate subclass you wish to use will drive how template(for:) is implemented.
CLKComplicationTemplateGraphicBezelCircularText wants both a CLKComplicationTemplateGraphicCircularImage as well as a CLKTextProvider. While you’ve already coded the method to generate the text provider, you need a circular image. Looking at the documentation for CLKComplicationTemplateGraphicCircularImage shows you that it requires a CLKFullColorImageProvider. You’ve got a helper method for that!
At this point, you can see how simple it becomes to add new complication families to your app. Beyond that, maintenance is contained in a single file, named after the complication.
If you decide to switch the .graphicCircular family from a CLKComplicationTemplateGraphicCircularStackImage to a CLKComplicationTemplateGraphicCircularImage, the update is quick and simple. You know that GraphicCircular.swift is the only file you’ll need to edit.
Freshness
Great work! You implemented your first complication. Have you noticed the issue with the data? Your complication is only going to be correct if your customer’s run the app hourly.
In the next chapter, you’ll learn how to use the future data you’ve downloaded, as well as keep the data up-to-date if the user doesn’t run the app some time.
Key points
- The complication controller’s methods are all asynchronous.
- Using a factory pattern makes adding newly supported complication families incredibly simple.
- Support as many complication families as possible to provide the best user experience. Even sim
Where to go from here?
The sample project in final shows implementations of almost all the supported complication types. You’ll learn about the SwiftUI-specific complications in a later chapter.
Apple’s Human Interface Guidelines for watchOS contains a wealth of useful material related to complications. For example, you’ll find image size and composition guidance, descriptions of each family type and example images of how the complication family appears on the watch face.
If you’d like to dive deeper into Design Patterns, like the Factory Method design pattern that you implemented in this chapter, please check out our book, Design Patterns by Tutorials.