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

Build a unit converter for macOS

Use AppKit 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 macOS apps too. 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.

Of course, where the two differ is in their user interface frameworks: where iOS uses UIKit, macOS uses AppKit. This is a significantly older framework that has a number of quirks and curiosities that can sometimes trip up folks moving over from iOS, but for several years now Apple has been working to modernize it.

In short, even though AppKit is still quite different from UIKit, you’re likely to find just as many similarities than differences – there really has never been a better time to make a macOS app.

I already wrote a whole book teaching how to make macOS 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 macOS 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 macOS 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!

Go ahead and launch Xcode, then create a new macOS project using the Cocoa App template. Give your project a name – iConvert? Convertatron? Convertr? – then make sure the only checkbox that’s checked is “Use Storyboards” before clicking Next then Create.

Hacking with Swift is sponsored by Essential Developer

SPONSORED Join a FREE crash course for mid/senior iOS devs who want to achieve an expert level of technical and practical skills – it’s the fast track to being a complete senior developer! Hurry up because it'll be available only until April 28th.

Click to save your free spot now

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 macOS apps have windows, menu bar, Touch Bar buttons, and more – things that simply don’t exist on iOS.

If you open Main.storyboard now you’ll see what I mean:

  • At the top of your canvas is “Main Menu”, containing the menu bar for your application.
  • Below that you’ll see “Window Controller”, containing the window for our app.
  • Below that you’ll see “View Controller”, which is what’s shown in side the window controller.

Note: Because the window controller contains the view controller, you’ll see “View Controller” written in large letters inside the window controller. This is confusing, I agree – the view controller itself is the empty thing at the bottom of your storyboard canvas!

We’re going to leave the top two alone for now and focus on the view controller – that’s where most of our user interface work will be done. This view controller is an instance of NSViewController, which works very similarly to UIViewController on iOS.

Start by dragging a segmented control near the top of the empty view controller canvas. It will have 3 segments by default, but I’d like you to change that to 1 – just select the segmented control, then look for Segments inside the attributes inspector.

By default macOS lets you specify precise sizes for your segment widths, but in this case we’re going to use flexible sizes so they automatically take up as much space as needed. To make that happen, select the size inspector then uncheck the Fixed box.

Below the segmented control is where we’ll put our conversion options. There are several ways you could lay this out, but the easiest is using NSStackView – the macOS equivalent of UIStackView. This works more or less like UIStackView, except it has the ability to start hiding items if space is running out. Stack views can take care of sizing our individual elements for us, which is much easier than doing it by hand.

So, go ahead and drag out a horizontal stack view below the segmented control. Now add the following things to it: a text field, a pop up button, a label, and another pop up button.

Text fields and labels are more or less like their iOS equivalents, but pop up button is entirely new: it shows a selected item, and when the user clicks on it shows a menu with alternatives to choose from.

The final user interface element is one more label, placed below the stack view. This will show our resulting conversion.

That’s all the basic layout done, but without any Auto Layout constraints it all looks a bit messy. By default, macOS users can resize their windows freely, so adding smart Auto Layout constraints really is a must!

Note: Adding constraints in macOS is usually easier using the document outline – that little sidebar in Interface Builder that shows you all your user interface elements.

  • Ctrl-drag from Round Segmented Control up to View, then hold down Shift and choose Leading Space to Container, Trailing Space to Container, Top Space to Container, and Center Horizontally in Container, then choose Add Constraints. (Ignore the red lines in Interface Builder – we’ll fix them soon!)
  • Ctrl-drag from Stack View up to View, then hold down Shift and choose Leading Space to Container and Trailing Space to Container, then choose Add Constraints.
  • Ctrl-drag from Stack View up to Round Segmented Control and choose Vertical Spacing.
  • Ctrl-drag from Label (the one below the stack view) to View, then hold down Shift and choose Bottom Space to Container and Center Horizontally in Container, then click Add Constraints.
  • Ctrl-drag from Label (the same one again) up to Stack View, then choose Vertical Spacing.

Those are the basic constraints, but Interface Builder isn’t happy – you should see a red arrow in your document outline.

To fix these we need to make a few small changes to our constraints:

  • Make the Leading and Trailing constraints for the segmented control into “>= 20”. The easiest way to do that is to select the segmented control, go to the size inspector, then click Edit next to each constraint. It will have “Constant: = 200” or some other number, so please change that to be “>= 20” for both leading and trailing.
  • Change Top Space for the segmented control to be 20.
  • Now select Stack View, and make all four of its constraints have the constant 20 so we get nice and equal spacing.
  • Select the label at the bottom, and make its Bottom Space constraint have the constant 20.
  • Select the text field, Ctrl-drag from itself to itself, then choose Width to give it a fixed-width constraint. Use the size inspector to make this a >= constraint with the constant of 100 – this will stop the text field being shrunk down to nothing.

When you make that last change, you’ll see the label jump to the bottom of your view controller, leaving a large space above it. This happens because Auto Layout is trying to make all your rules work together, and the simplest way to do that is to make the stack view bigger. We don’t want that, though – we want the window to shrink down so that it always fits its contents.

To make that happen, select the stack view in your document outline then change Vertical Hugging Priority to 1000. Stack views have two different hugging priorities, but you’re looking for the one directly above the Visibility Priorities sliders.

Making the vertical hugging priority equal to 1000 makes it more important for the stack view to stick tightly to its contents than for the window to resize freely, so you should see the window snap to fit its contents. We’ve let it resize horizontally, though – that part can be flexible.

Before we add any constraints, there are three more tweaks to make:

  1. Select the text field and give it the text “0” – that will be our default value when the app runs.
  2. Select the label inside the stack view and change its text to be “in”. This means that whole line of UI components can be read something like “36 centimeters in inches”.
  3. Select the label below the stack view and increase its font size. This is the result of our calculation, so try bumping up the font size to about 28 points and see what you think.

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.

Warning: Xcode has a bad habit of selecting the wrong file in the assistant editor. If you find yourself staring at source code for NSApplication.h or anything else that isn’t ViewController.swift, click the right chevron in the assistant editor divider – you should see < 3 > or similar, so just click > until you see ViewController.swift.

Please create the following outlets:

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

Tip: You might notice that Xcode assigns the result label the type NSTextField – this isn’t a mistake! Labels are actually borderless, uneditable text fields in macOS, which is how apps like Finder achieve the “double-click to edit a label” effect.

You also need a create an action from the segmented control so that we can track when it’s adjusted – name that one unitChanged. Finally, Ctrl-drag from the text field up to your view controller in the document outline, choosing “delegate” from the list of actions. This allows us to respond to the user entering text.

We’re done with Interface Builder, so try pressing Cmd+R to run your app. It won’t do much yet, but should at least be able to resize the window (horizontally only!) and try out the pop up buttons.

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

It takes just four methods and one property to make this whole app work, largely thanks to Apple’s Foundation framework doing so much of the work for us.

First, the 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 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.

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. Increase the number of segments to match however many conversion types we have.
  2. Set the label for each segment to match the name 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 menus inside our pop up buttons so they match whatever unit was just selected. We’re also going to call this method from viewDidLoad() because it will populate the menus with the correct options for our app’s initial state.

Replace your existing viewDidLoad() with this:

override func viewDidLoad() {
    super.viewDidLoad()

    // make sure we have the correct number of segments
    unitType.segmentCount = conversions.count

    // give each segment the correct title from our conversions array
    for (index, conversion) in conversions.enumerated() {
        unitType.setLabel(conversion.title, forSegment: index)
    }

    // select whichever conversion is first in the list
    unitType.selectedSegment = 0

    // call unitChanged() so the pop up buttons have their correct values
    unitChanged(self)
}

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

Filling in the units

The next step is to populate the pop up buttons 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 pop up button menu items 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 unitChanged():

@objc func updateResult() {
}

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

  1. Make sure that our segmented control has a valid selection. AppKit will report -1 as the selected segment if nothing has been chosen.
  2. Remove all items from the fromUnit and toUnit pop up buttons.
  3. Find whichever conversion was selected in our conversions array.
  4. Loop over all the units in that conversion, converting each one to an NSMenuItem with its title, and adding it to the pop up buttons.
  5. Call updateResult() so that our calculate label is updated with all the changes.

There are three 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 pop up buttons I think long works best.

Second, each of those NSMenuItem objects needs a method to call when it is selected. We already wrote an empty updateResult() method just for this purpose, but you might have wondered why I marked it as @objc. Well, now you know: this method will be called from each NSMenuItem, and because AppKit is written in Objective-C we must mark the method @objc.

Finally, pop up buttons 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 pop up button 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(_ sender: Any) {
    // make sure we have a valid unit type selected
    guard unitType.selectedSegment != -1 else { return }

    // remove all existing menu items from the two pop up buttons
    fromUnit.menu?.removeAllItems()
    toUnit.menu?.removeAllItems()

    // pull out whichever conversion was selected
    let conversion = conversions[unitType.selectedSegment]

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

    // repeat the same code for both pop up buttons
    for button in [fromUnit, toUnit] {
        // loop over all units in our selected conversion
        for unit in conversion.units {
            // get the formatted name for this, e.g. "kilograms"
            let unitName = formatter.string(from: unit)

            // create an NSMenuItem from that title, pointing it at updateResult()
            let item = NSMenuItem(title: unitName, action: #selector(updateResult), keyEquivalent: "")

            // add that menu item to the current button
            button?.menu?.addItem(item)
        }
    }

    // select the first and second item in the from and to buttons respectively
    fromUnit.selectItem(at: 0)
    toUnit.selectItem(at: 1)

    // update the result label
    updateResult()
}

Our app still won’t work, but we’re getting close now – if you press Cmd+R again you should now see both pop up buttons full with units that change when you adjust the segmented control.

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!

First, we need to make sure we have valid input. This means that unitType, fromUnit, and toUnit all need to have selections otherwise we should exit the method. AppKit uses -1 for “no selection” for each of those, so please start by adding this code inside updateResult():

// exit if we are missing important selections
guard unitType.selectedSegment != -1 else { return }
guard fromUnit.indexOfSelectedItem != -1 else { return }
guard toUnit.indexOfSelectedItem != -1 else { return }

Second, we need to read whatever text the user entered into the amount text field. AppKit lets us read this value as a string if we want, but it has other ways too – we’re actually going to read it as a Double, which takes care of converting their text to a Double or using 0 if they entered something invalid.

Please add this line to updateResult():

let input = amount.doubleValue

Third, we need to figure out what conversion to apply. We already know they selected values from unitType, fromUnit, and toUnit, 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.selectedSegment]

// read the "from" unit from its pop up button   
let from = conversion.units[fromUnit.indexOfSelectedItem]

// read the "to" unit from its pop up button
let to = conversion.units[toUnit.indexOfSelectedItem]

Fourth, 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 stringValue property.

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

let formatter = MeasurementFormatter()
formatter.unitOptions = .providedUnit
result.stringValue = 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, I asked you to make our view controller the delegate for the user’s text entry field so that we can monitor the user typing.

As a result of that change, every time the user enters some text into the text field a method called controlTextDidChange() will be called on our view controller. We haven’t implemented that 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, add this final method below updateResult():

override func controlTextDidChange(_ obj: Notification) {
    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 you’ve met some core AppKit components: NSViewController, NSSegmentedControl, NSTextField, NSPopUpButton, and NSStackView are all used extensively in macOS, and hopefully you found them fairly straightforward to grasp.

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

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


PS: Did you spot the bug in the current program? If you close our only window, you can't get it back! Try adding this code to AppDelegate.swift to fix that:

func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
    return true
}

Much better!

Hacking with Swift is sponsored by Essential Developer

SPONSORED Join a FREE crash course for mid/senior iOS devs who want to achieve an expert level of technical and practical skills – it’s the fast track to being a complete senior developer! Hurry up because it'll be available only until April 28th.

Click to save your free spot now

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.