MacKuba

🍎 Kuba Suder's blog on Mac & iOS development

WWDC 21

What's new in Foundation

Categories: Foundation, Swift 0 comments Watch the video

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