DEV Community is a community of 788,722 amazing developers

We're a place where coders share, stay up-to-date and grow their careers.

How to Write a Distance Converter in Swift/SwiftUI

In this tutorial, we will build a simple distance converter for iOS with SwiftUI. We will take a look at how to read user input as a Double and how to prevent the user from entering faulty values.

Once completed, we will be able to enter a distance with a start unit and select the unit we want to convert the distance into. Also, we will add a conversion history of the current session, which will be deleted after closing the app.

So let's get started 😀

Step 1: Modeling the Different Units

We start by creating an enum for the different units. For readability, we use the full name for the cases and add an abbreviation, which we assign as a corresponding String. Additionaly, we need our enum to conform to the CaseIterable protocol, so that we can later use DistanceUnit.allCases in our Picker.

enum DistanceUnit: String, CaseIterable {
case inch = "in"
case foot = "ft"
case yard = "yd"
case meter = "m"
case kilometer = "km"
case mile = "mi"
case nauticMile = "nmi"
}

Step 2: Add a Conversion Struct

To record a conversion history, we create first a new struct called Conversion so we can use then an Array of Conversion for our conversion history. In Conversion, we save the start value and unit as well as the end value and unit. Since we later want to use the Array of Conversion in a list, Conversion has to conform to the Identifiable protocol, so we have to add an id as well.

struct Conversion: Identifiable {
var id: UUID = UUID()

var startValue: Double = 0.0
var startDistanceUnit: DistanceUnit = .kilometer
var endValue: Double = 0.0
var endDistanceUnit: DistanceUnit = .mile
}

Step 3: Build a UI for the App

In the body of our ContentView, we build our UI. It contains a headline, the conversion input, a button and the history of conversions as a list.

struct ContentView: View {
@State var startDistanceUnit: DistanceUnit = .kilometer
@State var endDistanceUnit: DistanceUnit = .mile
@State var startValueString = ""

@State var conversionHistory: [Conversion] = []

var body: some View {
VStack {
Text("Distance Conversion")
.font(.title)
.bold()

// Conversion Input
HStack {
Text("Distance:")
TextField("e.g. 5.3", text: \$startValueString)
Picker("\(startDistanceUnit.rawValue)", selection: \$startUnit) {
ForEach(DistanceUnit.allCases, id: \.self) { unit in
Text(unit.rawValue)
}
}
}

HStack {
Spacer()

Text("Convert to: ")
Picker("\(endDistanceUnit.rawValue)", selection: \$endUnit) {
ForEach(DistanceUnit.allCases, id: \.self) { distanceUnit in
Text(distanceUnit.rawValue)
}
}
}

// Convert Button
HStack {
Spacer()

Button(action: {
}) {
Text("Convert")
}
.background(Color.green)
.foregroundColor(.black)

Spacer()
}

// Conversion History
List {
ForEach(conversionHistory.reversed()) { currentConversion in
HStack {
Spacer()

Text("\(currentConversion.startValue) \(currentConversion.startDistanceUnit.rawValue) -> \(currentConversion.endValue) \(currentConversion.endDistanceUnit.rawValue)")

Spacer()
}
}
}
}
}
}

It should look like this: Step 4: Implement Button Action

Next, we need a function to convert our start value and save it as a Conversion in our conversionHistory array. Therefore, we add the functions convertUnit and saveConversion and call saveConversion() in our Button.

One way of calculating the conversion is to use a nested switch statement. But then we have in each switch case again all other switch cases. That would lead to n2 statements for n distance units. And the more units we use, the longer and more unpleasent the statement will get.

Another way to calculate it is the use of a conversion factor for each case of our enum. When multiplying a distance with this conversion factor, we get the distance in meters. To use this factor, we add a variable to UnitSystem called conversionFactorToMeters:

extension DistanceUnit {
var conversionFactorToMeter: Double {
switch self {
case .inch:
return 0.0254
case .foot:
return 0.3048
case .yard:
return 0.9144
case .meter:
return 1
case .kilometer:
return 1000
case .mile:
return 1609.344
case .nauticMile:
return 1852
}
}
}

With this, we can simply convert our value to meters by multiplying the conversion factor of the startUnit and then to the specified unit by dividing with the conversion factor of the endUnit. This gives us to the following functions:

struct ContentView: View {
// ...

var body: some View {
// ...

Button(action: {
saveConversion()
}) {
Text("Convert")
}
// ...
}

// Functions
func convertUnit(valueToConvert: Double, fromUnit: DistanceUnit, toUnit: DistanceUnit) -> Double {
return valueToConvert * fromUnit.conversionFactorToMeter / toUnit.conversionFactorToMeter
}

func saveConversion() {
var conversion = Conversion()

conversion.startUnit = startUnit
conversion.endUnit = endUnit
conversion.startValue = Double(startValueString) ?? 0.0
conversion.endValue = convertUnit(valueToConvert: conversion.startValue, fromUnit: startUnit, toUnit: endUnit)

conversions.append(conversion)
}
}

Congratulations, you have a fully functioning Distance Converter App!

But...

If you try it, you will see that it isn't really user-friendly. So let's change that!

Since we only put Double values into our TextField, we can set the keyboardType to .decimalPad:

struct ContentView: View {
// ...
var body: some View {
// ...

TextField("e.g. 5.3", text: \$startValueString)

// ...
}
// ...
}

While this modifier prevents us from typing anything other than numbers and decimal separators, we can still paste other text. Furthermore, we can type multiple decimal separators. This means that we don't have a decimal input which results in using the default value as startValue, which is 0.

Step 5: Prevent Faulty Input

While searching for a solution for this problem, I came across this blog post. In this blog post is a solution how to use a TextField for numbers only. Since it should work not only for Integer but also for Double values, I made some adjustments:

First we add a new class called ValidatedDecimal, which has a variable valueString. When this variable is set, we check each char whether it is a number or the first decimal separator. All other characters will be filtered. Additionally, if the first input is a decimal separator, we add a 0 upfront.

class ValidatedDecimal: ObservableObject {
@Published var valueString = "" {
didSet {
var hasDecimalSeparator = false
var filteredString = ""

for char in valueString {
if char.isNumber {
filteredString.append(char)
} else if String(char) == Locale.current.decimalSeparator && !hasDecimalSeparator {
if filteredString.count == 0 {
filteredString = "0"
}
filteredString.append(char)
hasDecimalSeparator = true
}
}

if valueString != filteredString {
valueString = filteredString
}
}
}
}

Now we have to change a few things in our ContentView: Instead of the startValueString we use an instance of ValidatedDecimal called startValue and bind the input from the TextField to the variable valueString from startValue. In the function saveConversion() we also have to replace startValueString with startValue.valueString.

struct ContentView: View {
// ...

// @State var startValueString = ""
@ObservedObject var startValue = ValidatedDecimal()

// ...

var body: some View {
// ...

TextField("e.g. 5.3", text: \$startValue.valueString)

// ...
}
// ...

func saveConversion() {
// ...

conversion.startValue = Double(startValue.valueString) ?? 0.0

// ...
}
}

This already looks quite nice 😃 So we could stop here.

Or...

We take another look into the different regional settings. While this code works with a dot as decimal separator, it does not work in regions, where the decimal separator is a comma. So let's fix that.

Step 6: Taking Regional Settings into Account

If your region is using a decimal comma, we run into a problem:

Since we use the KeyboardType .decimalPad, the comma is shown as decimal separator, which is what we want. But when casting the string into a double, we need a decimal dot instead. Otherwise the string will not be recognized as double and we get our default value of 0.0 😕

To fix this, we add a computed property to ValidatedDecimal, which we simply call decimalValue. In this variable we take the valueString, replace the decimal separator with a "." and return the resulting value as Double:

class ValidatedDecimal: ObservableObject {
// ...

var decimalValue: Double {
let replacedString = valueString.replacingOccurrences(of: Locale.current.decimalSeparator!, with: ".")

return Double(replacedString) ?? 0
}
}

Now we only have to use decimalValue as startValue for the Conversion in the ContentView. Also, we change the decimal point of the text in the TextField to Locale.current.decimalSeparator to be consistent.

struct ContentView: View {
// ...

var body: some View {
// ...

TextField("e.g. 5\(Locale.current.decimalSeparator!)3", text: \$startValue.valueString)

// ...
}
// ...

func saveConversion() {
// ...

// conversion.startValue = Double(startValue.valueString) ?? 0.0
conversion.startValue = startValue.decimalValue

// ...
}
}

Wow, this looks like we're finished 😃

On the other hand...

By making some minor adjustments we can make it more readable and user-friendly.

First of all, we don't want to always delete the previous input. So we add a reset() function to our code.

struct ContentView: View {
// ...

var body: some View {
// ...

Button(action: {
saveConversion()
resetInput()
}) {
Text("Convert")
}
// ...
}

// Functions
// ...

func resetInput() {
startValue.valueString = ""
}
}

Also, we don't want to see trailing zeros in our history, so let's add a NumberFormatter. In this formatter, we can set the maximum accuracy of the conversion by setting a value to the property .maximumFractionDigits. Please note, however, that we should not set this too high, as the conversion and the floating point arithmetic of our double value can lead to inaccuracy.

For readability, we put the corresponding code into functions:

struct ContentView: View {
// ...
var body: some View {
// ...

List {
ForEach(conversions.reversed()) { currentConversion in
HStack {
Spacer()

Text(printConversion(conversion: currentConversion))

Spacer()
}
}
}

// ...
}
// Functions
// ...

func printConversion(conversion: Conversion) -> String {
return printDistanceWithUnit(distance: conversion.startValue, unit: conversion.startUnit) + " -> " + printDistanceWithUnit(distance: conversion.endValue, unit: conversion.endUnit)
}

func printDistanceWithUnit(distance: Double, unit: DistanceUnit) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = 6 // Change this value for accuracy

var returnString = ""

let value = NSNumber(value: distance)
if let distanceString = formatter.string(from: value) {
returnString = distanceString + " " + unit.rawValue
}

return returnString
}
}

Finally, to prevent tapping the Button and adding lot of zero to zero conversions, we disable the convert Button if the TextField is blank and change it's color to make the deactivation visible.

struct ContentView: View {
// ...
var body: some View {
// ...

Button(action: {
saveConversion()
resetInput()
}) {
Text("Convert")
}
.disabled(startValue.valueString == "")
.background(startValue.valueString == "" ? Color.gray : Color.green)
.foregroundColor(startValue.valueString == "" ? .black.opacity(0.2) : .black)
// ...
}
// ...
}

So that's it, we made a distance converter app 👍🏻

You can find the full source code here.

Conclusion

I hope this post helped you to implement a conversion app and how to use a TextField for Double input. And while there is no built in option to prevent faulty input in a TextField, we can always create our own input validator 😉.