DEV Community

Katharina Gopp
Katharina Gopp

Posted on

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"
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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()
                    }
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

It should look like this:

Alt Text

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)

        // ...
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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

        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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

        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

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 = ""
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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)        
        // ...
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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

Discussion (0)