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

Build a unit converter for tvOS

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

Paul Hudson       @twostraws

Part 1 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

If you can build iOS apps, you can build tvOS apps too. That’s not an exaggeration: not only does Swift provide a shared language, but the two platforms also share a huge range of frameworks – Core Graphics, Core Image, Core Animation, MapKit, WebKit, Auto Layout, and more all work almost identically on both iOS and macOS.

As for their user interface, the two aren’t identical but are certainly hugely similar. At their core, both tvOS and iOS use UIKit, but tvOS is missing a handful of components that don’t work well using the Apple Remote – UISwitch, UIPickerView, UISlider, and UIStepper are absent, for example.

In their place, tvOS has the focus engine: a unique way of letting users navigate around using indirect touches on their remote. For the most part this does The Right Thing, but if you develop more complex apps you’ll need to learn more about guiding it.

I already wrote a whole book teaching how to make tvOS 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 tvOS 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 tvOS 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 tutorials – “Build a unit converter for macOS” and “Build a unit converter for watchOS” – because here we’re making exactly the same app for tvOS. I’ve done this specifically so you can see how the platforms solve the same problems in different ways, so if you missed the earlier article you might want to consider bookmarking it for later.

Go ahead and launch Xcode, then create a new tvOS project using the Single View App template. Give your project a name – iConvert? Convertatron? Convertr? – then make sure all three checkboxes are unchecked 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, tvOS apps can be designed using storyboards. They work a little differently, though, because tvOS apps only come in one shape: 1920x1080 points, giving a widescreen 16:9 aspect ratio. This means you don’t need to worry about Auto Layout a lot of the time because you can just position things where you want them.

For this user interface we need a segmented control, two table views, a text field, and a couple of labels. Of those, the only components that need Auto Layout rules are the segmented control and one of the labels – everything else can be positioned precisely and left alone.

Start by dragging out a segmented control, using the size inspector to position it at X:783 Y:120. This will store the list of conversion types we’re offering the user: distance, duration, mass, temperature, and volume. It will have two segments by default, but we’re not going to change that here – we’re going to load the segments in code instead.

Below the segmented control is where we’ll put our conversion options. With the full power of UIKit there would be a few ways we could lay this out, but things like UIPickerView aren’t available in tvOS because they can’t be controlled from the remote – the picker would just keep looping rather than moving focus to the next element.

So, instead we’re going to use two table views: one for the “convert from” units and one for the “convert to” units. Drag out two table views, making them both 500 points wide by 450 high, then position the first at X:422 Y:236 and the second at X:999 Y:236.

Before we move on, we need to change the table views just a little. Do this for both the left and right table view:

  1. Change its Prototype Cells value to 1.
  2. Select the cell you just added, and give it the reuse identifier “Cell”.
  3. Change its Style attribute to Basic.
  4. Uncheck its Clips to Bounds box.

That last step might seem odd, but it allows tvOS to draw the cell slightly larger when focus moves over it – you get a zoomed effect with a drop shadow behind it.

Next we need a text field where the user can enter in the amount they want to convert. To distinguish this from the output value we’re going to add a label in front of it saying “Amount”. So, place a label on the canvas, give it the text “AMOUNT:”, then position it at X:663 Y:770. Next to it, place a text field at X:866, Y:770, and width:392. We want users only to enter numbers here, so please give it the default text of 100 and change its keyboard type attribute to Number Pad.

Finally, we need one last label to show users the results of our conversion. So, drag another label to X:878, y:874, then give it the text “1000” – that’s just a placeholder so we can see how it looks. Given that this is the most important thing on the screen, I’d like you to increase the font size to something nice and big such as System size 72.

That’s all the basic layout done, but we need to add four Auto Layout rules to keep things positioned appropriately:

  • Ctrl-drag from the segmented control to the view above it, then choose Center Horizontally In Safe Area.
  • Ctrl-drag from the segmented control to the view above it, then choose Top Space To Safe Area.
  • Ctrl-drag from the bottom label to the view below it, then choose Center Horizontally In Safe Area.
  • Ctrl-drag from the bottom label to the view below it, then choose Bottom Space To Safe Area.

We can ignore the rest of the components because they are positioned just fine – we only added the four above because the segmented control and bottom label will change width at runtime, so it’s nice to keep them positioned.

The last user interface step is to make some outlets and actions, so go to the View menu and choose Assistant Editor > Show Assistant Editor.

Please create the following outlets:

  • Name the segmented control unitType.
  • Name the left-hand table view fromUnit.
  • Name the right-hand table view toUnit.
  • Name the text field amount.
  • Name the large label at the bottom result.

We also need to create two actions: one from the segmented control so that we can track when it’s adjusted – name that one unitChanged – then one from the text field so we can react to the user entering a new value – name that one amountChanged.

Finally, Ctrl-drag from the left-hand table view up to your view controller in the document outline, choosing “delegate” then “data source” from the list of actions, then repeat that for the right-hand table view. This allows us to respond to the user selecting various units.

We’re done with Interface Builder, so try pressing Cmd+R to run your app. If everything has gone to plan your app should crash immediately, because we haven’t written the code for our view controller to act as a table view data source. Don’t worry – we’re fixing that next.

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

Adding some conversions

Now that we have a user interface, our first task is to make it work – or at least not crash.

The first step towards that goal is to create a property to store a list of the conversions we’ll 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 ViewController 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.

We also need to add two properties to track which row in both tables was selected by the user. The tvOS focus engine moves the user’s selection as they swipe around, so to make things clearer we’re going to mark their selected units using a checkbox.

Add these two properties now, to store the checkbox location in each table view:

var selectedFromUnit = 0
var selectedToUnit = 1

The next step is to fill in viewDidLoad() so that it fills up the segmented control with the four measurement names from our conversions array. We made it empty in the storyboard because this the kind of thing that’s more flexible in code.

To make this work we need to:

  1. Remove the two existing segments that were created in the storyboard. IB doesn’t let us have fewer than two, sadly.
  2. Insert a new segmented for each item in our conversions array.
  3. Select the first segment in the control, segment 0.

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

Replace your existing viewDidLoad() with this:

override func viewDidLoad() {
    super.viewDidLoad()

    unitType.removeAllSegments()

    for (index, conversion) in conversions.enumerated() {
        unitType.insertSegment(withTitle: conversion.title, at: index, animated: false)
    }

    unitType.selectedSegmentIndex = 0
    unitChanged(self)
}

You can try running your app again if you want, but there isn’t much point yet – it will still crash!

Filling in the units

The next step is to populate the table views with the correct dimensions for whichever measurement types was just chosen. The unitChanged() method will get triggered whenever the segmented control changes, but we’re also calling it from viewDidLoad() so that our initial state is correct.

Whenever the units are changed, whenever any of the table view cells are chosen, or whenever the user types into their text field, we need to recalculate our results and update the results label. We’ll be writing the code for that later, but for now we do at least need an empty method we can call.

So, please add this below amountChanged():

func updateResult() {
}

OK, time for the unitChanged() method. This needs to:

  1. Reset the selectedFromUnit and selectedToUnit properties to 0 and 1, because our conversions have different numbers of units and we don’t want out of bounds errors.
  2. Ask both table views to reload their data.
  3. Call updateResult() so that our calculate label is updated with all the changes.

Please go ahead and replace your unitChanged() method with this:

@IBAction func unitChanged(_ sender: Any) {
    selectedFromUnit = 0
    selectedToUnit = 1
    fromUnit.reloadData()
    toUnit.reloadData()
    updateResult()
}

Of course, all the real work happens when the tables are being loaded. This will take three methods to complete: one to return how many rows should be in the tables, one to provide some header text at the start of each table so the user knows which is which, and one to provide the content for each row.

We used Interface Builder to mark our view controller as being the data source and delegate for both our tables, so before we write any methods we need to add mark our view controller as conforming to the two relevant protocols: UITableViewDataSource and UITableViewDelegate.

Modify the class definition for ViewController to this:

class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {

Now for our three methods. The first of those is easy enough: we’ll pull out one conversion from our conversions array based on whatever was selected in the segmented control, then return however many units it has. Please add this method below updateResult():

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    let conversion = conversions[unitType.selectedSegmentIndex]
    return conversion.units.count
}

The second is also nice and easy, because it will return either “Convert From” or “Convert To” based on whether it was called for the left-hand or right-hand table view. Add this below the previous code:

func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
    if tableView == fromUnit {
        return "Convert from"
    } else {
        return "Convert To"
    }
}

The third takes more thinking, because 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 table views I think long works best.

As well as formatting each unit, this third method needs to dequeue and configure a table view cell, which works just like you’d expect on iOS. However, we’re also going to add some logic to make sure one row is always checked in each table: if it’s the left-hand table then we’ll check selectedFromUnit, and if it’s the right-hand table then we’ll check selectedToUnit.

Add this third method now:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    // prepare to format measurements in the long style
    let formatter = MeasurementFormatter()
    formatter.unitStyle = .long

    // load a cell ready for display
    let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)

    // find the correct conversion by examining our segmented control
    let conversion = conversions[unitType.selectedSegmentIndex]

    // find the appropriate unit for this cell (both tables are the same)
    let unit = conversion.units[indexPath.row]

    // give our cell the formatted name for this unit, with a capital at the start to look tidy
    cell.textLabel?.text = formatter.string(from: unit).capitalized

    // make sure one cell from each table view is selected
    if tableView == fromUnit {
        if indexPath.row == selectedFromUnit {
            cell.accessoryType = .checkmark
        } else {
            cell.accessoryType = .none
        }
    } else if tableView == toUnit {
        if indexPath.row == selectedToUnit {
            cell.accessoryType = .checkmark
        } else {
            cell.accessoryType = .none
        }
    }

    return cell
}

At this point our app still won’t work, but at least it won’t crash now. If you press Cmd+R again you should now see both table views full with units that change when you adjust the segmented control.

You might notice that the checkboxes don’t move when you select other cells, but we can fix that by adding one more table view method: didSelectRowAt. This needs to update either selectedFromUnit or selectedToUnit depending on what table view triggered the method, then reload the table and call updateResult(). Reloading the table is the easiest way to make sure the previous checkbox disappears while the new one is shown.

Add this last table view method:

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    if tableView == fromUnit {
        selectedFromUnit = indexPath.row
    } else {
        selectedToUnit = indexPath.row
    }

    tableView.reloadData()
    updateResult()
}

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’re going to tackle this piece by piece so I can explain as you add code. To begin with, we need to figure out what value the user entered. We have a text field, amount, for this purpose, but there could be anything in there. We need to convert it to a Double as safely as possible, so let’s start by adding this line of code to updateResult():

let input = Double(amount.text ?? "") ?? 0.0

That pulls out the text in amount or an empty string, then attempts to convert it to a Double. If that fails, we’ll use 0.0 instead.

Second, we need to figure out what conversion to apply. We can read their conversion type from the segmented control, and the from and to units using selectedFromUnit and selectedToUnit, so we can go ahead and turn those into actual dimensions with three more lines of code – well, six if you include comments. Add this to updateResult() now:

// pull out the conversion they selected
let conversion = conversions[unitType.selectedSegmentIndex]

// read the "from" unit that was chosen
let from = conversion.units[selectedFromUnit]

// read the "to" unit that was chosen
let to = conversion.units[selectedToUnit]

Third, we’re going to 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!

So, please add these two lines to updateResult() now:

let inputMeasurement = Measurement(value: input, unit: from)
let output = inputMeasurement.converted(to: to)

Finally, we need to 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.

Once we have a value, we can put it into our result label by setting its text property.

Go ahead and add these final three lines to updateResult():

let formatter = MeasurementFormatter()
formatter.unitOptions = .providedUnit
result.text = formatter.string(from: output)

Before you run the app: I’m sure you’re keen to check out your progress, but wait just a minute!

To complete this app we need to write only a tiny amount more code to bring the whole thing to life.

Way back when were we designing our UI in Interface Builder, we added an amountChanged() method that gets triggered when the user edits the text field. We haven’t put anything in there yet so nothing will happen, but all it needs to do is call updateResult() so that a new calculation is performed every time some new value is entered.

So, modify your existing amountChanged() method to this:

@IBAction func amountChanged(_ sender: Any) {
    updateResult()
}

And now we’re done – press Cmd+R to try out your finished app!

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 I hope you’ve seen just how easy tvOS is for iOS developers – so much of UIKit is shared, along with all of Swift, all of Foundation, and a lot more. Plus there’s much less need to worry about Auto Layout, because the fixed screen size renders that moot a lot of the time.

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

Click here more information about Hacking with tvOS – 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: 5.0/5

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.