I've created the following class that's a custom formatter supporting formatting both attributed and standard strings:
import Foundation
import SwiftUI
/// A formatter that converts a distance value (in meters) into a localized, styled `AttributedString`.
///
/// This formatter:
/// - Automatically switches between metric (km) and imperial (mi) based on the user's locale.
/// - Applies custom fonts and colors to the numeric value and unit label separately.
/// - Uses increased precision for shorter distances (< 200 km or mi).
final class AttributedDistanceFormatter {
private let style: AttributedFormatterStyle
let locale: Locale
init(style: AttributedFormatterStyle, locale: Locale = .current) {
self.style = style
self.locale = locale
}
/// Formatter for distances ≥ 200 km/mi (shows up to 1 decimal place).
private lazy var measurementFormatter: MeasurementFormatter = {
let formatter = MeasurementFormatter()
formatter.locale = locale
formatter.unitOptions = .providedUnit
formatter.unitStyle = .medium
let numberFormatter = NumberFormatter()
numberFormatter.minimumFractionDigits = 0
numberFormatter.maximumFractionDigits = 1
formatter.numberFormatter = numberFormatter
return formatter
}()
/// Formatter for distances < 200 km/mi (shows up to 2 decimal places).
private lazy var shortMeasurementFormatter: MeasurementFormatter = {
let formatter = MeasurementFormatter()
formatter.locale = locale
formatter.unitOptions = .providedUnit
formatter.unitStyle = .medium
let numberFormatter = NumberFormatter()
numberFormatter.minimumFractionDigits = 0
numberFormatter.maximumFractionDigits = 2
formatter.numberFormatter = numberFormatter
return formatter
}()
/// Converts a distance (in meters) into a localized and styled `AttributedString`.
///
/// - Parameter distance: The distance in meters.
/// - Returns: A styled string like “3.2 km” or “0.75 mi”.
func attributedString(from distance: Double) -> AttributedString {
let formatted = formattedDistanceString(from: distance)
let components = formatted.split(separator: " ", maxSplits: 1).map(
String.init)
guard components.count == 2 else {
// Fallback: apply value style to the entire string if format fails
return attributedValueString(formatted)
}
let number = components[0]
let unit = components[1]
var result = AttributedString()
result += attributedValueString(number)
result += attributedUnitLabel(" \(unit)")
return result
}
/// Converts a distance (in meters) into a plain localized string (e.g., "1.8 mi").
func string(from distance: Double) -> String {
formattedDistanceString(from: distance)
}
/// Internal: Computes the raw distance string using appropriate precision based on value.
func formattedDistanceString(from distance: Double) -> String {
let measurement = Measurement(value: distance, unit: UnitLength.meters)
let usesMetric = locale.measurementSystem == .metric
let unit = usesMetric ? UnitLength.kilometers : UnitLength.miles
let converted = measurement.converted(to: unit)
return (converted.value < 200)
? shortMeasurementFormatter.string(from: converted)
: measurementFormatter.string(from: converted)
}
/// Applies the value font/color style to the numeric part of the distance.
private func attributedValueString(_ string: String) -> AttributedString {
var attributed = AttributedString(string)
attributed.font = style.valueFont
attributed.foregroundColor = style.valueColor
return attributed
}
/// Applies the label font/color style to the unit part of the distance.
private func attributedUnitLabel(_ string: String) -> AttributedString {
var attributed = AttributedString(string)
attributed.font = style.labelFont
attributed.foregroundColor = style.labelColor
return attributed
}
}
struct AttributedFormatterStyle {
/// The color to apply to the numeric value part of the formatted string.
let valueColor: Color
/// The font to apply to the numeric value part of the formatted string.
let valueFont: Font
/// The color to apply to the unit label part of the formatted string.
let labelColor: Color
/// The font to apply to the unit label part of the formatted string.
let labelFont: Font
/// Initializes a new style configuration for an attributed formatter.
///
/// - Parameters:
/// - valueColor: The color to use for value text.
/// - valueFont: The font to use for value text.
/// - labelColor: The color to use for label text.
/// - labelFont: The font to use for label text.
init(
valueColor: Color,
valueFont: Font,
labelColor: Color,
labelFont: Font
) {
self.valueColor = valueColor
self.valueFont = valueFont
self.labelColor = labelColor
self.labelFont = labelFont
}
}
The formatter is localized, so that it respects the user's preference for decimal, thousands separators, metric or imperial measurement system and others.
To test this behavior I've created the following test:
import SwiftUI
import XCTest
@testable import App
final class AttributedDistanceFormatterTests: XCTestCase {
let style = AttributedFormatterStyle(
valueColor: .red,
valueFont: .system(size: 10, weight: .bold),
labelColor: .blue,
labelFont: .system(size: 8, weight: .light)
)
private func decimalDigitsCount(in string: String) -> Int {
// Normalize non-breaking space
let normalized = string.replacingOccurrences(of: "\u{00a0}", with: " ")
guard
let numberPart = normalized.split(separator: " ").first,
let separatorIndex = numberPart.firstIndex(where: { $0 == "." || $0 == "," })
else {
return 0
}
return numberPart.distance(from: separatorIndex, to: numberPart.endIndex) - 1
}
func testMetricLocaleShortDistanceUsesTwoDecimalPlacesAndKmForFrenchLocale() {
let formatter = AttributedDistanceFormatter(
style: style, locale: Locale(identifier: "fr_FR"))
let distanceMeters = 1234.56 // ~1.23456 km < 200
let string = formatter.string(from: distanceMeters)
let trimmedString = string.trimmingCharacters(in: .whitespacesAndNewlines)
XCTAssertTrue(
trimmedString.contains("km"),
"Expected suffix 'km' in: \(trimmedString)"
)
XCTAssertTrue(
trimmedString.contains(","),
"Expected comma as decimal separator in: \(trimmedString)" // French uses comma
)
XCTAssertLessThanOrEqual(decimalDigitsCount(in: string), 2)
let attributed = formatter.attributedString(from: distanceMeters)
XCTAssertEqual(attributed.runs.count, 2)
let runs = attributed.runs
let firstRun = runs[runs.startIndex]
XCTAssertEqual(firstRun.foregroundColor, style.valueColor)
XCTAssertEqual(firstRun.font, style.valueFont)
let secondRunIndex = runs.index(after: runs.startIndex)
let secondRun = runs[secondRunIndex]
XCTAssertEqual(secondRun.foregroundColor, style.labelColor)
XCTAssertEqual(secondRun.font, style.labelFont)
}
}
This test is executing perfectly on my machine locally. However, when I push this test to GitHub, their CI is failing in a very weird way:
error: -[tests.AttributedDistanceFormatterTests testMetricLocaleShortDistanceUsesTwoDecimalPlacesAndKmForFrenchLocale] : XCTAssertTrue failed - Expected comma as decimal separator in: 1.23 km
Test Case '-[tests.AttributedDistanceFormatterTests testMetricLocaleShortDistanceUsesTwoDecimalPlacesAndKmForFrenchLocale]' failed (0.383 seconds).
Test Suite 'AttributedDistanceFormatterTests' failed at 2025-05-28 14:25:50.175.
Yet, when I check the locale in that runner, I get the following:
🟢 Test Locale Info (fr_FR):
• Identifier: fr_FR
• Decimal Separator: ','
• Grouping Separator: ' '
• Measurement System: metric
• Region Identifier: FR
• Language Code: fr
So we have the following situation:
Why could this happen? Why does it work on my machine?
I've noticed this on my machine as well:
This is the output of the current locale:
🟡 Current Locale Info:
• Identifier: en_US@rg=fizzzz
• Decimal Separator: ','
• Grouping Separator: ' '
• Measurement System: metric
• Region Identifier: FI
• Language Code: en
And on the GitHub:
🟡 Current Locale Info:
• Identifier: en_US
• Decimal Separator: '.'
• Grouping Separator: ','
• Measurement System: ussystem
• Region Identifier: US
• Language Code: en
My assumption is that the locale set in the test is not actually used and a default locale is used. Since on my machine, the decimal separator is ",", i.e. comma, the test passes and on GitHub's it's the period and this break the test. No matter what locale I'm using, it's always the period that's used on GitHub.
Questions:
en_US@rg=fizzzz
on my machine?This is the code I'm using to print out the locale in the test (just insert in the test):
let currentLocale = Locale.current
let testLocale = Locale(identifier: "fr_FR")
// Print system current locale info
print("🟡 Current Locale Info:")
print(" • Identifier: \(currentLocale.identifier)")
print(" • Decimal Separator: '\(currentLocale.decimalSeparator ?? "nil")'")
print(" • Grouping Separator: '\(currentLocale.groupingSeparator ?? "nil")'")
print(" • Measurement System: \(currentLocale.measurementSystem)")
print(" • Region Identifier: \(currentLocale.region?.identifier ?? "nil")")
print(" • Language Code: \(currentLocale.language.languageCode?.identifier ?? "nil")")
// Print test locale info
print("\n🟢 Test Locale Info (fr_FR):")
print(" • Identifier: \(testLocale.identifier)")
print(" • Decimal Separator: '\(testLocale.decimalSeparator ?? "nil")'")
print(" • Grouping Separator: '\(testLocale.groupingSeparator ?? "nil")'")
print(" • Measurement System: \(testLocale.measurementSystem)")
print(" • Region Identifier: \(testLocale.region?.identifier ?? "nil")")
print(" • Language Code: \(testLocale.language.languageCode?.identifier ?? "nil")")
// Print all available locales (no trimming)
let availableLocales = Locale.availableIdentifiers.sorted()
print("\n📦 Available Locales (\(availableLocales.count)):")
for localeID in availableLocales {
print(" • \(localeID)")
}
The problem was setting locale
in the NumberFormatter
that's used inside the measurement formatter. It defaulted to the current
locale:
/// Formatter for distances ≥ 200 km/mi (shows up to 1 decimal place).
private lazy var measurementFormatter: MeasurementFormatter = {
let formatter = MeasurementFormatter()
formatter.locale = locale
formatter.unitOptions = .providedUnit
formatter.unitStyle = .medium
let numberFormatter = NumberFormatter()
numberFormatter.locale = locale // FIX1
numberFormatter.minimumFractionDigits = 0
numberFormatter.maximumFractionDigits = 1
formatter.numberFormatter = numberFormatter
return formatter
}()
/// Formatter for distances < 200 km/mi (shows up to 2 decimal places).
private lazy var shortMeasurementFormatter: MeasurementFormatter = {
let formatter = MeasurementFormatter()
formatter.locale = locale
formatter.unitOptions = .providedUnit
formatter.unitStyle = .medium
let numberFormatter = NumberFormatter()
numberFormatter.locale = locale // FIX2
numberFormatter.minimumFractionDigits = 0
numberFormatter.maximumFractionDigits = 2
formatter.numberFormatter = numberFormatter
return formatter
}()