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
}
}
< /code>
Форматер локализован, так что он уважает предпочтение пользователя десятичных, тысячи сепараторов, метрической или имперской системы измерений и других.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)
}
}
< /code>
Этот тест отлично выполняется на моей машине локально.
Однако, когда я выдвигаю этот тест на Github, их CI терпит неудачу очень странно: < /p>
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.
< /code>
Пока я проверяю локаль в этом бегуне, я получаю следующее: < /p>
• Identifier: fr_FR
• Decimal Separator: ','
• Grouping Separator: ' '
• Measurement System: metric
• Region Identifier: FR
• Language Code: fr
< /code>
Итак, у нас есть следующая ситуация: < /p>
Локаль правильный, разделитель верен. Почему это работает на моей машине?
• Identifier: en_US@rg=fizzzz
• Decimal Separator: ','
• Grouping Separator: ' '
• Measurement System: metric
• Region Identifier: FI
• Language Code: en
< /code>
и на GitHub: < /p>
• Identifier: en_US
• Decimal Separator: '.'
• Grouping Separator: ','
• Measurement System: ussystem
• Region Identifier: US
• Language Code: en
< /code>
Мое предположение состоит в том, что локали, установленная в тесте, фактически не используется, и используется локаль по умолчанию. Поскольку на моей машине десятичный сепаратор - «», то есть запятая, тест проходит, а на Github это период, и этот тест преодолел. Независимо от того, какой локаль я использую, это всегда период, который используется на GitHub. И есть ли способ исправить это? let currentLocale = Locale.current
let testLocale = Locale(identifier: "fr_FR")
// Print system current locale info
print("
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
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
for localeID in availableLocales {
print(" • \(localeID)")
}
Подробнее здесь: https://stackoverflow.com/questions/796 ... s-but-work
Мобильная версия