WWDC 21
What's new in Foundation
Attributed strings
An attributed string allows you to associate attributes in the form of key-value pairs with specific ranges of the string, in order to make a part of the text use a different style
A part of a string can have multiple attributes applied to it and the attribute ranges can overlap
Attributed strings are usually used with APIs that support rich text, like UITextView/NSTextView
String attributes are defined in the SDK, but you can also add your own
Attributed strings in Foundation previously used an ObjC-based reference type called NSAttributedString
This year, there is a completely new Swift-only struct type called AttributedString
, which takes full advantage of Swift features
The Swift AttributedString
is a value type, compatible with String
, fully localizable and with a more type-safe API
ℹ️ I've replaced some of the code samples in this section to better demonstrate the use of the API in this form
Assigning attributes to strings:
To assign attributes to the whole string, simply assign an appropriate value (which is type-checked at compile time) to a property like font
or foregroundColor
on the attributed string object:
var thanks = AttributedString("Thank you!") thanks.font = .body.bold() var website = AttributedString("Please visit our website.") website.font = .body.italic() website.link = URL(string: "http://www.example.com")
To assign attributes to a range, use slicing:
let msg = AttributedString() let start = msg.startIndex let after5 = msg.index(start, offsetByCharacters: 5) msg[start ..< after5].foregroundColor = .blue
You can also find a range by looking for a substring in the text:
var website = AttributedString("Please visit our website.") if let range = message.range(of: "visit") { message[range].font = .body.italic().bold() message.characters.replaceSubrange(range, with: "surf") }
It may also be useful to hold a set of attributes separately from the string itself and then apply it to one or more strings at some point – for that you can use an AttributeContainer
:
var container = AttributeContainer() container.foregroundColor = .red container.underlineColor = .primary website.mergeAttributes(container)
Iterating over an attributed string:
To iterate over the string's contents, you can use one of the provided "views" into the string:
- characters, which provides a collection of separate characters
- runs, which lists ranges of text, each having a single uniform set of attributes
These views are Swift collections, so you can use any standard collection methods on them
Here, we iterate over the collection of characters in a string, modifying the attributes of some of them:
let characters = message.characters for i in characters.indices where characters[i].isPunctuation { message[i ..< characters.index(after: i)].foregroundColor = .orange }
Now, assuming we have an attributed string like this:
var line1 = AttributedString("Thank you! ") line1.font = .boldSystemFont(ofSize: 10) var line2 = AttributedString("Please visit our website") // make the word "website" a link let range = line2.range(of: "website")! line2[range].link = URL(string: "http://example.com") let message = line1 + line2
If we iterate over the string's runs collection like this, we get 3 items, because there are three distinct sections in the string:
let parts = message.runs.map { run in String(message.characters[run.range]) } // => ["Thank you! ", "Please visit our ", "website"]
However, we can also divide the string into runs only by looking at one specific attribute, e.g. .link
– in this case, there will be only two separate runs:
let parts = message.runs[\.link].map { (value, range) in String(message.characters[range]) } // => ["Thank you! Please visit our ", "website"]
The value provided for each one will be of URL?
type in this case – the first run will have a value of nil
, and the second one will have the URL we've assigned earlier:
for (value, range) in message.runs[\.link] { if let url = value { print(url.host!) // => "example.com" } }
Attributed string localization:
Attributed strings are fully localizable
The old ObjC-based NSAttributedString
now also has localization support added
Attributed string localizations are listed in .strings
files, just like for normal strings
Both plain strings and attributed strings can now be localized in Swift code using String interpolation, in a similar way to how it's done in SwiftUI:
func prompt(for document: String) -> AttributedString { AttributedString(localized: "Would you like to save the document '\(document)'?") }
Xcode can now collect localizable strings for the .strings
files from such initializers in your code using the Swift compiler
You can turn this on with the build setting: "Localization > Use Compiler to Extract Swift Strings"
Markdown support:
Attributed strings now automatically parse Markdown content and generate attributes from tags like **bold**
:
struct ReceiptView: View { var body: some View { VStack { Text("**Thank you!**") Text("_Please visit [our website](https://example.com)._") } } }
Conversion and archiving:
The new struct-based attributed strings can be easily converted to and from the old ObjC-based reference types:
let nsString = NSAttributedString(message) let swiftString = AttributedString(nsString)
AttributedString
conforms to Codable
, so you can encode a string with all its attributes into whatever form that Codable
supports
Custom attributes:
Apart from the built-in string attributes provided by AppKit, UIKit, SwiftUI and other system frameworks, you can also define your own attributes
A string attribute consists of a key and a value
The key is a type the conforms to the AttributedStringKey
protocol
It defines the type used for the value (through the associated type Value
) and the name used for archiving (static var name
)
The key can also customize encoding and decoding by conforming to some other protocols
Here, we define a "rainbow" attribute which colors the range of the text with rainbow colors, whose value is one of the three enum cases that set the intensity:
enum RainbowAttribute: AttributedStringKey { enum Value: String { case plain case fun case extreme } public static var name = "rainbow" }
If we want the attribute to be encoded & decoded with the string, it needs to be Codable
:
enum RainbowAttribute: CodableAttributedStringKey { enum Value: String, Codable { case plain case fun case extreme } public static var name = "rainbow" }
You can also make the custom attribute available to the Markdown parser, using a special custom attribute syntax
To use an attribute in Markdown, it needs to also conform to MarkdownDecodableAttributedStringKey
The attribute is added to a range of text using a similar syntax like images and URLs:
This text contains ^[an attribute](rainbow: 'extreme'). This text contains ^[two attributes](rainbow: 'extreme', otherValue: 42). This text contains ^[an attribute with 2 properties](someStuff: {key: true, key2: false}).
The part in the square brackets is the marked text, and the part in parentheses are the attributes
The attribute values are written as a JSON5 object
Support for JSON5 parsing was also added to existing JSON parsing APIs like JSONSerialization
and JSONDecoder
(you need to enable a proper option first):
let json5text = "{ foo: 'bar' }" let json5data = json5text.data(using: .utf8)! let decoded = JSONSerialization.jsonObject(with: json5data, options: .json5Allowed)
The names of attributes from the attributes JSON object are looked up in "attribute scopes"
An attribute scope defines a list of attributes from one domain, e.g. SwiftUI or your app
When creating an AttributedString
from Markdown, you need to specify one single attribute scope from which attributes will be looked up
However, attribute scopes can be nested in one another, so you can include e.g. a scope of all SwiftUI attributes inside your scope (which in turn includes Foundation attributes)
An attribute scope is defined as a type conforming to AttributeScope
, nested inside the AttributeScopes
namespace:
extension AttributeScopes { // attribute scope for our "Caffe" app struct CaffeAppAttributes: AttributeScope { // this is our "rainbow" attribute let rainbow: RainbowAttribute // here, we include standard SwiftUI attributes let swiftUI: SwiftUIAttributes } // expose our attribute scope on a keypath in AttributeScopes var caffeApp: CaffeAppAttributes.Type { CaffeAppAttributes.self } } // now, pass the keypath to our scope when creating a string: let header = AttributedString( localized: "^[Fast & Delicious](rainbow: 'extreme') Food", including: \.caffeApp )
To actually render a text marked with custom attributes in a specific way, you need to create a custom view that takes an AttributedString
with those attributes and displays it – see the example RainbowText
SwiftUI view in the "Building a Localized Food-Ordering App sample code project
New formatter APIs
Formatters are used for converting values like dates or numbers into localized and user-presentable strings
Current formatters (NSFormatter
) are quite heavy objects, so it's a common pattern to create them once, cache them and reuse them, often across different parts of the app, which isn't always convenient
This year, there is a completely new API for formatters in Swift, which is aimed to improve both their usability and efficiency
The formatting is now done by calling a format method on the formatted value directly
Instead of formatting a date with a date formatter:
let dateFormatter = DateFormatter() dateFormatter.dateStyle = .medium dateFormatter.timeStyle = .medium ... dateLabel.text = dateFormatter.string(from: quakeTime)
We ask the date for a formatted representation:
dateLabel.text = quakeTime.formatted(date: .abbreviated, time: .standard)
Formatting floating point numbers, before:
magnitudeLabel.text = String(format: "%.1f", magnitude)
after:
magnitudeLabel.text = magnitude.formatted(.number.precision(.fractionLength(1)))
This is not only more readable, but also safer – the old version requires using string format specifiers that have to be written in a specific way, aren't checked by the compiler and can't be easily remembered, and if the value is not a floating point number – you will get a wrong output instead of a compiler error
You also now get autocompletion in Xcode for the formatting options
There are new formatting API equivalents for all existing formatters in Foundation – for lists, date components and intervals, measurements, data sizes etc.
They're designed to help you avoid some common pitfalls when using string-based format specifiers, like using a wrong date format field that only fails in some specific cases
Formatting dates:
Simplest version:
let formatted = date.formatted() // => "6/7/2021, 9:42 AM"
Configure time and date styles:
let onlyDate = date.formatted(date: .numeric, time: .omitted) // => "6/7/2021" let onlyTime = date.formatted(date: .omitted, time: .shortened) // => "9:42 AM"
List specific fields to include:
let formatted = date.formatted(.dateTime.year().day().month()) // => "Jun 7, 2021"
Customize field styles:
let formattedWide = date.formatted(.dateTime.year().day().month(.wide)) // => "June 7, 2021" let weekday = date.formatted(.dateTime.weekday(.wide)) // => "Monday"
Standardized formats:
let logFormat = date.formatted(.iso8601) // => "20210607T164200Z" let fileNameFormat = date.formatted(.iso8601.year().month().day().dateSeparator(.dash)) // => "2021-06-07"
Setting a specific locale:
let formatted = date.formatted(.dateTime.locale(myLocale))
The general pattern is:
- start with the value to be formatted
- call one of the
formatted
methods - pass a style in the argument (e.g. for dates it's
.dateTime
or.iso8601
) - call methods on the format to customize it
The order of the fields in the chained call doesn't matter – it just lists the fields that should be included somewhere in the final output, and the formatter decides on the order based on the locale
Formatting date ranges:
let range = (now..<later).formatted() // => "6/7/21, 9:42 - 11:05 AM" let noDate = (now..<later).formatted(date: .omitted, time: .complete) // => "9:42:00 AM PDT - 11:05:20 AM PDT" let timeDuration = (now..<later).formatted(.timeDuration) // => "1:23:20" let components = (now..<later).formatted(.components(style: .wide)) // => "1 hour, 23 minutes, 20 seconds" let relative = later.formatted(.relative(presentation: .named, unitsStyle: .wide)) // => "in 1 hour"
Formatting values as attributed strings:
You can ask a formatter to create an attributed string instead by calling .attributed()
The generated attributed string does not have any visual styles applied to it, but it marks all ranges of text that contain specific fields like month or year with formatter-specific properties
You can then analyze the returned AttributedString
and use these format properties to mark different tokens within the generated string with different colors or text styles
var dateString: AttributedString { var str = date.formatted(.dateTime .minute() .hour() .weekday() .locale(locale) .attributed()) // alternative way to create an AttributeContainer let weekday = AttributeContainer.dateField(.weekday) let color = AttributeContainer.foregroundColor(.orange) // we can ask an AttributedString to replace the attributes // in one container set with those in another container // here, the text range marked with a "weekday" attribute // will have an orange text color applied to it str.replaceAttributes(weekday, with: color) return str }
Parsing dates from strings:
To convert the values the other way, you can use a new Date
initializer that takes a "strategy" argument
The strategy is an object that tells the parser what kind of fields to expect in the input
You can use one of the format objects shown earlier as a parsing strategy:
let format = Date.FormatStyle().year().day().month() let formatted = date.formatted(format) // formatted is "Jun 7, 2021" if let date = try? Date(formatted, strategy: format) { // date is 2021-06-07 07:00:00 +0000 }
You can also use a more specialized strategy object, like this one for parsing dates sent from the server in a specific strict format:
let strategy = Date.ParseStrategy( format: "\(year: .defaultDigits)-\(month: .twoDigits)-\(day: .twoDigits)", timeZone: TimeZone.current ) if let date = Date("2021-06-07", strategy: strategy) { // date is 2021-06-07 07:00:00 +0000 }
Instead of using magic string format specifiers, ParseStrategy
lets you specify the fields using string interpolation – checking the format at compile time, and helping you in Xcode with autocomplete and inline documentation
"No more guessing how many Y characters you should use to parse a year"
Formatting numbers:
let value = 12345 var formatted = value.formatted() // => "12,345" let percent = 25 let percentFormatted = percent.formatted(.percent) // => "25%" let scientific = 42e9 let sciFormatted = scientific.formatted(.number.notation(.scientific)) // => "4.2E10" let price = 29 let priceFormatted = price.formatted(.currency(code: "usd")) // => "$29.00"
Formatting lists:
To format a list of things, call .formatted()
on an array and specify which of the formats should be used for each member:
let list = [25, 50, 75].formatted(.list(memberStyle: .percent, type: .or)) // => "25%, 50%, or 75%"
Formatting text field content in SwiftUI:
In SwiftUI, you can assign a format to a text field
The text field will automatically parse and reformat the value entered by the user, and if a correct value of a given type can be parsed from the input, it will assign it through the binding to the connected state property as e.g. a number or a date value:
struct ReceiptTipView: View { @State var tip = 0.15 var body: some View { HStack { Text("Tip") Spacer() TextField("Amount", value: $tip, format: .percent) } } }
Automatic grammar agreement
In some languages, like in Spanish, there needs to be a grammatical agreement between the words in a sentence, e.g. in a phrase "two small salads" the number, the noun and the adjective need to have not only the same pluralization, but also same grammatical gender
This makes it often extremely complex to provide full and correct localization for all combinations of UI strings, and either makes the localization process more time-consuming, or makes the translation less correct or less natural
E.g. instead of just translating "small", "large", "juice" and "salad" you need each combination of "small juice", "large salad" etc. (and then add another dimension for pluralizations)
Foundation in iOS 15 adds a new feature called "automatic grammar agreement" that handles a lot of these problems automatically (available in English and Spanish at the moment)
You can now write a string like this:
Text("Add ^[\(quantity) \(size) \(food)](inflect: true) to your order")
The custom attribute Markdown syntax for Attributed Strings is used to mark a part of the text that needs to be automatically inflected with the "inflect" attribute
This will be exported to an English strings file like this:
"Add ^[%lld %@ %@](inflect: true) to your order" = "Add ^ [%lld %@ %@](inflect: true) to your order"; "Pizza" = "Pizza"; "Juice" = "Juice"; "Salad" = "Salad"; "Small" = "Small"; "Large" = "Large";
And to a Latin American Spanish strings file like this:
/* in Spanish the order of the noun and adjective is reversed */ "Add ^[%lld %@ %@](inflect: true) to your order" = "Añadir ^[%1$lld %3$@ %2$@](inflect: true) a tu pedido"; "Pizza" = "Pizza"; "Juice" = "Jugo"; "Salad" = "Ensalada"; "Small" = "Pequeño"; "Large" = "Grande";
The key change is that you don't need to provide separate translations for e.g. "pequeño" (masculine) and "pequeña" (feminine) – the grammar agreement engine handles this automatically
User's term of address:
In some languages some of the localized text also needs to change depending on the person reading it, because it needs to be adjusted for the person's term of address (depending on the gender)
Now, users using supported languages (currently Spanish) can specify their term of address in the iOS Settings app, under "Language & Region", and can choose to share it with apps
This is done the same way as in the previous example:
^[Bienvenido](inflect: true) a Notas
Such text can be displayed as "Bienvenido…" or "Bienvenida…" depending on the user's gender
You can also provide a default text to be used if this information is not available:
^[Bienvenido](inflect: true, inflectionAlternative: "Te damos la bienvenida") a Notas