UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

Build a unit converter for watchOS

Use WatchKit and Swift to find out how many inches are in a parsec

Paul Hudson       @twostraws

Part 2 in a series of cross-platform Swift apps. You can read these in any order you want – each article builds the same app on a different platform, but all are standalone tutorials too.

  1. Build a unit converter for macOS
  2. Build a unit converter for watchOS
  3. Build a unit converter for tvOS

watchOS comes from the same foundations as iOS and macOS, both literally (it uses Apple’s Foundation framework) and figuratively. However, it has one fundamental and inescapable difference: it must run on a comparatively tiny screen, and it must function efficiently enough to satisfy extraordinarily short attention spans.

I already wrote a whole book teaching how to make watchOS apps (yes, it’s fully updated for the latest version of Swift, and actually comes with lifetime updates for free!), but in this article I’m going to take a different approach to the book.

Rather than teach you watchOS programming from scratch, we’re just going to dive into a real-world project and see how we get on. This will mean you having to learn a variety of things at once (the book is paced more carefully), but on the flip side it will give you a pretty clear idea of whether watchOS programming is for you or not.

In this project we’re going to solve a classic introductory problem with a twist. One of the first things I ever developed was a Celsius to Fahrenheit converter (using Visual Basic, of all things!), and there are similar projects for a variety of other languages and platforms.

Here, though, we have Apple’s Foundation framework, so we’re going to take it up a notch: we’re going to build a general-purpose converter that can handle any distance, duration, mass, or temperature units. Ever wondered how many astronomical units there are in 10 yards? Or how many teaspoons of liquid fit into one gallon? Soon you’ll know!

This article is going to be particularly interesting for folks who already read my other tutorial “Build a unit converter for macOS”, because here we’re making exactly the same app for watchOS. I’ve done this specifically so you can see how the two platforms solve the same problems in different ways, so if you missed the earlier article you might want to consider bookmarking it for later.

For now, though, go ahead and launch Xcode, then create a new watchOS project using the iOS App with WatchKit App template. Give your project a name – iConvert? Convertatron? Convertr? – then make sure you uncheck all four checkboxes before clicking Next then Create.

Hacking with Swift is sponsored by RevenueCat

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

Designing our user interface

Just like apps from Apple’s other platforms, macOS apps can be designed using storyboards. They work a little differently, though, because watchOS apps must use storyboards. Yes, you read that correctly: if you’re the kind of person who likes to build their user interfaces using code, watchOS simply won’t allow you.

Fortunately, WatchKit storyboards don’t use autoresizing masks or Auto Layout – they use an entirely different and more simple solution that just arranges things horizontally and vertically.

Let’s try it now: look in the Convertatron WatchKit App group and open Interface.storyboard. You should see one Apple Watch Interface, complete with a clock at the top and a bezel around it.

This app is going to be displayed in two screens to keep things nice and easy for users. The first screen will let the user choose what type of unit they want to convert (distance, temperature, etc) along with what units they want to convert from and to for that type (e.g. centimeters, inches). Once they are ready, they’ll press a button to see the second screen, which will let them enter a number and see the conversion for the units they selected.

To start with, drop a picker into the black space inside the watch screen – you should see it take up over half the screen. Now drag a button below it, then drag two groups between the button and the picker. Your final layout should be picker > group > group > button – we did it in a slightly roundabout way because if you drag out the groups first it becomes hard to place the button.

As you drag out items, you’ll see the virtual Apple Watch expand to fit them all. Clearly this isn’t realistic, but it reveals a powerful feature of watchOS: you can design your interfaces to be as long as you want, and WatchKit will automatically make them scroll using touch or the digital crown.

Tip: When adding user interface components it’s important to make them run right to the edge of the Apple Watch display. This is the kind of thing we rarely do with macOS, iOS, or watchOS apps, but in watchOS it’s a good idea because the physical watch face already provides a black border around our interface.

Inside the first group I’d like you to drag out a label then a picker. If everything has gone to plan, you should see the label disappear when the picker is added – the picker will take up all the space, leaving nothing left for the label.

This might seem annoying, but what you’re seeing is WatchKit’s simple, flexible layout system. Each component you place can be sized to fit its content, sized relative to its parent, or given a specific size in points. By default pickers use their content size for their width and 100 for their height, so by default they will occupy all the horizontal space they are given.

We’re going to change that so that the new picker occupies only 65% of its container, so the remaining 35% is free for the label. So, please select the picker you just added and change its Width property from “Size to Fit Content” to “Relative to Container”. Below a new number will appear: 1. This means “take up 100% of the parent’s space,” so I’d like you to change that to be 0.65 for 65%.

As for the height of the picker, change that from Fixed to be Relative To Container, and it should have the value of 1 by default.

Now go ahead and add another label and picker to the second group, repeating the layout changes above so the two groups look the same.

Next we’re going to make the top picker, the two groups, and the button fit themselves to the available space of the device by giving them each 25% of the available space. Keep in mind that Apple Watch comes in two sizes (38mm and 42mm), so all this Relative To Container work helps us scale beautifully.

So, choose the picker at the very top and give it a Relative To Container height of 0.25, then select the first group, the second group, and the button and do the same for each of them.

Note: You’ll notice that each of our three pickers has no obvious border. It’s entirely subjective, but I find it much better to have a small border around pickers so it’s clearer to users that they can interact with them. It’s absolutely down to you, but I recommend you select each of your pickers then use the attributes inspector to change their Focus Style property to Outline. You won’t see any difference in IB, but it will look better when you run the app.

For the final UI change on this screen, we need to fix the labels – you’ll notice they aren’t quite as tall as the pickers next to them, and they also need something other than “Label” as their text.

Select the first label, then:

  1. Give it the text “From:”.
  2. Using the alignment icons, change its text alignment to be right-aligned. Note: do not change the Horizontal property from Left to Center – you should use the button directly above “Lines”.
  3. Change its vertical alignment property to Center.
  4. Change its horizontal width to be Relative To Container with the value 0.35.

Now repeat that for the second label, except this time calling it “To:”.

Tip: Labels have a text alignment along with a regular alignment, and they do different things. If a label is set to occupy 50% of its container’s space, then text alignment determines where the letters are written inside that 50%. On the other hand, regular alignment determines where in the container the label is placed: in the left, center, or right of the container.

That’s our first screen complete, so let’s look at the second one.

Drag out a new Interface Controller component from the object library, placing it next to the existing screen. Now give it a label, a slider, then another label.

We don’t need to do anything special with the sizes here because they are fine by default, however I would like you to change the horizontal alignment for the labels so they are centered. To be clear, this is the regular alignment not the text alignment – it’s the option directly above the vertical alignments.

We need to set the range of values for our slider in order to make it useful, so please select it then go to the attributes inspector if you aren’t there already. I’d like you to change Maximum to 100 (the highest value the slider can show), Steps to 10 (how much to increase or decrease when the buttons are tapped), and Value to 50 (the initial value for the slider.)

Setting Steps to 10 will divide the slider into green rectangles, but if you’d prefer one continuous line just check the Continuous box. We’ll be adding something later that makes this a good idea, so I suggest you check it!

That completes most of our user interface work, but before we’re done we need to create some outlets and actions so we can refer to these components in code.

So, go to the View menu and choose Assistant Editor > Show Assistant Editor, then make sure your first screen is selected in your storyboard. All being well you should see InterfaceController.swift in the assistant pane, but if not try deselecting and reselecting the screen in Interface Builder.

Please create the following outlets:

  • Call the first picker unitType.
  • Call the second picker fromUnit.
  • Call the third picker toUnit.

Now create the following actions:

  • For the first picker create unitChanged().
  • For the second picker create fromUnitChanged().
  • For the third picker create toUnitChanged().

Now Ctrl-drag from the button over to your second screen in IB, and choose Push. This button is the one users will press to start their conversion, so please give it the text Convert.

As for the second screen, we need to create a new Swift class for it before we can make any connections. So, press Cmd+N to bring up the New File dialog, then choose WatchKit Class under the watchOS heading. For “Subclass Of” please enter WKInterfaceController, then give your new class the name “ResultInterfaceController” before clicking Next.

Important: This next screen is really easy to screw up, so please pay attention. Regardless of what Xcode has said it wants to do, you need to make sure you change the Group option to be your WatchKit Extension – not the WatchKit App or anything else you see. If you don’t do this, you’ll find the rest of this tutorial supremely confusing!

With that change made, go ahead and click and click Create and Xcode should bring up your new class for editing. Head back to Interface.storyboard, then select the second screen – the one with the slider.

We need to connect that screen to the new ResultInterfaceController we just created, so go to the identity inspector and change its Class property to be “ResultInterfaceController”. You should immediately see the assistant editor window update to show ResultInterfaceController.swift, which means we’re ready to make some connections.

Please create the following outlets:

  • Call the first label amountLabel.
  • Call the slider amountSlider.
  • Call the second label resultLabel.

While the slider is there to let users choose values easily, the label is there so we can show them the current value – this label/slider combo is common in WatchKit.

We also need one action, so please make one from the slider called inputChanged() – this will be triggered whenever the slider’s value changes.

At last we’re done with Interface Builder, but don’t try pressing Cmd+R to run your app just yet – it will probably run the iPhone app, which isn’t what you want. Instead, go to the Product menu and choose Scheme then your WatchKit app. When you press Cmd+R now you’ll see both the iOS and watchOS simulators launch, and after a few seconds your watchOS app will appear.

Despite all our UI work, this app won’t really do very much – the pickers are all empty. However, you can at least tap the Convert button to see the second screen slide in!

When you’re ready, go to View > Standard Editor > Show Standard Editor, then select InterfaceController.swift – it’s time for the fun part!

Adding some conversions

It takes just four methods and four properties to make this whole screen work. so let’s not hang around.

First, the most important property: we need to store a list of the conversions to show to the user. There are lots of conversions supported by Foundation, but rather than overwhelm you with options I’ve just picked out the most important.

So, this property needs to store the human-readable name of the conversions – “Distance” or “Temperature”, for example – along with an array of the various measurements inside each conversion group, all of which descend from the Dimension class. You might think that could be represented using a dictionary like this:

let conversions = [String: [Dimension]]

That is, a dictionary where names of units are the keys (“Distance”, “Temperature”, etc), and an array of their associated dimensions are their values.

The problem with that approach is that dictionaries are unordered, so you’re all but guaranteed to get your conversion names come out in a different order than you put in.

A better solution is to use either an array of tuples or an array of a custom struct. In this case an array of tuples is nice and easy, because we can just add them to an array like this:

(title: "Duration", units: [UnitDuration.hours, UnitDuration.minutes, UnitDuration.seconds]),

That will store the human-readable name “Duration” along with three Foundation dimensions for measuring durations: hours, minutes, and seconds.

Go ahead and add this property to your InterfaceController class now:

let conversions = [
    (title: "Distance", units: [UnitLength.astronomicalUnits, UnitLength.centimeters, UnitLength.feet, UnitLength.inches, UnitLength.kilometers, UnitLength.lightyears, UnitLength.meters, UnitLength.miles, UnitLength.millimeters, UnitLength.parsecs, UnitLength.yards]),
    (title: "Duration", units: [UnitDuration.hours, UnitDuration.minutes, UnitDuration.seconds]),
    (title: "Mass", units: [UnitMass.grams, UnitMass.kilograms, UnitMass.ounces, UnitMass.pounds, UnitMass.stones, UnitMass.metricTons]),
    (title: "Temperature", units: [UnitTemperature.celsius, UnitTemperature.fahrenheit, UnitTemperature.kelvin]),
    (title: "Volume", units: [UnitVolume.bushels, UnitVolume.cubicFeet, UnitVolume.cups, UnitVolume.fluidOunces, UnitVolume.gallons, UnitVolume.liters, UnitVolume.milliliters, UnitVolume.pints, UnitVolume.quarts, UnitVolume.tablespoons, UnitVolume.teaspoons]),
]

Note: If you're using Swift 4.1 or earlier the type inference isn't quite as good as 4.2 or later. To help Swift along and avoid force casting elsewhere, change the first line of the conversions definition to this:

let conversions: [(title: String, units: [Dimension])] = [

That creates an array of four different measurement types, each with a variety of dimensions appropriate to it.

Properties two, three, and four will all hold values from our user interface: the indexes of the selected conversion type, the from unit, and the to unit respectively. On iOS this would be redundant because you can read values from UI components whenever you want to, but in watchOS you can’t – WatchKit will tell you when a value changes, but you need to squirrel that away into a variable yourself otherwise it’s lost.

So, please add these three extra properties to InterfaceController:

var unitIndex = 0
var fromIndex = 0
var toIndex = 1

The next step is to fill in the awake() method so that it fills up the top picker with the four measurement names from our conversions array – this method is analogous to viewDidLoad() in iOS and macOS. Pickers always used a fixed area of WKPickerItem objects in watchOS, each with their own title.

To make this work we need to:

  1. Create a new, empty array of WKPickerItem objects.
  2. Loop over each item in the conversions array.
  3. Create a new WKPickerItem from each conversion title, appending it to our array.
  4. Call unitType.setItems() and pass in that array.

As a bonus, we’re also going to call the unitChanged() action we made from IB. This will be called whenever the conversions picker has changed, and it’s our opportunity to reload the other two pickers so they match whatever unit was just selected. We’re also going to call this method from awake() because it will populate the pickers with the correct options for our app’s initial state.

Replacing your existing awake() method with this:

override func awake(withContext context: Any?) {
    super.awake(withContext: context)

    // create an empty of array of picker items
    var items = [WKPickerItem]()

    // loop over each conversion and make it into a picker item in the array
    for conversion in conversions {
        let item = WKPickerItem()
        item.title = conversion.title
        items.append(item)
    }

    // assign that array to our picker
    unitType.setItems(items)

    // call unitChanged() so the other pickers can be updated to match
    unitChanged(0)
}

Again, try running your app to make sure it works OK – you should now see the first picker filled with four measurement types to choose from.

If you haven’t used the watchOS simulator before, try scrolling up and down using the digital crown. If you’re using a trackpad, this is usually done by hovering your mouse over the virtual crown then using a two-finger scroll to move up and down. Don’t try to click your mouse button in otherwise you’ll activate Siri!

Filling in the units

The next step is to populate the other two pickers with the correct dimensions for whichever measurement types was just chosen. The unitChanged() method will get triggered whenever the top picker changes, but we’re also calling it from awake() so that our initial state is correct.

The method needs to:

  1. Update our unitIndex property to whatever new value was chosen. We’ll be referring to this later.
  2. Find whichever conversion was selected in our conversions array.
  3. Loop over all the units in that conversion, converting each one to a WKPickerItem with its title, and adding it to the two lower pickers.

There are two small complications.

First, measurements are complicated – we all write them differently, not least because of varying languages. To avoid hassle here, we’re going to use Apple’s MeasurementFormatter: give it any unit you want, and it will send you back the localized name for it. It has a useful unitStyle property that lets you specify whether you want short, medium, or long names, but for our limited space I think medium works best.

Second, pickers will automatically choose their first item as their default selection. Sometimes that’s OK, but because we’re loading the same list of units into each picker it looks a bit silly – it will try to convert Hours to Hours, or Grams to Grams, for example.

To fix this, we’re going to pre-select the first item in fromUnit and the second item in toUnit. The user will still want to select other things, but at least it’s more interesting by default.

That’s all you need to know, so please go ahead and replace your unitChanged() method with this:

@IBAction func unitChanged(_ value: Int) {
    // store the selected conversion index for later
    unitIndex = value

    // pull out whichever conversion was selected
    let conversion = conversions[value]

    // prepare a measurement formatter that we can use with each unit
    let formatter = MeasurementFormatter()
    formatter.unitStyle = .medium

    // repeat the same code for both pickers
    for picker in [fromUnit, toUnit] {
        // create a new empty array of items
        var items = [WKPickerItem]()

        // repeat the same code for both pickers
        for unit in conversion.units {
            // convert it into a picker item, using the formatted name for this, e.g. "kilograms"
            let item = WKPickerItem()
            item.title = formatter.string(from: unit)

            // add it to our list of items
            items.append(item)
        }

        // update the picker with its items
        picker?.setItems(items)
    }

    // select the first and second item in the from and to pickers respectively
    fromUnit.setSelectedItemIndex(0)
    toUnit.setSelectedItemIndex(1)
}

Our app is still only about half way done, but it is at least coming together now – if you press Cmd+R again you should see both lower pickers full with units that change when you adjust the top picker.

Providing some context

We’ve already written unitChanged(), but there are still three more methods we need to write before we can show the results screen – fortunately, all three are trivial.

First, when fromUnitChanged() is called we need to store its new value in the fromIndex property. So, please change fromUnitChanged() to this:

@IBAction func fromUnitChanged(_ value: Int) {
    fromIndex = value
}

We need to do something similar for toUnitChanged(), so please change it to this:

@IBAction func toUnitChanged(_ value: Int) {
    toIndex = value
}

The last method is an important one: when the user taps the Convert button a segue moves them from the original screen to the second screen. The question is, what data do we want to pass along with that segue?

WatchKit lets us pass arbitrary objects between screens, or perhaps nothing at all. In Swift this is represented using Any? – anything at all, or maybe nothing – so we’re going to pass a dictionary of whatever data the user chose.

When the segue is triggered, WatchKit will automatically call a method called contextForSegue() on our interface controller. There’s a default implementation that sends no data, but we need to override it to provide both the “from” and “to” units the user chose.

So, please add this method to your InterfaceController class now:

override func contextForSegue(withIdentifier segueIdentifier: String) -> Any? {
    let conversion = conversions[unitIndex]
    let from = conversion.units[fromIndex]
    let to = conversion.units[toIndex]

    return ["from": from, "to": to]
}

You can try running the app again if you want, but it should look identical – we’re passing data now, but that all happens behind the scenes.

Still, on the up side at least that’s our first screen completed!

Calculating the results

At last we’re onto the important part: reading the user’s input text and selected units, then calculating the conversion. This takes quite a bit of code, but relax – you’ll find it a walk in the park!

We just wrote some code to send our app’s context from the first screen to the second, so our task now is to look for and unpack that context into properties.

So, select ResultInterfaceController.swift and add these three properties there:

var from: Dimension!
var to: Dimension!
var inputValue = 50.0

The first two will store the “from” and “to” units selected in the previous screen, and the third will store whatever number we’re actually trying to convert.

Next we need to implement the awake() method so that it reads the context that was passed in, pulling out both the “from” and “to” values from the dictionary. We can provide sensible defaults for the units if they are missing (which they really ought not to be!), but if the context itself is missing it means something is catastrophically wrong so we’ll call fatalError() to force the app to quit.

Replace the awake() method ResultInterfaceController with this:

override func awake(withContext context: Any?) {
    super.awake(withContext: context)

    guard let context = context as? [String: Dimension] else {
        fatalError("Bad context provided.")
    }

    from = context["from"] ?? UnitLength.centimeters
    to = context["to"] ?? UnitLength.meters
}

Earlier we added an inputChanged() method for when the slider’s value is changed, but we’re not going to add our calculation work there – you’ll see why soon.

Instead, we’re going to create a separate updateResult() method that will run the necessary calculation and update both amountLabel and resultLabel with their correct text.

This takes three steps.

First, we’ll create an instance of Measurement. This takes a unit, e.g. centimeters, and a value, e.g. 50, meaning that the whole thing represents 50 centimeters. We can then use that to convert to any other dimension as long as it’s the same type – as smart as Foundation is, it doesn’t have a way to convert kilometers to Celsius!

Second, we’ll convert the resulting measurement into a formatted string. This will use MeasurementFormatter again, but this time with an extra setting: we’re going to set its unitOptions property to .providedUnit.

One of the many clever things that Foundation does for us is automatically adapt itself to the user. It does this transparently: their language gets used by default, their currency gets used by default, and so on.

In the case of measurement formatting, the user’s preferred measurement is used by default. If you convert from inches to centimeters then your finished output is some number in centimeters. However, if you try to format that then Foundation might realize it has a user that prefers to see inches, and silently convert the value back to inches on our behalf.

This is really clever, and to be honest it’s also really useful – in some places. But not here – it kind of defies the point of our app! By setting the measurement formatter’s unitOptions parameter to .providedUnit we’re explicitly forcing Foundation to format our measurements in the way we want rather than the way our user’s system configuration wants.

Third, as well as creating a formatted string for the result, we’re also going to create a formatted string for the amount label so that both read naturally – it might say “50cm” in the top and “500mm” in the bottom, for example.

Add this method to ResultInterfaceController now:

func updateResult() {
    // create a measurement from the user's input
    let input = Measurement(value: round(inputValue), unit: from)

    // convert it to our target unit
    let output = input.converted(to: to)

    // prepare a formatter for both values
    let formatter = MeasurementFormatter()
    formatter.unitOptions = .providedUnit

    // set the labels using that formatter
    amountLabel.setText(formatter.string(from: input))
    resultLabel.setText(formatter.string(from: output))
}

We need to call that method every time the slider is changed, but we’re also going to call it from awake() – this is why it’s a separate method.

First, change your implementation of inputChanged() so that it stores the new slider value in inputValue and calls updateResult(), like this:

@IBAction func inputChanged(_ value: Float) {
    inputValue = Double(value)
    updateResult()
}

Now add this line to the end of your awake() method so that our result screen looks good when it first loads:

updateResult()

One more thing…

At this point the app could easily be finished – you can select any conversion type you want, choose your units, then convert between them freely.

However, before we’re done I’d like to add one last thing: let’s allow users to use their digital crown to adjust the conversion amounts in custom increments. This lets them make fine-grained conversions alongside the 10-step changes of the slider, which is much nicer.

Using the digital crown doesn’t take much. First, add these two lines to the willActivate() method of ResultInterfaceController:

crownSequencer.focus()
crownSequencer.delegate = self

The willActivate() method is called just the screen is about to be shown, and those lines request that crown messages be sent to our current class for processing. Xcode will throw up an error because we haven’t made ResultInterfaceController conform to WKCrownDelegate, but that only takes two steps.

First, change the class definition to this:

class ResultsInterfaceController: WKInterfaceController, WKCrownDelegate {

Second, add this method:

func crownDidRotate(_ crownSequencer: WKCrownSequencer?, rotationalDelta: Double) {
    inputValue += rotationalDelta
    inputValue = max(0, min(inputValue, 100))
    amountSlider.setValue(Float(inputValue))
    updateResult()
}

That adds the rotational delta (how much the crown was turned) to our current inputValue, then clamps it to the range 0 through 100 so that it matches the slider. It then updates then slider with that new value, and calls our updateResult() method so the user interface responds.

And that’s it – go ahead and run the app one last time, and you should find you can select any conversion you like, then try out a range of values using either the slider or the digital crown.

Good job!

Where next?

At its core this is a trivial app that converts one type of data to another. It’s made a bit more fun because I included some unusual units – bushels, parsecs, and so on – but I hope you’ll agree that Foundation is doing most of the hard work for us.

While building the app you’ve met some core WatchKit components: interface controllers, pickers, labels, groups, sliders, and buttons. Plus you had your first experience with doing user interface layout with watchOS – I hope you’ll agree it’s pleasantly simple compared to Auto Layout!

If you enjoyed this tutorial, I have good news for you: I wrote a whole book on building apps for watchOS, and it’s fully updated for the latest version of Swift. It’s called Hacking with watchOS, and includes 12 massive projects that teach a wide range of WatchKit components and techniques – there’s no faster or easier way to learn watchOS programming.

Click here more information about Hacking with watchOS – I think you’ll be amazed how fast you can pick it up!

Hacking with Swift is sponsored by RevenueCat

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

BUY OUR BOOKS
Buy Pro Swift Buy Pro SwiftUI Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Beyond Code

Was this page useful? Let us know!

Average rating: 4.5/5

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.