Foundation’s Measurement
type is an incredibly useful tool in an Apple developer’s toolkit. It’s a purpose-built type for storing, converting, and calculating the sum of physical measurements with a powerful localization system built on top of it. It’s a relatively new API that was introduced with Xcode 8 in 2016 and is supported by iOS 10+, macOS 10.12+, Mac Catalyst 13.0+, tvOS 10.0+, watchOS 3.0+.
From personal experience, there’s a hesitation to use this type in production code. I think the biggest reasons are that Apple hasn’t done a great job in selling devs on the benefits of the type, and that the powerful localization features were locked behind a clunky MeasurementFormatter
.
It may seem overkill to wrap that temperature or distance value in an addition layer of complexity, especially if your app will never be released outside of the US market. But I’d like to change your mind on that.
Let’s walk though Measurement
, how to use it, how to convert things using it, and how to localize its output. At the end I hope I’ve convinced you to use it in your code starting tomorrow.
This post is all about the Swift API for
Measurement
. Know that these are accessible in Objective-C asNSMeasurement
, and nearly everything mentioned in this post is available to you. Unfortunately theMeasurement<UnitType>.FormatStyle
is Swift-only, you’ll need to rely on theNSMeasurementFormatter
for localization.
Measurement Basics
To be a good developer in a type-safe language, you should be using the appropriate types for the data in question. Strings should be String
, Numbers should be Int
, Float
or Decimal
as needed and those measurements of physical properties should be a Measurement
.
The Measurement
type requires that you associate your measurement value with a unit. Makes sense.
struct Measurement<UnitType> where UnitType : Unit
But here we have run into the first knowledge hurdle about the API: What units are even supported?
As of Xcode 14, this is the canonical list of Dimensions 1 that are supported:
Dimension 1 | Description | Base unit |
---|---|---|
Unit Acceleration | Unit of measure for acceleration | meters per second squared (m/s²) |
Unit Angle | Unit of measure for planar angle and rotation | degrees (°) |
Unit Area | Unit of measure for area | square meters (m²) |
Unit Mass | Unit of measure for concentration of mass | grams per liter (g/L) |
Unit Dispersion | Unit of measure for dispersion | parts per million (ppm) |
Unit Duration | Unit of measure for duration of time | seconds (sec) |
Unit Charge | Unit of measure for electric charge | coulombs (C) |
Unit Current | Unit of measure for electric current | amperes (A) |
Unit Difference | Unit of measure for electric potential difference | volts (V) |
Unit Resistance | Unit of measure for electric resistance | ohms (Ω) |
Unit Energy | Unit of measure for energy | joules (J) |
Unit Frequency | Unit of measure for frequency | hertz (Hz) |
Unit Efficiency | Unit of measure for fuel efficiency | liters per 100 kilometers (L/100km) |
Unit Illuminance | Unit of measure for illuminance | lux (lx) |
Unit Storage | Unit of measure for quantities of information | bytes (b) |
Unit Length | Unit of measure for length | meters (m) |
Unit Mass | Unit of measure for mass | kilograms (kg) |
Unit Power | Unit of measure for power | watts (W) |
Unit Pressure | Unit of measure for pressure | newtons per square meter (N/m²) |
Unit Speed | Unit of measure for speed | meters per second (m/s) |
Unit Temperature | Unit of measure for temperature | kelvin (K) |
Unit Volume | Unit of measure for volume | liters (L) |
Each of these Dimensions have a number of units available. Here’s every unit for UnitLength
as an example:
Name | Method | Symbol | Coefficient |
---|---|---|---|
Megameters | megameters | Mm | 1000000.0 |
Kilometers | kilometers | kM | 1000.0 |
Hectometers | hectometers | hm | 100.0 |
Decameters | decameters | dam | 10.0 |
Meters | meters | m | 1.0 |
Decimeters | decimeters | dm | 0.1 |
Centimeters | centimeters | cm | 0.01 |
Millimeters | millimeters | mm | 0.001 |
Micrometers | micrometers | µm | 0.000001 |
Nanometers | nanometers | nm | 1e-9 |
Picometers | picometers | pm | 1e-12 |
Inches | inches | in | 0.0254 |
Feet | feet | ft | 0.3048 |
Yards | yards | yd | 0.9144 |
Miles | miles | mi | 1609.34 |
Scandinavian Miles | scandinavianMiles | smi | 10000 |
Light Years | lightyears | ly | 9.461e+15 |
Nautical Miles | nauticalMiles | NM | 1852 |
Fathoms | fathoms | ftm | 1.8288 |
Furlongs | furlongs | fur | 201.168 |
Astronomical Units | astronomicalUnits | ua | 1.496e+11 |
Parsecs | parsecs | pc | 3.086e+16 |
I’d recommend that you check out Apple’s documentation for each of the Dimensions to get a good idea of every possible unit available to you.
Creating a new measurement is as easy as setting the value and declaring it’s Dimension
and it’s unit:
let speedLimit = Measurement(value: 100, unit: UnitSpeed.kilometersPerHour)
let myHeight = Measurement(value: 190, unit: UnitLength.centimeters)
let drivingDistance = Measurement(value: 200, unit: UnitLength.kilometers)
let averageBaseballThrow = Measurement(value: 70, unit: UnitLength.feet)
let bodyTemperature = Measurement(value: 98.5, unit: UnitTemperature.fahrenheit)
let aNiceDay = Measurement(value: 25.0, unit: UnitTemperature.celsius)
// Alternatively, declare the Dimension and only set the unit.
let coldDay: Measurement<UnitTemperature> = .init(value: -30, unit: .celsius)
Once created, you can think of that value as a unit-agnostic representation of the Dimension
. While you had initialized that speed limit as kilometers per hour, you should immediately stop thinking of it in that way. This is the completely opposite of simply storing these values as an Int
, Double
, Float
, or Decimal
as you’d be needing to keep that unit in mind at all times when working with the unit.
However, it is important to know that under the hood the value is tied to the unit it’s initialized with. This only really comes into play when you’re using the MeasurementFormatStyle
to output your measurement as a nice looking string. This is detailed later on.
Calculating Sums
Once you have your unit agnostic dimensional value, you can then do math on several unit agnostic dimensional values! Units no longer matter, as long as everything shares the same Dimension
:
let myWalkingDistance = Measurement(value: 20, unit: UnitLength.fathoms)
let yourSwimmingDistance = Measurement(value: 10, unit: UnitLength.nauticalMiles)
let ourDistance = myWalkingDistance + yourSwimmingDistance // 18556.576 m
Unit Conversions
The best part about having your value stored in an agnostic fashion is that you can rely on the system to do your unit conversions for you.
To use, simply call converted(to:)
on the Measurement which will return a Measurement in that new unit.
let calgaryTemperature = Measurement(value: 9, unit: UnitTemperature.celsius)
let bostonTemperature = Measurement(value: 58, unit: UnitTemperature.fahrenheit)
let marsTemperature = Measurement(value: -112, unit: UnitTemperature.fahrenheit)
let surfaceOfTheSunTemperature = Measurement(value: 5772, unit: UnitTemperature.kelvin)
calgaryTemperature.converted(to: .celsius) // 9.0 °C
bostonTemperature.converted(to: .celsius) // 14.444444444446788 °C
marsTemperature.converted(to: .celsius) // -79.99999999999841 °C
surfaceOfTheSunTemperature.converted(to: .celsius) // 5498.85 °C
There’s also the convert(to:)
method, which will modify the internal unit of that Measurement. Again, this is used in specific cases when outputting string values.
Custom Units
// A custom one-off Unit (https://en.wikipedia.org/wiki/Smoot)
let smoots = UnitLength(symbol: "smoot", converter: UnitConverterLinear(coefficient: 1.70180))
// Extending a Dimension to include a custom Unit
extension UnitSpeed {
static let furlongPerFortnight = UnitSpeed(
symbol: "fur/ftn",
converter: UnitConverterLinear(coefficient: 201.168 / 1209600.0)
)
}
// Fully custom Dimension subclass
class CustomRadioactivityUnit: Dimension {
static let becquerel = CustomRadioactivityUnit(symbol: "Bq", UnitConverterLinear(coefficient: 1.0))
static let curie = CustomRadioactivityUnit(symbol: "Ci", UnitConverterLinear(coefficient: 3.7e10))
static let baseUnit = self.becquerel
}
Pretty Strings
TL;DR: Measurement outputs are localized, and this can cause unexpected output.
Having your data nicely represented as unit agnostic values is one thing, but showing them to your users is another thing all together.
In the past, you had to rely on the MeasurementFormatter
class to handle this job. It was clunky to work with as you had to initialize a new instance of the formatter for every different output style that you wanted to show. And similar to the DateFormatter
initializing them is expensive so you should be storing these instances in a shared location to avoid re-creating them as much as humanly possible.
That all changed with iOS 15 and FormatStyle
, which created an extremely simplified API to convert your data into pretty strings. Unfortunately, Apple’s documentation is sparse on this topic, so I created an entire site to document and show off what this system can do.
Simple FormatStyle Output
At it’s most basic, you can simply call .formatted()
onto any Measurement instance and get a nice looking string out of it:
let speedLimit = Measurement(value: 100, unit: UnitSpeed.kilometersPerHour)
let myHeight = Measurement(value: 190, unit: UnitLength.centimeters)
let drivingDistance = Measurement(value: 200, unit: UnitLength.kilometers)
let averageBaseballThrow = Measurement(value: 70, unit: UnitLength.feet)
let averageWeight = Measurement(value: 197.9, unit: UnitMass.pounds)
let bodyTemperature = Measurement(value: 98.5, unit: UnitTemperature.fahrenheit)
speedLimit.formatted() // "62 mph"
myHeight.formatted() // "6.2 ft"
drivingDistance.formatted() // "124 mi"
averageBaseballThrow.formatted() // "70 ft"
averageWeight.formatted() // "198 lb"
bodyTemperature.formatted() // "98°F"
Useful for debugging or simple output, but you almost certainly want more control of things.
To customize the output, we can access the .measurement(width:usage:numberFormatStyle)
extension on FormatStyle
and pass in different options for the three parameters.
Parameter | Accepted Type | Description |
---|---|---|
width |
Measurement<UnitType>.FormatStyle.UnitWidth |
Sets how verbose the output is |
usage (optional) |
MeasurementFormatUnitUsage<UnitType> |
Sets how the unit will be used |
numberFormatStyle (optional) |
FloatingPointFormatStyle<Double> |
Sets the format style on the number |
Explaining how each of these parameters interact with each other, and how this all ties into the Measurement<UnitType>.FormatStyle
struct that backs this whole feature means we need to discuss this system from the ground up.
Width parameter
Before we dive deeply into the details, we can cover the width
parameter quickly. There are three possible options:
Width | Description |
---|---|
.wide |
Displays the full unit description |
.abbreviated |
Displays an abbreviated unit description |
.narrow |
Displays the unit in the least number of characters |
In general, wide
will spell out the name of the unit in the most verbose way possible. .abbreviated
will output the shortened version of the unit, and .narrow
will remove any whitespace characters.
let gForce = Measurement(value: 1.0, unit: UnitAcceleration.gravity)
gForce.formatted(.measurement(width: .wide)) // "1 g-force"
gForce.formatted(.measurement(width: .narrow)) // "1G"
gForce.formatted(.measurement(width: .abbreviated)) // "1 G"
The Nitty and the Gritty
I’ve detailed how the FormatStyle
protocol works by creating one for my own custom type (the ISBN) in an earlier post. It’s a great primer on understanding how the system is implemented under the hood by Apple’s engineers.
The .formatted()
method on any Measurement instance accepts an optional parameter that conforms to the FormatStyle
protocol. The protocol itself is quite simple: You define an input type, an output type, and you get a method to do your conversion. That’s it.
We can create an instance of our format style by initializing an instance with it’s Dimension
. This is how it’s defined in the documentation:
public init(
width: Measurement<UnitType>.FormatStyle.UnitWidth,
locale: Locale = .autoupdatingCurrent,
usage: MeasurementFormatUnitUsage<UnitType> = .general,
numberFormatStyle: FloatingPointFormatStyle<Double>? = nil
)
Interestingly enough, there’s a special initializer with an extra parameter that’s available only when you’re creating a new instance with the UnitTemperature
Dimension:
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
extension Measurement.FormatStyle where UnitType == UnitTemperature {
/// Hides the scale name. For example, "90°" rather than "90°F" or "90°C" with the `narrow` unit width,
/// or "90 degrees" rather than "90 degrees celcius" or "90 degrees fahrenheit" with the `wide` width.
public var hidesScaleName: Bool
public init(
width: Measurement<UnitType>.FormatStyle.UnitWidth = .abbreviated,
locale: Locale = .autoupdatingCurrent,
usage: MeasurementFormatUnitUsage<UnitType> = .general,
hidesScaleName: Bool = false,
numberFormatStyle: FloatingPointFormatStyle<Double>? = nil
)
}
The extra option allows us to omit the output of the temperature scale in the final string. Thanks to the magic of protocol extensions with where
clauses, this is only available when our UnitType
is UnitTemperature
.
With this understanding, we can create a new instance of our format style and use it to format our measurement by using the .formatted()
method on the measurement OR using the .format()
method on our format style.
let myHeight = Measurement(value: 190, unit: UnitLength.centimeters)
let measurementStyle = Measurement<UnitLength>.FormatStyle(width: .wide)
myHeight.formatted(measurementStyle) // "6.2 feet"
measurementStyle.format(myHeight) // "6.2 feet"
But this brings up some issues and questions related to the output: “6.2 feet”?
- Why am I getting “feet” as my output when I created it using
UnitLength.centimetres
? - Why am I getting fractional feet as my output? No one uses that for a person’s height!
Answering both questions means we need to understand how the format style uses the locale
and usage
parameters together to create our output.
Obfuscated Localization
As a quick aside, you need to know that the string output of the .formatted()
method on a Measurement
instance is non-deterministic between different devices.
Because by default, the measurement format style will use the device’s current locale, this means that two developers can get different output by the simple fact that their devices are set to different locales.
let myHeight = Measurement(value: 190, unit: UnitLength.centimeters)
let measurementStyle = Measurement<UnitLength>.FormatStyle(width: .wide)
// When the device's Locale is US (en-US)
myHeight.formatted(measurementStyle) // "6.2 feet"
// When the device's Local is Sweden (sv-SE)
myHeight.formatted(measurementStyle) // "1.9 m"
That FormatStyle extension
Creating instances of Measurement<UnitType>.FormatStyle
isn’t how you should be interacting with the system. At all. You’re much better off using the .measurement(width:usage:numberFormatStyle)
extension on FormatStyle
directly.
The usage
and numberFormatStyle
parameters are optional. And to specify the Locale
, you can simply hang the .locale()
off the end:
let usa = Locale(identifier: "en-US")
let sweden = Locale(identifier: "sv-SE")
let myHeight = Measurement(value: 190, unit: UnitLength.centimeters)
myHeight.formatted(.measurement(width: .abbreviated, usage: .general).locale(usa)) // "6.2 ft"
myHeight.formatted(.measurement(width: .abbreviated, usage: .general).locale(sweden)) // "1.9 m"
We’re going to be using this from now on.
Usage Is Very Important
Let’s answer both of those earlier questions with the same unsatisfying answer:
- Why am I getting “feet” as my output when I created it using
UnitLength.centimetres
? - Why am I getting fractional feet as my output? No one uses that for a person’s height!
That’s easy: Because the default usage
parameter is .general
and the default Locale
is en-US
when you’re using an Xcode Playground.
But seriously, let’s get into more detail.
The usage
parameter is interesting because while there are two shared options, Apple provides some specialized usages depending on your Dimension
that is used.
The shared options are:
Option | Description |
---|---|
.general |
Outputs the value in the most generalized way for the given locale |
.asProvided |
Outputs a string value of the unit the Dimension was created with or converted to |
Therefore this means that for the US English locale, the system defines fractional feet as the output when the .general
usage parameter is used. Whereas for the Sweden Swedish locale defines the .general
usage parameter as outputting fractional metres.
If we switch up the usage on the above code to use the .asProvided
option, we’d get the output in the original units regardless of the Locale:
let usa = Locale(identifier: "en-US")
let sweden = Locale(identifier: "sv-SE")
let myHeight = Measurement(value: 190, unit: UnitLength.centimeters)
myHeight.formatted(.measurement(width: .abbreviated, usage: .asProvided).locale(usa)) // "190 cm"
myHeight.formatted(.measurement(width: .abbreviated, usage: .asProvided).locale(sweden)) // "190 cm"
To answer the second question, we need to detail the usage options that are available to us only when the Dimension
is UnitLength
.
MeasurementFormatUnitUsage
All of the custom usages are static properties on the MeasurementFormatUnitUsage
struct (you can check Apple’s docs here). But here are all of the custom usages available to us when we use the UnitLength
Dimension
.
Option | Description |
---|---|
.person |
For distances as they relate to a person |
.personHeight |
For displaying a person’s height |
.road |
For distances while driving |
.focalLength |
For the focal length of optics |
.rainfall |
For displaying rainfall values |
.snowfall |
For displaying snowfall values |
Look at that, we have a special usage option for displaying someone’s height.
let usa = Locale(identifier: "en-US")
let sweden = Locale(identifier: "sv-SE")
let myHeight = Measurement(value: 190, unit: UnitLength.centimeters)
myHeight.formatted(.measurement(width: .abbreviated, usage: .personHeight).locale(usa)) // "6 ft, 2.8 in"
myHeight.formatted(.measurement(width: .abbreviated, usage: .personHeight).locale(sweden)) // "1 m, 90 cm"
Better. Much better. We now get the height in the customary units of feet and inches for the US locale and metres and centimetres for the Sweden locale.
Formatting the number
Our new string is a lot better than before, there is one issue though: The fractional inches.
Colloquially in the US, you give your height in whole values for feet and inches so we need to somehow remove or round that inches value. This is where that final numberFormatStyle
parameter comes in. As you can guess by looking at the name, this is simply another format style we provide to our Measurement<UnitType>.FormatStyle
. In this case is specifically a FloatingPointFormatStyle<Double>
format style.
This format style is complicated and powerful. If you’re looking for a detailed explanation of it, you can see my write up on the goshdarnformatstyle.com site. But for our uses here, we can see how to both round the value up and remove all fractional digits:
let usa = Locale(identifier: "en-US")
let sweden = Locale(identifier: "sv-SE")
let myHeight = Measurement(value: 190, unit: UnitLength.centimeters)
myHeight.formatted(
.measurement(
width: .abbreviated,
usage: .personHeight,
numberFormatStyle: .number.precision(.fractionLength(0))
).locale(usa)
) // "6 ft, 3 in"
myHeight.formatted(
.measurement(
width: .abbreviated,
usage: .personHeight,
numberFormatStyle: .number.precision(.fractionLength(0))
).locale(sweden)
) // "1 m, 90 cm"
The Hard Sell
You can now see why storing your physical values as Measurement
types can unlock some incredible features inside of your app. Converting and doing math on these values can be helpful, but the real power comes in when you get so much localization power for free.
Even though a country officially uses one set of units, colloquially a population can use different sets of units depending on the situation. Canadians give out their height and weight in imperial units, British roads use miles per hour as their speed, etc. Much like using the Date
type to store dates removes so many common date mistakes, using Measurement
for store and format your measurements saves you from having an encyclopedic knowledge of local customs.
The next time you’re faced with using a physical measurement value, it may be tempting to simply store it as a numeric type. But I hope now you see the power in fully embracing using Measurement
from now on.
-
Apple’s documentation refers to these units as subclasses of
Dimension
, which is “An abstract class representing a dimensional unit of measure.”. ↩ ↩2