Digit group separators in a SwiftUI app

iOSdev / Swift / SwiftUI

Digit group separators in a SwiftUI app

Recently one user of my iOS app Wins (Goals Tracker) asked me for one small improvement - my app should display numbers with digit group separators (AKA thousands separators). Small change but can improve the UX of my app.

Without digit group separators, big numbers look like this:

10000

Thousands are not separated and the numbers are harder to read. With digit group separators the same number will look like this:

10 000
10.000
10,000

It depends on the users' locale. In iOS you set it in Settings / General / Language & Region / Region. It means that depending on the user's region, the user will see 10 000 or 10.000 or 10,000. Many non-English-speaking countries use periods as digit group separators. Most English-speaking countries use a comma. In some countries, it's just space. But thankfully we don't need to worry too much and remember which separator to use. We just need to specify which numbers should have the separator.

Text view

I needed to format numbers in 2 different places. The first one was the Text view.

The first thing I needed to do is to create and configure the NumberFormatter.

struct GoalView: View {
    // other properties

    private let numberFormatter: NumberFormatter = {
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        formatter.usesGroupingSeparator = true
        return formatter
    }()

    var body: some View {
        // body of my GoalView
    }
}

Previously, when I didn't use digit group separators, my Text in the body of the GoalView looked like this:

HStack(spacing: 4) {
    Text("\(goal.currently)")
    Text("/")
    Text("\(goal.goal)")
    Text(goal.currency ?? "USD")
}

To use digit group separators, my new code looks like this:

HStack(spacing: 4) {
    Text("\(numberFormatter.string(for: goal.currently) ?? "0")")
    Text("/")
    Text("\(numberFormatter.string(for: goal.goal) ?? "0")")
    Text(goal.currency ?? "USD")
}

We are using the string(for:) method on numberFormatter, so we are changing the number into a String and applying the style we previously configured for NumberFormatter.

TextField

I also wanted to format numbers using digit group separators when users type data in the TextField.

One way of doing this is to use TextField like this:

TextField("Goal", value: $goal, format: .number)

But one problem with this solution is that the text is changed to the number (and formatted) only when the user presses Enter. To format the numbers on the fly, when users edit the text in the TextField, I did something else.

First, we need to use NumberFormatter as before:

struct AddGoalView: View {
    // other properties

    private let numberFormatter: NumberFormatter = {
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        formatter.usesGroupingSeparator = true
        return formatter
    }()

    @State private var goal = 0
    @State private var goalString = ""

    var body: some View {
        // body of my GoalView
    }
}

One important thing here - I am using 2 properties per each TextField, for example goal (Int) and goalString (String).

My TextFields in the body of my AddGoalView look like this:

TextField("0", text: $goalString)
    .keyboardType(.numberPad)
    .onChange(of: goalString) { _ in
        goalString = goalString.filter { $0.isNumber }
        goal = Int(goalString) ?? 0
        goalString = numberFormatter.string(for: goal) ?? "0"
    }

The solution is very simple. I am using .onChange modifier. When goalString in the TextField changes, my app does 3 things:

  1. goalString is filtered and we keep only characters that represent a number

  2. goalString is converted to Int and assigned to the goal property

  3. the value of goal is assigned to goalString as a string formatted as specified for numberFormatter

Limitations

Please mind that there are some limitations here. If the user copies and pastes 10,000.50, it will be converted to the Int value of 1000050 and this converted to 1,000,050.

That's because of this line of code I showed you before:

goalString = goalString.filter { $0.isNumber }

I solved this problem in my other app Numi - in Numi you can copy and paste numbers with decimal fractions and they are correctly formatted based on the user's locale. But Numi is an app for personal finances and decimal fractions are something very common when you work with money. In Wins I only use Integers, so I didn't need this.

In Numi I have more complex functions triggered when users edit the text in TextFields, involving properties like formatter.minimumFractionDigits, formatter.maximumFractionDigits and formatter.roundingMode. But this is a topic for another article :)
Just please be aware of this and rewrite the logic in .onChange modifier based on your needs.

THE END

If you want to support my work, please like, comment, share the article and most importantly...

📱Check out my apps on the App Store:
https://apps.apple.com/developer/next-planet/id1495155532