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:
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)
.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 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.
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 😉.
Top comments (0)