MacKuba

🍎 Kuba Suder's blog on Mac & iOS development

WWDC 21

What's new in SwiftUI

Categories: SwiftUI 0 comments Watch the video

Async images

Built-in support for loading images asynchronously:

AsyncImage(url: photo.url)

AsyncImage provides a default placeholder, but it can also be customized:

AsyncImage(url: photo.url) { image in
    image
        .resizable()
        .aspectRatio(contentMode: .fill)
} placeholder: {
    Color.blue
}

Custom animations and error handling:

AsyncImage(
    url: photo.url,
    transaction: .init(animation: .spring())
) { phase in
    switch phase {
        case .empty: ...
        case .success(let image): ...
        case .failure(let error): ...
    }
}

Improvements to lists

Pull to refresh:

Support for pull-to-refresh in lists on iOS:

List {
    ...
}
.refreshable {
    await items.reload()
}

Tasks:

Asynchronous task associated with a view:

.task {
    await items.load()
}

The task is run when the view appears and is automatically cancelled when the view is removed

This can be also used to load data from an async sequence that produces items continuously for the whole duration of the view being visible

See more about new concurrency features in "Discover concurrency in SwiftUI"

Bindings to list items:

A List can now accept a binding to an array property and will pass a binding to each specific item to the closure

This lets you use controls that require a binding (like TextField) within list items:

List($items) { $item in
    Text(item.title)
    TextField("Item", text: $item.text)
}

The previous solution for cases like this was to iterate over list indexes and bind to e.g. $list[index].text, but this causes SwiftUI to reload the list after every change anywhere

This is actually a Swift language change, so it works in all previous versions of SwiftUI too

Also works in ForEach and some other places

Customizing lists:

.listRowSeparatorTint(Color.blue) β€“ customizing the color of separators

β†’ can be defined on the item to give each item a different separator

.listRowSeparator(.hidden) β€“ hide separators completely

Same for customizing separators between sections: .listSectionSeparator, .listSectionSeparatorTint

Swipe actions:

You can now define custom swipe actions on list items:

CharacterProfile(character)
.swipeActions {
    Button {
        character.isPinned.toggle()
    } label: {
        Label("Pin", systemImage: "pin")
    }
    .tint(.yellow)
}

By default the action is on the right edge β€“ use .swipeActions(edge: .leading) to put the action on the left edge

Add multiple .swipeActions modifiers to include swipe actions on both sides

Improvements on the Mac:

List style with alternating backgrounds, like in standard NSTableView:

.listStyle(.inset(alternatesRowBackgrounds: true))

New control for full-featured multi-column tables:

Table(characters) {
    TableColumn("<>") { CharacterIcon($0) }
      .width(20)
    TableColumn("Villain") { Text($0.isVillain ? "Villain" : "Hero") }
      .width(40)
    TableColumn("Name", value: \.name)
    TableColumn("Powers", value: \.powers)
}

Like a List, a Table presents a list of rows rendered from the given content, except a Table has a defined list of columns

Table supports single- and multi-row selection:

@State private var singleSelection: StoryCharacter.ID?
@State private var multiSelection = Set<StoryCharacter.ID>()

Table(characters, selection: $multiSelection) { ... }

You can sort the table by selected column β€“ to support sorting in a column, provide a key-path to a model value which should be used for sorting:

@State private var sortOrder = [KeyPathComparator(\StoryCharacter.name)]

Table(characters, selection: $selection, sortOrder: $sortOrder) {
    TableColumn("Name", value: \.name)
    ...
}

Tables also support various visual styles and allow you to customize the appearance of each column

Integration with Core Data fetch requests:

Fetch requests now provide a binding to their sort descriptors, which lets you build a sortable multi-column table backed by a Core Data request:

@FetchRequest(sortDescriptors: [SortDescriptor(\.name)])
private var characters: FetchedResults<StoryCharacter>

Table(characters, selection: $selection, sortOrder: $characters.sortDescriptors) {
    ...
}

New @SectionedFetchRequest which lets you build a list divided into sections from a Core Data request:

@SectionedFetchRequest(
    sectionIdentifier: \.isPinned,
    sortDescriptors: [
        SortDescriptor(\.isPinned, order: .reverse),
        SortDescriptor(\.lastModified)
    ],
    animation: .default
)
private var characterSections: SectionedFetchResults<...>

The value of the property is a list of sections, each containing a list of items:

List {
    ForEach(characterSections) { section in
        Section(section.id)
        ForEach(section) { character in
            CharacterRowView(character)
        }
    }
}

Support for search in lists:

.searchable(text: $characters.filterText)

SwiftUI automatically adds a search field in the appropriate place depending on the platform, and may automatically provide suggestions based on the context

The modifier takes a binding to the entered filter text, which you should use to filter the results


Sharing data with other apps

Support for customized previews during drag & drop operation:

CharacterIcon(character)
.onDrag {
    character.itemProvider
} preview: {
    CharacterDragPreview(character)
}

New ways to import and export data to/from your app using Item Providers

Importing items from outside:

.importsItemProviders(StoryCharacter.imageAttachmentTypes) { itemProviders in
    guard let firstItem = itemProviders.first else { return false }

    Task {
        selectedCharacter.imageAttachment = await StoryCharacter.loadImage(from: firstItem)
    }

    return true
}

This lets you e.g. import an image taken with an iPhone's camera to a Mac app using the Continuity Camera feature

The new ImportFromDevicesCommands() helper lets you add the necessary menu entries (File > Import from iPhone or iPad):

WindowGroup { ... }
    .commands {
        ImportFromDevicesCommands()
    }
}

Exporting items to another app, e.g. to Shortcuts:

.exportsItemProviders(StoryCharacter.contentTypes) {
    if let selectedCharacter = selectedCharacter {
        return [selectedCharacter.itemProvider]
    } else {
        return []
    }
}

Graphics

SF Symbols:

Many new symbols

Two new rendering modes:

  • hierarchical β€“ uses one color like monochrome, but adds multiple levels of opacity to emphasize key elements of the symbol
  • palette β€“ gives you fine grained control over which parts of the symbol should be filled with what color

New SwiftUI colors: mint, teal, cyan, indigo, brown

Symbol variants like "filled" are now automatically chosen for you depending on the context

So if e.g. filled symbols should be used in tab bars, you can just use "person.circle" instead of "person.circle.fill" and the framework will choose the right variant automatically

This makes your code more reusable β€“ e.g. you can use the same tab bar on macOS and it will use the outline versions of the same symbols there

Canvas view:

A new view that gives you a rectangular canvas for free drawing, like custom NSViews/UIViews with drawRect: in AppKit/UIKit

You can use this to draw a specific custom element, or to draw multiple elements like a grid of small icons that don't need individual tracking and invalidation

Canvas { context, size in
    for symbol in symbols {
        let image = context.resolve(symbol.image)
        context.draw(image, in: symbol.rect)
    }
}

To provide accessibility to a Canvas element, you can use the new .accessibilityChildren modifier:

Canvas {
    ...
}
.accessibilityChildren {
    List(symbols) { Text($0.name) }
}

This provides a completely separate view hierarchy to accessibility interfaces which is more readable than the actual view

Timeline view:

TimelineView is a new view that generates different content based on the time of rendering

Can be used to build e.g. an animated screen saver for tvOS

TimelineView(.animation) {
    let time = $0.date.timeIntervalSince1970

    Canvas { context, size in
        // draw items differently based on the time parameter
    }
}

The timeline takes a TimelineSchedule parameter, which specifies when or how often it should be updated

Timeline view can also be used to provide a view for a Watch app which automatically updates when the app is in dimmed mode on always-on displays

Also in the dimmed mode on watchOS you can use the new .privacySensitive modifier to blur out elements which can include private information and should be hidden when the Watch is not being used:

VStack(alignment: .center) {
    Text("Favorite Symbol")
    Image(systemName: favoriteSymbol)
        .privacySensitive(true)
}

The .privacySensitive modifier can also be used in widgets β€“ in this case some content may be hidden (blurred) when the widget is shown on the lock screen while the device is locked

Materials:

You can now create views with material backgrounds (the ones with a translucent blur effect):

VStack {
    Text("Symbol Browser")
        .font(.largeTitle)
    Text("\(symbols.count) symbols")
        .foregroundStyle(.secondary)
        .font(.title2)
}
.padding()
.background(.ultraThinMaterial,
  in: RoundedRectangle(cornerRadius: 16.0))

When you use semantic styles like .foregroundStyle(.secondary) for the text, the content of a container with material background uses the appropriate "vibrancy" effect

The effect is applied to text, with the exception of emoji, which are excluded because this would render them an in incorrect way

On the Mac, system controls like sidebars and popups use the material background and now also apply the vibrancy effect to their content

More about canvas and materials in the "Add rich graphics to your SwiftUI app" talk

You can also read more about vibrancy and materials in my old blog post about the introduction of dark mode in macOS Mojave β€“ "Dark Side of the Mac β€“ Appearance & Materials"

Safe area inset:

There is a new view modifier .safeAreaInset that allows you to position a view on top of a ScrollView in a way that adjusts the scroll view's top/bottom insets so that all the content can be reached and the beginning/end isn't obscured by the overlay:

ScrollView {
    ...
}
.safeAreaInset(edge: .bottom, spacing: 0) {
    VStack {
        Text("\(symbols.count) symbols selected")
    }
    .padding()
    .background(.regularMaterial)
}

See more in the "Add rich graphics to your SwiftUI app" talk

Preview enhancements:

New .previewInterfaceOrientation modifier for choosing portrait/landscape orientation:

static var previews: some View {
    ColorList()
        .previewInterfaceOrientation(.vertical)

    ColorList()
        .previewInterfaceOrientation(.horizontal)
}

The Attributes Inspector in the preview now includes a section for accessibility attributes, and there is a new inspector tab that shows the accessibility properties of all view elements at a glance


Text and keyboard

Markdown support:

Text now automatically interprets Markdown formatting:

Text("**Hello**, [WWDC](https://developer.apple.com/wwdc21)!")
Text("`print(helloText)`")

This is built on top of the new Swift-native AttributedString in Foundation

It has a new rich, type-safe API for adding attributes, and even allows defining custom attributes that can be applied to text through the Markdown syntax

See more in "What's new in Foundation"

Localization:

Xcode 13 can now automatically extract strings for localization using the Swift compiler (see option in build settings)

See more in "Localize your SwiftUI app"

Dynamic Type:

You can now restrict some content to only a specified range of Dynamic Type text sizes β€“ so that when the user picks one of the extra large sizes which would make your text too large to fit, it stays at the maximum allowed size:

Header("Today's Activities")
    .dynamicTypeSize(.large .. .extraExtraLarge)

Text selection:

You can enable selection of non-editable text:

Text(activity.description)
    .textSelection(.enabled)

This allows the user to select and copy any part of the text on macOS

On iOS this only allows copying the whole text though

New formatter APIs:

Foundation now includes new .formatted() APIs for various values like dates that allow you to specify the format in a type-safe way:

Text(date.formatted())

Text(date.formatted(date: .omitted, time: .shortened))

Text(date.formatted(
    .dateTime.weekday(.wide).day().month().hour().minute()))

There's even a list formatter API like ListFormatter:

people.map(\.nameComponents).formatted(
    .list(memberStyle: .name(style: .short), type: .and))

// => "Matt, Jacob, and Taylor"

Format styles can also be specified in TextField:

@State private var newAttendee = PersonNameComponents()

var body: some View {
    ...
    TextField("New Person", value: $newAttendee, format: .name(style: .medium))
    ...
}

The entered text is automatically validated and reformatted according to the specified format, and then parsed into a value of a correct type if possible

See more about the new format styles in "What's new in Foundation"

Text field prompts:

TextField now includes a prompt: parameter, which allows you to specify a placeholder value (an example text to enter) that is different from the label (which normally should specify the name or meaning of the field)

Text fields inside a Form on macOS now display with labels outside on the left, right-aligned to the text field, with the prompt (if any) shown as the placeholder

β†’ previously they were rendered like on iOS, with the label used as the placeholder inside the field

Text field submission:

New .onSubmit modifier allows you to specify an action to execute when a text field is submitted by pressing a physical or virtual Return key:

TextField(
    "New Person",
    value: $newAttendee,
    format: .name(style: .medium)
)
.onSubmit {
    people.append(Person(newAttendee))
}

This can also be applied to the entire form:

Form {
    TextField("Username", text: $viewModel.userName)
    SecureField("Password", text: $viewModel.password)
}
.onSubmit(of: .text) {
    guard viewModel.validate() else { return }
    viewModel.login()
}

You can now also configure the title of the Return key on the iOS keyboard (which gives it a blue background):

TextField(...)
.onSubmit {
    ...
}
.submitLabel(.done)

Keyboard accessory views:

Adding accessory views to the iOS keyboard β€“ configure toolbar items with the placement of .keyboard:

Form {
    ...
}
.toolbar {
    ToolbarItemGroup(placement: .keyboard) {
        Button(action: selectPreviousField) {
            Label("Previous", systemImage: "chevron.up")
        }
        .disabled(!hasPreviousField)

        Button(action: selectNextField) {
            Label("Next", systemImage: "chevron.down")
        }
        .disabled(!hasNextField)
    }
}

β†’ on macOS the buttons will be shown in the Touch Bar instead

Focus state:

New property wrapper @FocusState that reflects the state of focus and provides precise control over it

A @FocusState property is bound to a field's focus using .focused

The value of the property will then show if and which view is currently focused, and you can move the focus by modifying the value

Simple form: declare a boolean property, and it will be set to true when the view is focused:

@FocusState private var newPersonIsFocused: Bool

var body: some View {
    TextField("New Person", value: $newAttendee,
        format: .name(style: .medium)
    )
    .focused($newPersonIsFocused)

    Button {
        newPersonIsFocused = true
    } label: {
        Label("Add Attendee", systemImage: "plus")
    }
}

In a more advanced version, the focus property can use any Hashable as its type, e.g. an enum, and be used to show which of the possible fields is currently focused:

private enum Field: Int, Hashable {
    case name, location, date, addAttendee
}

@FocusState private var focusedField: Field?

var body: some View {
    TextField("New Person", value: $newAttendee,
        format: .name(style: .medium)
    )
    .focused(focusedField, equals: .addAttendee)

    Button {
        focusedField = .addAttendee
    } label: {
        Label("Add Attendee", systemImage: "plus")
    }
}

This allows you to build any kind of complex screen with multiple focusable fields that you may want to move between programmatically

You can also use .focused to dismiss the keyboard on iOS by unfocusing a focused field:

@FocusState private var newPersonIsFocused: Bool

var body: some View {
    TextField(...).focused($newPersonIsFocused)
}

func endEditing() {
    newPersonIsFocused = false
}

See more about the new focus API in "Direct and reflect focus in SwiftUI"


Buttons

Support for bordered buttons on iOS:

Button("Add") { ... }
    .buttonStyle(.bordered)

Like all modifiers, this can be added to a larger container and applies to all buttons inside

Choose a color for a bordered button by specifying a tint:

Button("Buy") { ... }
    .buttonStyle(.bordered)
    .tint(.green)

Make buttons smaller or larger by using the .controlSize modifier, previously used only on macOS:

ForEach(entry.tag) { tag in
    Button(tag.name) { ... }
        .tint(tag.color)
}
.controlSize(.small)

β†’ this only seems to work for buttons at the moment

You can also make "prominent" bordered buttons which use a style that stands out more (stronger background):

Button("Submit", action: submitForm)
    .buttonStyle(.borderedProminent)

ℹ️ In the video, this is shown as .controlProminence(.increased). The .controlProminence modifier was replaced in a later beta with a separate .borderedProminent button style.

Buttons with a .large size can be used to build a menu of action buttons at the bottom of a screen/form, with the default button marked as prominent:

VStack {
    Button(action: addtoJar) {
        Text("Add to Jar").frame(maxWidth: 300)
    }
    .buttonStyle(.borderedProminent)
    .keyboardShortcut(.defaultAction)

    Button(action: addToWatchlist) {
        Text("Add to Watchlist").frame(maxWidth: 300)
    }
    .tint(.accentColor)
    .buttonStyle(.bordered)
}
.controlSize(.large)

Note: do not add .borderedProminent to every single button on the screen β€“ this should be only used for single primary actions

β†’ this is the equivalent of a blue-colored default button in a dialog on macOS, one that is bound to the Return key shortcut, of which by definition there can be only one within a dialog

Buttons using the new styles like .controlSize, .borderedProminent and .tint automatically provide appropriate pressed states and automatically adapt to dark mode, Dynamic Type font sizes etc.

You can now specify a "role" for a button, currently .cancel or .destructive:

Button("Delete...", role: .destructive) { ... }

Marking a button as destructive adds a red foreground and/or background depending on the context, to emphasize that the action is potentially dangerous

Cancel and destructive buttons are often used in confirmation dialogs, which now have a new view modifier made specifically for them:

RowCell(entry)
.contextMenu {
    Button("Delete...", role: .destructive) {
        showConfirmation = true
    }
}
.confirmationDialog("Are you sure you want to delete \(title)?",
    isPresented: $showConfirmation
) {
    Button("Delete", role: .destructive) {
        deleteEntry(entry)
    }
} message:
    Text("Deleting \(title) will remove it from all of your jars.")
}

A confirmation dialog is shown as an action sheet on iOS, as a popover on the iPad, and as an alert on macOS

Another type of button is a menu button (available before):

Menu("Add") {
    ForEach(jarStore.allJars) { jar in
        Button("Add to \(jar.name)") {
            jarStore.add(buttonEntry, to: jar)
        }
    }
}
.menuStyle(.borderedButton)

ℹ️ In the video, this is shown as .menuStyle(.button). There is however no such menu style as .button β€“ use .borderedButton on macOS and .borderlessButton elsewhere (or just keep the default).

A bordered button menu is rendered as a button with a down arrow on the right on macOS (a pull-down menu button)

A new option for button menus (both macOS and iOS) is to provide a "primary action":

Menu("Add") {
    ForEach(jarStore.allJars) { jar in
        Button("Add to \(jar.name)") {
            jarStore.add(buttonEntry, to: jar)
        }
    }
} primaryAction: {
    jarStore.add(buttonEntry)
}

This makes the button act as both a normal button and a menu:

  • on iOS, a normal tap runs the primary action, and a long press shows the menu
  • on macOS, the button uses a slightly different style and lets you either click the main button part for the primary action, or click the arrow on the right to show the menu (without a primary action, clicking either of the parts shows the menu)

You can also choose to hide the arrow indicator of a menu button on macOS using .menuIndicator:

Menu("Add") {
    ...
}
.menuStyle(.borderedButton)
.menuIndicator(.hidden)

Such menu button looks like a completely plain button, but still acts as a menu button:

  • without a primary action, it always shows the menu when pressed
  • with a primary action, it runs the primary action when pressed, and to show the menu you need to do a long-press like on iOS

You can use such buttons with a hidden indicator to decrease the visual prominence of the button, e.g. if there are a lot of them in the same view

A Toggle can now also be displayed as a toggle button:

Toggle(isOn: $showOnlyNewFilter) {
    Label("Show Only New", systemImage: "sparkles")
}
.toggleStyle(.button)

This works like a toggle button on macOS (NSButton.ButtonType.toggle) β€“ one that cycles between an "on" state (with a blue background) and an "off" state when pressed; it works the same way on iOS β€“ in the "on" state it's rendered as a bordered button with a blue background

There is also a new control that groups buttons, mainly toolbar buttons β€“ ControlGroup:

ControlGroup {
    Button(action: archive) {
        Label("Archive", systemImage: "archiveBox")
    }
    Button(action: delete) {
        Label("Delete", systemImage: "trash")
    }
}

Toolbar buttons in a ControlGroup are displayed grouped together visually, as a kind of segmented control β€“ as opposed to buttons in ToolbarItemGroup, which are only arranged side by side, but not joined visually in any way

ControlGroup with menu buttons can be used to create a pair of standard back/forward navigation buttons:

ControlGroup {
    Menu {
        ForEach(history) { ... }
    } label: {
        Label("Back", systemImage: "chevron.backward")
    } primaryAction: {
        goBack(to: history[0])
    }
    .disabled(history.isEmpty)

    Menu {
        ForEach(forwardHistory) { ... }
    } label: {
        Label("Forward", systemImage: "chevron.forward")
    } primaryAction: {
        goForward(to: forwardHistory[0])
    }
    .disabled(forwardHistory.isEmpty)
}