How to Write a Distance Converter in Swift/SwiftUI
Tags: tutorial, beginners, swift, ios
Published: 2021-10-05 11:25:10

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 {
            // Headline
            Text("Distance Conversion")
                .font(.title)
                .bold()
                .padding()
            
            // 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)
                    }
                }
                .pickerStyle(MenuPickerStyle())
            }
            .padding(.horizontal, 25)
            
            HStack {
                Spacer()
                
                Text("Convert to: ")
                Picker("\(endDistanceUnit.rawValue)", selection: $endUnit) {
                    ForEach(DistanceUnit.allCases, id: \.self) { distanceUnit in
                        Text(distanceUnit.rawValue)
                    }
                }
                .pickerStyle(MenuPickerStyle())
            }
            
            // Convert Button
            HStack {
                Spacer()
                
                Button(action: {
                    // TODO: add conversion
                }) {
                    Text("Convert")
                }
                .padding()
                .background(Color.green)
                .foregroundColor(.black)
                .cornerRadius(15)
                
                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:

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4r2bil4reuzo542769zj.png)

## 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 n<sup>2</sup> 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)
            .keyboardType(.decimalPad)
        
        // ...
    }
    // ...
}
``` 

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](https://programmingwithswift.com/numbers-only-textfield-with-swiftui/) 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.

## Step 7: Final Adjustments

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 == "")
                .padding()
                .background(startValue.valueString == "" ? Color.gray : Color.green)
                .foregroundColor(startValue.valueString == "" ? .black.opacity(0.2) : .black)
                .cornerRadius(15)        
        // ...
    }
    // ...
}
```

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

You can find the full source code [here](https://github.com/KatharinaGopp/DistanceConverter).

## 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 😉.