How to create pixel-perfect Apple Watch complications for watchOS 8

Posted on February 18, 2022 by Lukas under iOS development | No comments

Apple Watch Series 7 complicationsRecently I have went through the process of adding support for the new watch face complication sizes on the Apple Watch Series 7. Previously, I have used PNG images for the various complication image providers, but with these two new device sizes (41mm and 45mm) things would get a bit out of hand – I felt like it’s time to do it better. In the end, I have spent over 4 days working on this – and in this blog post, I will try to share my learnings and propose what I think is the best approach for having pixel-perfect complications on watchOS 8.

The goal

My goal was to have full support for all the different complication families and Apple Watch sizes. This means supporting all of the following:

(This table is taken from the watchOS Complications Human Interface Guidelines – which is a great resource to have handy when working on complications.)

Doing some quick math, that’s 40 images if there was just one image per complication family. But in reality – since I wanted to support both full-color images, two-piece images, and tinted images, and also because my complications can be in two states (idle or time tracking active) – if you added all that together, it would amount to hundreds of images.

PDF assets to the rescue? Well.. not quite

There is basic support for PDF assets in Watch apps. The way it works is that in your Asset catalog, you specify that you want a Single scale and you enable Automatic scaling.

Assets watchOS scaling

The automatic scaling then creates differently sized versions of the image for each Apple Watch size, using these percentages:


(This table is taken directly from the WatchKit documentation: Supporting Multiple Watch Sizes.)

I believe this works well for actual watch apps, but for complications, there are various problems with it: first, since you have to provide the image in the size that it will appear on the 40/42mm watches, you still have to provide different images for different complications. Considering that there are 9 complication families, and that about half of them need two-piece images and the second half need tinted versions of its full-color images, that brings the number of images to about 22 or so.
The second problem: the percentages by which the image is enlarged or shrunk don’t always match the required complication image size on larger watches. To give a specific example: the CLKComplicationTemplateGraphicCornerTextImage image size for the 40mm version is 20×20 pt. When multiplied by 1,19 (using the table above), you get 23,8×23,8 pt. But the actual correct size for the 45mm version is 24x24pt. So the resulting image will be misaligned, like this:


(On the left, you can see the auto-scaled version – notice the top and right sides being a bit off. On the right, the image is in the correct 24×24 pt dimensions.)

Okay, so PDF assets with auto-scaling are not the answer (at least not if you want to have truly pixel-perfect complications on all currently available Apple Watch models). But what is?

The solution – generating the images in code

What I ended up doing is generating all the complication images in code, based on the specific sizes that I need. It’s a bit more work, but – it saved me from having to provide lots of images and then having some naming scheme to match them to specific complications.

Basically, I have an enum for all the complication image sizes that I support. And then I have a struct where I provide the sizes for all the different complication image types, taken directly from HIG. It looks like this:


enum ComplicationImageType {
    case graphicCornerTextImage
    case graphicCircularStackImage
    case graphicCircularImage
    // ....
}

struct ComplicationImageSizeCollection {
    var size38mm: CGFloat = 0
    let size40mm: CGFloat
    let size41mm: CGFloat
    let size44mm: CGFloat
    let size45mm: CGFloat
        
    static let graphicCornerTextImageSizes = ComplicationImageSizeCollection(size40mm: 20, size41mm: 21, size44mm: 22, size45mm: 24)
    static let graphicCircularStackImageSizes = ComplicationImageSizeCollection(size40mm: 14, size41mm: 15, size44mm: 16, size45mm: 16.5)
    static let graphicCircularImageSizes = ComplicationImageSizeCollection(size40mm: 42, size41mm: 44.5, size44mm: 47, size45mm: 50)
    // ....

And then I have a method which takes the current screen size (using WKInterfaceDevice.current().screenBounds.size.height) and returns the size for the specific Apple Watch model:


func sizeForCurrentWatchModel() -> CGFloat {
    let screenHeight = WKInterfaceDevice.current().screenBounds.size.height
    if screenHeight >= 242 {
        return self.size45mm
    }
    else if screenHeight >= 224 {
        return self.size44mm
    }
    else if screenHeight >= 215 {
        return self.size41mm
    }
    else if screenHeight >= 197 {
        return self.size40mm
    }
    else if screenHeight >= 170 {
        return self.size38mm
    }
    return self.size40mm
}

I also have a convenience static method on ComplicationImageSizeCollection which lets me easily obtain the sizes for a given complication image type:


static func sizes(for type: ComplicationImageType) -> ComplicationImageSizeCollection {
    switch type {
    case .graphicCornerTextImage: return Self.graphicCornerTextImageSizes
    case .graphicCircularStackImage: return Self.graphicCircularStackImageSizes
    case .graphicCircularImage: return Self.graphicCircularImageSizes
    // ...
}

And then I have various methods which can render a specific image of the complication based on the current Apple Watch size and the specified complication image type.

Here’s a simplified example of one of them:


func coloredTimerImageProvider(for type: ComplicationImageType, color: UIColor, rounded: Bool = false) -> CLKFullColorImageProvider {
    let complicationImageSizes = ComplicationImageSizeCollection.sizes(for: type)
    let width = complicationImageSizes.sizeForCurrentWatchModel()
    let size = CGSize(width: width, height: width)
    
    // First draw the background
    let rect = CGRect(x: 0, y: 0, width: size.width, height: size.height)
    UIGraphicsBeginImageContextWithOptions(rect.size, true, 0)
    let context = UIGraphicsGetCurrentContext()!
    
    // TODO: Draw your symbol here.
    context.setFillColor(color.cgColor)
    context.fill(rect)
    
    var fullColorImage = UIGraphicsGetImageFromCurrentImageContext()!
    UIGraphicsEndImageContext()
    
    if rounded == true {
        fullColorImage = fullColorImage.roundedImage
    }
    
    // TODO: Draw this one using Core Graphics too. It will be used for the "tinted" version.
    let timerSymbolImage: UIImage
    
    let tintedImageProvider = CLKImageProvider(onePieceImage: timerSymbolImage)
    return CLKFullColorImageProvider(fullColorImage: fullColorImage, tintedImageProvider: tintedImageProvider)
}

And then on the call site, if you have a switch over the various complication families, you’d call it like this:


case .utilitarianSmall:
    let template = CLKComplicationTemplateUtilitarianSmallSquare()
    template.imageProvider = self.timerImageProvider(for: .utilitarianSmallSquare)
    return template

What about images that cannot be generated in code?

For me, in several of my complications, I am using the icon of my watch app when in an idle state. These can’t be easily generated in code.

Apple Watch Series 7 Extra large complications

So what I ended up doing: I have created a PDF version of the icon and added it to my project directly (not inside the asset catalog). And then I render it on the fly in a specific size, using this method:


func renderPDFToImage(named filename: String, outputSize size: CGSize) -> UIImage {
    
    // Create a URL for the PDF file
    let resourceName = filename.replacingOccurrences(of: ".pdf", with: "")
    let path = Bundle.main.path(forResource: resourceName, ofType: "pdf")!
    let url = URL(fileURLWithPath: path)
    
    guard let document = CGPDFDocument(url as CFURL),
          let page = document.page(at: 1) else {
        fatalError("We couldn't find the document or the page")
    }
    
    let originalPageRect = page.getBoxRect(.mediaBox)
    
    // With the multiplier, we bring the pdf from its original size to the desired output size.
    let multiplier = size.width / originalPageRect.width
    
    UIGraphicsBeginImageContextWithOptions(size, false, 0)
    let context = UIGraphicsGetCurrentContext()!
        
    // Translate the context
    context.translateBy(x: 0, y: (originalPageRect.size.height * multiplier))
    
    // Flip the context vertically because the Core Graphics coordinate system starts from the bottom.
    context.scaleBy(x: multiplier * 1.0, y: -1.0 * multiplier)
    
    // Draw the PDF page
    context.drawPDFPage(page)
    
    let image = UIGraphicsGetImageFromCurrentImageContext()!
    UIGraphicsEndImageContext()
    
    return image
}

To see all these helper structs and methods as well as examples of methods which create CLKImageProviders, you can refer to this gist on GitHub: ComplicationController+helpers

If you have an idea how to do this better, I’d love to hear about it in the comments. In terms of how this was built in ClockKit: I understand it is a complicated problem to solve, but I think one good improvement would be if the individual CLKComplicationTemplates could provide the expected maximum size of its image for the current Apple Watch model. That would already save me (and I believe many other developers too) from having to have a lot of boilerplate code or having a large number of images.

I hope you have found this useful. And good luck with creating pixel-perfect complications for your own watch app!