# FormatStyle, Locale and Layout Direction

`FormatStyle` needs iOS 15 or later. This skill uses it for locale-aware
display: numbers, dates, currency, units, names, lists, calendars and
separators, plus previews and tests across locales. Custom `FormatStyle`
types, `ParseableFormatStyle`, parsing, `Date.IntervalFormatStyle`,
`URL.FormatStyle` and reusable formatter API design belong to
`swift-formatstyle`; here they only get locale risks and testing advice unless
someone asks for the implementation. Whatever the value, recommend previewing
it in several locales such as `en_US`, `de_DE`, `ar_SA` and `ja_JP`.

## Contents

1. [Replacing legacy formatters](#replacing-legacy-formatters)
2. [Dates](#dates)
3. [Numbers](#numbers)
4. [Measurements](#measurements)
5. [Durations](#durations)
6. [Person names](#person-names)
7. [Byte counts](#byte-counts)
8. [Lists](#lists)
9. [Custom styles](#custom-styles)
10. [Pinning a locale](#pinning-a-locale)
11. [Right-to-left in depth](#right-to-left-in-depth)
12. [ScaledMetric and Dynamic Type](#scaledmetric-and-dynamic-type)
13. [Checking layout with Accessibility Inspector](#checking-layout-with-accessibility-inspector)
14. [Cheat sheet](#cheat-sheet)

## Replacing legacy formatters

| Old formatter | FormatStyle replacement |
|---|---|
| `DateFormatter` | `.formatted(.dateTime...)`, `Date.FormatStyle` |
| `NumberFormatter` | `.formatted(.number...)`, `IntegerFormatStyle`, `FloatingPointFormatStyle` |
| `DateComponentsFormatter` | `Duration.formatted(.units(...))` or `.time(pattern:)` |
| `MeasurementFormatter` | `Measurement.formatted(.measurement(...))` |
| `DateIntervalFormatter` | `(start..<end).formatted(date:time:)` |
| `PersonNameComponentsFormatter` | `.formatted(.name(style:))` |
| `ByteCountFormatter` | `.formatted(.byteCount(style:))` |
| `ListFormatter` | `.formatted(.list(type:))` |

Format styles are value types, `Sendable` and composable, and SwiftUI `Text`
accepts them directly. The old formatters are classes whose locale and calendar
you had to configure and share by hand.

## Dates

Presets:

```swift
let pickup = Date.now

pickup.formatted(date: .numeric, time: .omitted)     // 6/3/2026
pickup.formatted(date: .abbreviated, time: .omitted) // Jun 3, 2026
pickup.formatted(date: .long, time: .omitted)        // June 3, 2026
pickup.formatted(date: .complete, time: .omitted)    // Wednesday, June 3, 2026

pickup.formatted(date: .omitted, time: .shortened)   // 9:40 AM
pickup.formatted(date: .omitted, time: .standard)    // 9:40:12 AM
pickup.formatted(date: .omitted, time: .complete)    // adds the time zone
pickup.formatted()                                   // platform default
```

Components: list what you want and the system arranges it for the locale.

```swift
pickup.formatted(.dateTime.weekday(.abbreviated).day().month(.wide))
pickup.formatted(.dateTime.month(.narrow))            // "J"
pickup.formatted(.dateTime.month(.twoDigits).day(.twoDigits))
pickup.formatted(.dateTime.day(.ordinalOfDayInMonth)) // first Wednesday -> 1
pickup.formatted(.dateTime.year(.twoDigits))
pickup.formatted(.dateTime.year(.defaultDigits))
pickup.formatted(.dateTime.hour().minute())           // 12h or 24h per locale
pickup.formatted(.dateTime.hour(.defaultDigits(amPM: .omitted)).minute())
```

`month(_:)` takes `.wide`, `.abbreviated`, `.narrow`, `.twoDigits`. `day(_:)`
takes `.twoDigits` or `.ordinalOfDayInMonth`, which counts which occurrence of
that weekday the date is in the month.

In SwiftUI:

```swift
Text(pickup, format: .dateTime.day().month())
Text(pickup...pickup.addingTimeInterval(7_200))   // a range
Text(pickup, style: .relative)                   // updates itself
Text(pickup, style: .timer)                      // counts live
Text(pickup, style: .offset)
```

Relative phrasing:

```swift
let lastSync = Date.now.addingTimeInterval(-86_400)
lastSync.formatted(.relative(presentation: .named))                   // "yesterday"
lastSync.formatted(.relative(presentation: .numeric))                 // "1 day ago"
lastSync.formatted(.relative(presentation: .numeric, unitsStyle: .wide))
```

Ranges:

```swift
let checkIn = Date.now
let checkOut = checkIn.addingTimeInterval(4 * 86_400)
(checkIn..<checkOut).formatted(date: .abbreviated, time: .omitted)
// shared month and year are written once: "Jun 3 - 7, 2026"
(checkIn..<checkOut).formatted(.components(style: .wide))             // "4 days"
```

ISO 8601 is for the wire, never for people:

```swift
pickup.formatted(.iso8601.dateSeparator(.dash).timeSeparator(.colon))
```

## Numbers

Grouping follows the locale: 2,500,000 in the US, 2.500.000 in Germany,
2 500 000 in France.

```swift
2_500_000.formatted()                                      // locale grouping
2_500_000.formatted(.number.grouping(.never))              // 2500000

0.004218.formatted(.number.precision(.significantDigits(3)))   // 0.00422
72.5.formatted(.number.precision(.fractionLength(2)))          // 72.50
72.5.formatted(.number.precision(.fractionLength(0...3)))      // 72.5

4_300_000.formatted(.number.notation(.compactName))  // "4.3M", German "4,3 Mio."
6_022.0.formatted(.number.notation(.scientific))

18.6.formatted(.number.rounded(rule: .up, increment: 1))              // 19
18.6.formatted(.number.rounded(rule: .down, increment: 1))            // 18
18.5.formatted(.number.rounded(rule: .toNearestOrEven, increment: 1)) // 18
```

Without `increment:` (or a `.precision` of zero fraction digits) the rule only
applies at the style's default precision, so `18.6` still prints "18.6".

Percent multiplies by 100. The integer `92` with `.percent` prints
"9,200%". Convert to a fraction first:

```swift
let batteryLevel = 92
(Double(batteryLevel) / 100).formatted(.percent)                            // "92%"
0.1275.formatted(.percent.precision(.fractionLength(1)))                    // "12.8%"
```

Currency: always pass `code:`. The code picks the currency; the locale only
decides where the symbol goes and which separators are used.

```swift
let fare = Decimal(124_950) / 100
fare.formatted(.currency(code: "USD"))                      // $1,249.50 in en_US
fare.formatted(.currency(code: "EUR"))                      // de_DE: "1.249,50" then the euro sign
Decimal(3200).formatted(.currency(code: "JPY"))             // no decimals for yen
fare.formatted(.currency(code: "CAD").presentation(.narrow)) // "$" when space is tight
Text(fare, format: .currency(code: "GBP"))
```

Money belongs in `Decimal`, not `Double`, so binary rounding never shows up in
a price.

Ordinals: the iOS 26.4 SDK has no ordinal `FormatStyle` (`.number.notation`
offers only `.automatic`, `.compactName` and `.scientific`), so this is the one
case that still uses `NumberFormatter`:

```swift
let ordinals = NumberFormatter()
ordinals.numberStyle = .ordinal
ordinals.string(from: 3)   // "3rd", German "3.", French "3e"
```

## Measurements

Units convert to the locale's system (metric or imperial) unless you opt out.

```swift
let hike = Measurement(value: 12.4, unit: UnitLength.kilometers)
hike.formatted(.measurement(width: .wide))          // "7.7 miles" in en_US
hike.formatted(.measurement(width: .abbreviated))   // "7.7 mi"
hike.formatted(.measurement(width: .narrow))        // "7.7mi"
hike.formatted(.measurement(width: .wide, usage: .asProvided))  // keeps km

let luggage = Measurement(value: 23, unit: UnitMass.kilograms)
luggage.formatted(.measurement(width: .abbreviated, usage: .personWeight))

let outside = Measurement(value: 28, unit: UnitTemperature.celsius)
outside.formatted(.measurement(width: .abbreviated, usage: .weather))   // F in en_US

let windSpeed = Measurement(value: 40, unit: UnitSpeed.kilometersPerHour)
windSpeed.formatted(.measurement(width: .abbreviated))   // mph in en_US

let bottle = Measurement(value: 500, unit: UnitVolume.milliliters)
bottle.formatted(.measurement(width: .abbreviated, usage: .liquid))      // "1.1 pt" in en_US
```

Usage hints such as `.personWeight`, `.weather` and `.liquid` (iOS 16) choose
the unit people expect for that kind of value.

## Durations

```swift
let workout = Duration.seconds(3_725)
workout.formatted(.time(pattern: .hourMinuteSecond))   // "1:02:05"
workout.formatted(.time(pattern: .hourMinute))         // "1:02"
workout.formatted(.time(pattern: .minuteSecond))       // "62:05", minutes may pass 59

// iOS 16+
workout.formatted(.units(allowed: [.hours, .minutes], width: .wide))   // "1 hour, 2 minutes"
workout.formatted(.units(allowed: [.hours, .minutes, .seconds], width: .abbreviated))
workout.formatted(.units(width: .narrow, maximumUnitCount: 1))         // "1h"
```

Widths are `.wide`, `.abbreviated` and `.narrow`; `maximumUnitCount:` drops the
smaller units.

## Person names

```swift
var guest = PersonNameComponents()
guest.namePrefix = "Dr."
guest.givenName = "Hana"
guest.familyName = "Sato"

guest.formatted(.name(style: .long))        // includes the prefix; order follows locale
guest.formatted(.name(style: .medium))
guest.formatted(.name(style: .short))       // "Hana" in en_US
guest.formatted(.name(style: .abbreviated)) // initials
Text(guest, format: .name(style: .medium))
```

## Byte counts

```swift
let backupSize: Int64 = 3_400_000_000
backupSize.formatted(.byteCount(style: .file))
backupSize.formatted(.byteCount(style: .memory))   // 1024-based
backupSize.formatted(.byteCount(style: .binary))
Int64(5_120).formatted(.byteCount(style: .file, allowedUnits: [.kb]))
```

## Lists

```swift
let toppings = ["olives", "basil", "garlic"]
toppings.formatted(.list(type: .and))   // "olives, basil, and garlic"; French uses "et"
toppings.formatted(.list(type: .or))

let tips: [Decimal] = [5, 10, 15]
tips.formatted(.list(memberStyle: .currency(code: "USD"), type: .or))

["olives", "basil"].formatted(.list(type: .and))   // "olives and basil", no serial comma
```

## Custom styles

Designing custom and parseable styles is `swift-formatstyle` territory. The
shape, for recognition:

```swift
struct ScoreFormat: FormatStyle {
    func format(_ value: Int) -> String {
        let number = Double(value)
        let style = FloatingPointFormatStyle<Double>.number.precision(.fractionLength(0...1))
        switch value {
        case 1_000_000_000...: return (number / 1_000_000_000).formatted(style) + "B"
        case 1_000_000...: return (number / 1_000_000).formatted(style) + "M"
        case 1_000...: return (number / 1_000).formatted(style) + "K"
        default: return value.formatted()
        }
    }
}

extension FormatStyle where Self == ScoreFormat {
    static var score: ScoreFormat { ScoreFormat() }
}

let shown = 48_200.formatted(.score)     // "48.2K"
Text(48_200, format: .score)
```

A parseable variant adds `ParseableFormatStyle` with a `parseStrategy` whose
`parse(_:) throws` reads the suffix back and throws on malformed input, for
example `DecodingError.dataCorrupted`. Send any real implementation work there.

## Pinning a locale

`.locale(_:)` forces a locale, which is right for serialization or for a
document that must always look one way, such as a German invoice PDF:

```swift
let invoiceDate = Date.now.formatted(.dateTime.day().month().year().locale(Locale(identifier: "de_DE")))
let payloadDate = Date.now.formatted(.iso8601)   // preferred for APIs
```

Never pin a locale in user-facing UI.

## Right-to-left in depth

SwiftUI takes `layoutDirection` from the environment. Under RTL, `.leading` is
the right edge, horizontal `ScrollView` content starts at the right, list
chevrons point left and the back button sits on the left.

Flip: `chevron.forward`, `arrow.right` and custom directional assets, using
`.flipsForRightToLeftLayoutDirection(true)`.

Do not flip: logos and brand marks, photos and illustrations, clock faces,
music notation, checkmarks, mathematical symbols, media playback controls.

SF Symbols that ship an RTL variant switch on their own; the SF Symbols app
shows which do.

```swift
#Preview("Seat map, Hebrew") {
    SeatMapView()
        .environment(\.layoutDirection, .rightToLeft)
        .environment(\.locale, Locale(identifier: "he"))
}

#Preview("Both directions") {
    HStack {
        FareRow().environment(\.layoutDirection, .leftToRight)
        FareRow().environment(\.layoutDirection, .rightToLeft)
    }
}
```

UIKit views inside a `UIViewRepresentable` read `semanticContentAttribute`:
`.forceLeftToRight`, `.forceRightToLeft`, or `.unspecified`, the default, which
follows the system.

```swift
struct CardNumberField: UIViewRepresentable {
    func makeUIView(context: Context) -> UITextField {
        let field = UITextField()
        field.semanticContentAttribute = .forceLeftToRight
        return field
    }
    func updateUIView(_ uiView: UITextField, context: Context) {}
}
```

Mixed-direction text follows the Unicode bidirectional algorithm. For edge
cases like a phone number inside Arabic text, prefix a left-to-right mark
`\u{200E}`, or set `.environment(\.layoutDirection, .leftToRight)` on that one
view.

| Problem | Fix |
|---|---|
| `.padding(.left)` | `.padding(.leading)` |
| `.offset(x:)` to push content one way | Alignment or trailing padding |
| `.multilineTextAlignment(.left)` | `.multilineTextAlignment(.leading)` |
| Rounding top-left and top-right corners | Round leading and trailing corners |
| "Swipe right to archive" | "Swipe toward the leading edge", or rely on system swipe actions |

## ScaledMetric and Dynamic Type

`@ScaledMetric(relativeTo:)` scales your own numbers, such as spacing, icon
size and padding, together with the text style you name.

```swift
struct ContactRow: View {
    @ScaledMetric(relativeTo: .body) private var portraitSide: CGFloat = 36
    @ScaledMetric(relativeTo: .body) private var gap: CGFloat = 10

    var body: some View {
        HStack(spacing: gap) {
            Image(systemName: "person.crop.circle")
                .resizable()
                .frame(width: portraitSide, height: portraitSide)
            Text("Mira Lopez")
        }
    }
}
```

Which style to scale against:

| `relativeTo` | Base size | Good for |
|---|---|---|
| `.body` | 17 pt | General spacing |
| `.caption` | 12 pt | Small icons |
| `.title` | 28 pt | Large icons |
| `.largeTitle` | 34 pt | Hero elements |

```swift
#Preview("Accessibility 3") {
    ContactRow().dynamicTypeSize(.accessibility3)
}

#Preview("All sizes") {
    ScrollView {
        ForEach(DynamicTypeSize.allCases, id: \.self) { size in
            ContactRow().dynamicTypeSize(size)
        }
    }
}
```

## Checking layout with Accessibility Inspector

Open it from Xcode > Open Developer Tool > Accessibility Inspector.

- Audit finds problems, truncated text among them.
- Inspection shows font sizes and whether an element reacts to Dynamic Type.
- Settings overrides Dynamic Type, Bold Text and Reduce Motion without touching
  system settings.

Routine:

1. Run the app in Simulator and point the inspector at it.
2. Set Dynamic Type to Accessibility XXL.
3. Visit every screen and look for truncation and overlap.
4. Switch the scheme's App Language to Arabic.
5. Visit the screens again for alignment and reading order.

## Cheat sheet

| Value | Style | en_US sample |
|---|---|---|
| `Date` | `.dateTime.month().day().year()` | Jun 3, 2026 |
| Date range | `(start..<end).formatted(date:time:)` | Jun 3 - 7, 2026 |
| Relative date | `.relative(presentation: .named)` | yesterday |
| `Int` | `.number` | 2,500,000 |
| `Int` | `NumberFormatter` with `numberStyle = .ordinal` | 3rd |
| `Int` | `.number.notation(.compactName)` | 4.3M |
| `Double` | `.number.precision(.fractionLength(2))` | 72.50 |
| `Double` | `.percent` | 92% (for 0.92) |
| `Decimal` | `.currency(code: "USD")` | $1,249.50 |
| `Measurement` | `.measurement(width: .abbreviated)` | 7.7 mi |
| `Duration` | `.time(pattern: .hourMinuteSecond)` | 1:02:05 |
| `Duration` | `.units(width: .abbreviated)` | 1 hr, 2 min, 5 sec |
| `PersonNameComponents` | `.name(style: .medium)` | Hana Sato |
| `Int64` | `.byteCount(style: .file)` | 3.4 GB |
| `[String]` | `.list(type: .and)` | olives, basil, and garlic |
