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)
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())
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))
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))
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.