Rob Napier
Rob Napier

Reputation: 299265

Formatting large currency numbers

Using the FormatStyle APIs, is there a way to format large numbers with trailing SI units like "20M" or "10k"? In particular I'm looking for a way to format large currency values like "$20M" with proper localization and currency symbols.

I currently have a currency formatter:

extension FormatStyle where Self == FloatingPointFormatStyle<Double>.Currency {
    public static var dollars: FloatingPointFormatStyle<Double>.Currency {
        .currency(code: "usd").precision(.significantDigits(2))
    }
}

I'd like to extend this to format Double(20_000_000) as "$20M".

Upvotes: 2

Views: 883

Answers (3)

Rob Napier
Rob Napier

Reputation: 299265

After digging more deeply into this, I've developed a solution that get the best of Joakim Danielson and lorem ipsum's answers, using .compactName to avoid reimplementing unit scaling, while still getting a currency symbol.

It's important that this is not a fully localized solution, which is quite difficult. In American English, 20 million euros would commonly be written "€20M", but in French there's no common practice I can find. Something like "20 M€" seems more likely from my research. But how would "millions of Canadian dollars" be written in French? Where does the space go? "20 MCA$"? "20 M CAD"? It's a mess just looking at American English and French, let alone every other supported Locale and currency.

But that's not the problem I'm solving. I'm the only user of this program and I just want to display quantities of US Dollars. How do I write a formatter that does just what I want?

Just like the Percent formatter and the Currency formatter, create a ShortDollars formatter nested inside of FloatingPointFormatStyle:

extension FloatingPointFormatStyle {
    public struct ShortDollars: FormatStyle {
        public func format(_ value: Value) -> String {
            let s = FloatingPointFormatStyle()
                .precision(.significantDigits(2))
                .notation(.compactName)
                .format(value)
            return "$\(s)"
        }
    }
}

This uses the standard FloatingPointFormatStyle for all the heavy lifting, and then slaps a $ on the front.

And to give it the standard syntax, add static properties (you need this for each specific type of BinaryFloatingPoint this can format):

extension FormatStyle where Self == FloatingPointFormatStyle<Double>.ShortDollars {
    public static var shortDollars: Self { .init() }
}

extension FormatStyle where Self == FloatingPointFormatStyle<Float>.ShortDollars {
    public static var shortDollars: Self { .init() }
}

Upvotes: 1

Joakim Danielson
Joakim Danielson

Reputation: 51821

You can format ordinary numbers this way using the notation modifier with compactName as argument

Double(20_000_000).formatted(.number.notation(.compactName))

Unfortunately this modifier doesn't exist for Currency although it also exists for Percent so hopefully this is something we will see implemented in the future.

So the question is if this is good enough or if it is worth it to implement a custom solution.

Upvotes: 1

lorem ipsum
lorem ipsum

Reputation: 29242

You can create a custom struct that conforms to FormatStyle

public struct ShortCurrency<Value>: FormatStyle, Equatable, Hashable, Codable where Value :  BinaryFloatingPoint{
    let locale: Locale
    enum Options: Int{
        case million = 2
        case billion = 3
        case trillion = 4
        
        func short(locale: Locale) -> String{
            switch self {
            case .million:
                return millionAbbr[locale, default: "M"]
            case .billion:
                return billionAbbr[locale, default: "B"]
            case .trillion:
                return trillionAbbr[locale, default: "T"]
            }
        }
        ///Add other supported locales
        var millionAbbr: [Locale: String] { [Locale(identifier: "en_US") : "M"]}
        var billionAbbr: [Locale: String]  { [Locale(identifier: "en_US") : "B"]}
        var trillionAbbr: [Locale: String]  { [Locale(identifier: "en_US") : "T"]}
    }
    public func format(_ value: Value) -> String {
        let f = NumberFormatter()
        f.locale = locale
        f.numberStyle = .currency
        f.usesSignificantDigits = true

        let basic = f.string(for: value) ?? "0"
        let count = basic.count(of: ".000")
        //Checks for million value
        if let abbr = Options(rawValue: count)?.short(locale: f.locale){
            //Get the symbol and the most significant numbers
            var short = String(basic.prefix(basic.count - (4*count)))
            //Append from the dictionary based on locale
            short.append(abbr)
            //return modified string
            return short
        }else{
            //return the basic string
            return basic
        }
    }
    
}

extension String {
    
    func count(of string: String) -> Int {
        guard !string.isEmpty else{
            return 0
        }
        var count = 0
        var searchRange: Range<String.Index>?
        
        while let foundRange = range(of: string, options: .regularExpression, range: searchRange) {
            count += 1
            searchRange = Range(uncheckedBounds: (lower: foundRange.upperBound, upper: endIndex))
        }
        return count
    }
}

Then extend FormatStyle

@available(iOS 15.0, *)
extension FormatStyle where Self == FloatingPointFormatStyle<Double>.Currency {
    public static func shortCurrency (locale: Locale? = nil) -> ShortCurrency<Double> {
        return ShortCurrency(locale: locale ?? .current)
    }
}

It will be available for usage just as any other FormatStyle

Text(Double(20_000_000), format: .shortCurrency())

Upvotes: 3

Related Questions