This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We review the differences between the different formatting APIs and how they interact with SwiftUI.

00:06 We often get frustrated because we always forget the FormatStyle APIs. There are so many we know roughly, but it's a constant struggle to bend them to do exactly what we want. We actually made a little website, formatstyle.guide, that shows different configurations and their outputs. It's interactive, so people can play around with it and see what's possible. In this episode, we want to highlight some of the FormatStyle functionalities.

00:58 We'll cover specific things that work with the more recent FormatStyle APIs and don't work with the old ones, plus some gotchas, so it's a bit of a grab bag episode.

01:18 One fun thing is when we have a Text view, we can put an integer in and then specify the .number format, which causes a comma to be inserted for a US locale:

Text(1000, format: .number) // Renders as 1,000

01:41 Besides passing the format directly into the Text view, we can also prepare a formatted string or use the legacy NumberFormatter approach:

VStack {
    Text(1000, format: .number)
    Text(1000.formatted(.number))
    Text(1000 as NSNumber, formatter: NumberFormatter())
}

02:42 The legacy formatter approach will crash at runtime if we have type mismatches because it's not strictly typed at compile time. The API expects an NSObject, so it accepts any object.

03:07 Each of these behaves differently. The first two use the FormatStyle API that's relatively new. The last one uses NSFormatter, which is called Formatter in modern Swift. This last API is useful when we have existing formatters around. We've worked with clients who had massively complex formatters for currencies, and they don't want to rewrite those.

Number Formatting

03:43 The first two examples we've shown behave differently even though they use the same formatting API. The output will be the same in a static scenario, but in the example, we create a static string that doesn't observe environment changes.

04:04 When we add a picker to switch the locale dynamically, we see that one reacts to the changes while the other doesn't. The first variant picks up locale changes because the format API on Text automatically observes the environment, whereas .formatted doesn't:

struct ContentView: View {
    @State private var locale = Locale(identifier: "en_US")

    var body: some View {
        VStack {
            Text(1000, format: .number)
            Text(1000.formatted(.number))

            Picker("Locale", selection: $locale) {
                Text("US").tag(Locale(identifier: "en_US"))
                Text("Germany").tag(Locale(identifier: "de_DE"))
            }
        }
        .environment(\.locale, locale)
    }
}

05:16 Both Text views use the dot as decimal separator when the locale is German, which is our system's default. When we switch to US locale, the top label switches to using a comma for the thousand separator, but the second one doesn't pick up this locale from the environment.

05:50 We could set it manually on the formatter. That's what SwiftUI does for us automatically:

Text(1000.formatted(.number.locale(locale)))

06:28 This is a bit of an edge case because the locale rarely changes in a realistic context, but it's nice that we get that for free. And the Text view listens to more than just locale — it also picks up the time zone from the environment.

Date Formatting

06:48 We can try a different example using a date formatter to see what we get in terms of updates there:

struct ContentView: View {
    @State private var locale = Locale(identifier: "en_US")
    @State private var timezone = TimeZone(secondsFromGMT: 0)!

    var body: some View {
        VStack {
            Text(Date.now, format: .dateTime)
            Text(Date.now.formatted(.dateTime))

            Picker("Locale", selection: $locale) {
                Text("US").tag(Locale(identifier: "en_US"))
                Text("Germany").tag(Locale(identifier: "de_DE"))
            }
            Picker("Timezone", selection: $timezone) {
                Text("GMT+0").tag(TimeZone(secondsFromGMT: 0)!)
                Text("GMT+3").tag(TimeZone(secondsFromGMT: 3 * 3600)!)
            }
        }
        .environment(\.locale, locale)
        .environment(\.timeZone, timezone)
        .padding()
    }
}

09:28 We immediately see that we have a different time display. When we switch to GMT+3, the first date changes but the second one doesn't. This is the key difference: if we use the API that specifies the FormatStyle on the SwiftUI Text view, it observes the environment and picks up changes. If we just construct a plain string, we don't get any of that because a string has no idea about the environment.

Rounding and Precision Control

10:18 There are more useful techniques to explore. One thing that always confused us but we finally figured out is number rounding behavior. When we have measurements in SwiftUI, we often get values like 33.33333. We can format them as numbers:

Text(33.33333, format: .number)

10:18 If we want a compact representation, we might wrap this as an Int, which works and displays 33. But if we try to use rounding, it doesn't round as expected:

Text(33.33333, format: .number.rounded()) // Displays as 33.33333

11:12 This is confusing. The key is that we have to specify how the number should be rounded. There's a rule parameter (which specifies whether to round up, down, or closest), but we need to provide the increment parameter. If we specify 1, it rounds to whole numbers:

Text(33.33333, format: .number.rounded(increment: 1)) // Displays as 33

11:38 Looking at the rounded API, the increment is actually an optional parameter with nil as the default value. That's why it's not doing anything when we don't specify it.

11:53 Another way to write this is to set the precision using a fraction length:

Text(measurement, format: .number.precision(.fractionLength(0)))

11:59 But that's different output because now it's not rounding anymore, but just cutting off the fractional part. It's nice to use the number formatting rather than integer conversion because it gives us more control.

12:32 Rounding has many interesting rules. There are different ways to round. If we don't specify a rule, we get the standard behavior we'd expect. But even the standard rounding behavior always rounds to the nearest number, yet in different directions for negative numbers. If we have 5.2, it goes to 5. But if we have -5.2, it goes to -5, so it goes towards zero. There are all kinds of different rules we can specify for exactly how we want rounding to work. There's even one that rounds towards even numbers.

Currency Formatting with Integer Scaling

13:12 Another common scenario is working with currencies, we encounter this especially in our workshops with developer teams. A price like "$19.99" is often stored as an integer:

Text(1999, format: .number)

13:41 The nice thing about integers is we don't have precision loss. If we use Double or Float for currencies, we risk losing precision. The question then becomes: how do we turn this into something like "$19.99" with decimals?

13:58 We might divide by 100, but then we have the problem that it rounds to 19 because it's an integer. If we first cast it to a Float, it's also messy. But we can use currency formatting with scaling:

Text(1999, format: .currency(code: "USD").scale(0.01)) // Displays "$19.99"

14:29 Both the number formatter and currency formatter support scaling. We can scale by 0.01, and this gives us the output we want without having to convert to floats manually.

Working with Attributed Strings

14:48 Some of these formatters, like the currency formatter, can also produce attributed strings. It seems to not do anything visually, but it's a useful concept. It sets attributes on the formatted string that are invisible, but we can use them later to format our text.

15:20 We can take a look at the attributes being set. We can't do this with a Text view directly because we need to generate the attributed string and then examine the attributes:

let string = 1999.formatted(.currency(code: "USD").scale(0.01).attributed)

15:39 We can examine the runs — i.e. groups of characters that all share the same attributes and values — of this attributed string. The .list format can help us output this array as a list of values:

let runs = string.runs.map { "\($0)" }
Text(runs, format: .list(type: .and))

17:32 We can see that we have digits "19" with an attribute key Foundation.NumberFormat.Part set to the value integer. Then we have the comma as a decimalSeparator, and then "99" as a fraction part. We also see a currency symbol part.

18:23 This enables us to style the currency symbol in red by transforming the attributes. It's quite verbose to do this, but we can do a lot, because everything is annotated semantically. Using the transformingAttributes method, we can specify the attribute key we're interested in and provide a closure that modifies the attributes:

let transformedString = string.transformingAttributes(
    AttributeScopes.FoundationAttributes.NumberFormatAttributes.SymbolAttribute.self
) { symbol in
    if symbol.value == .currency {
        symbol.replace(with: AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute.self, value: .red)
    }
}
Text(transformedString)

Parseable Format Styles

21:27 One final thing to mention is ParseableFormatStyle, which we already covered in a previous episode. This is an addition to the FormatStyle protocol and it can be used to enter values into text fields. We can have text fields that accept percentages, numbers, currencies, or anything else that can be formatted.

21:27 One thing we discovered while researching the FormatStyle Guide is that we can also pass a parseable format style to a Stepper, and then we get a stepper with the up and down arrows, but also a text field to directly enter a value.

22:13 It's quite a complex API with lots of types and generics, but we can do powerful things with it.

Resources

  • Sample Code

    Written in Swift 6.2

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

63 Episodes · 22h21min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes