WWDC Quick Look 💓 By SwiftGGTeam
What's new in SwiftUI

What's new in SwiftUI

Watch original video

Highlight

SwiftUI in 2021 adds dozens of APIs—AsyncImage, refreshable, task, Table, searchable, Canvas, Materials, FocusState—covering list interaction, graphics, text, button styles, and keyboard navigation for richer cross-platform experiences with less code.

Core Content

Loading network images is basic iOS work. You used to hand-roll URLSession + UIImageView or add third-party libraries. Placeholders, failure handling, pull-to-refresh—every piece was DIY.

SwiftUI now ships AsyncImage—pass a URL for automatic download and display with default placeholders. With refreshable and task, pull-to-refresh and async page loading become declarative one-liners.

For list interaction, editable TextFields per row used to require index-based bindings that redrew the whole list. SwiftUI now accepts binding arrays in List—closures get per-element bindings directly. swipeActions adds custom left/right swipe operations via Button.

macOS finally gets Table. Declare columns with TableColumn; single/multi-select and sorting—a Finder-style file list in a few lines. @FetchRequest gains sort descriptor bindings for Core Data tables.

Search no longer needs custom UI. searchable on NavigationView places the search field correctly per platform—from Apple TV to Apple Watch with one codebase.

For graphics, SF Symbols adds Hierarchical and Palette rendering; tab bar symbol variants adapt per platform. Canvas supports immediate-mode drawing; TimelineView enables animation. Materials via .ultraThinMaterial etc. handle vibrancy and Dark Mode automatically.

Text gets native Markdown—Text("**Hello**, World!") renders bold directly. AttributedString offers type-safe attributes. TextField supports format styles and prompts; keyboard toolbars are customizable; FocusState controls form focus precisely.

Button styles expand greatly. .bordered for standard bordered buttons; .controlSize(.large) and .controlProminence(.increased) control size and visual hierarchy. Menu supports primary action; Toggle can use .toggleStyle(.button); ControlGroup groups related buttons.

Detailed Content

AsyncImage: Load Network Images in One Line

(03:22

Loading remote images used to mean managing URLSession tasks, caching, and placeholder logic. SwiftUI now provides AsyncImage directly.

struct ContentView: View {
  @StateObject private var photoStore = PhotoStore()

  var body: some View {
    NavigationView {
      ScrollView {
        LazyVGrid(columns: [GridItem(.adaptive(minimum: 420))]) {
          ForEach(photoStore.photos) { photo in
            AsyncImage(url: photo.url)
              .frame(width: 400, height: 266)
              .mask(RoundedRectangle(cornerRadius: 16))
          }
        }
        .padding()
      }
      .navigationTitle("Superhero Recruits")
    }
    .navigationViewStyle(.stack)
  }
}

class PhotoStore: ObservableObject {
  @Published var photos: [Photo] = []
}

struct Photo: Identifiable {
  var id: URL { url }
  var url: URL
}

Key points:

  • AsyncImage(url:) takes a URL, automatically fetches and displays the image
  • Default gray placeholder; switches automatically when loaded
  • With LazyVGrid, images load when scrolled into view
  • Available on iOS, macOS, watchOS, and tvOS

Want custom placeholders and loading animation? Use the full closure form:

AsyncImage(url: photo.url, transaction: .init(animation: .spring())) { phase in
  switch phase {
  case .empty:
    randomPlaceholderColor()
      .opacity(0.2)
      .transition(.opacity.combined(with: .scale))
  case .success(let image):
    image
      .resizable()
      .aspectRatio(contentMode: .fill)
      .transition(.opacity.combined(with: .scale))
  case .failure(let error):
    ErrorView(error)
  @unknown default:
    ErrorView()
  }
}

Key points:

  • transaction passes animation config for load transitions
  • phase enum has .empty, .success, .failure
  • Return different views per phase for custom placeholders and errors

refreshable and task: Declarative Async Operations

(04:23

Pull-to-refresh is common for lists. You used to integrate UIRefreshControl and manage begin/endRefreshing. Now use refreshable:

List {
  ForEach(photoStore.photos) { photo in
    AsyncImage(url: photo.url)
      .frame(minHeight: 200)
      .listRowSeparator(.hidden)
  }
}
.refreshable {
  await photoStore.update()
}

Key points:

  • refreshable configures refresh action via environment
  • List on iOS and iPadOS shows pull-to-refresh automatically
  • await means async operation without blocking UI

Use task for initial page data loading:

List {
  // ...
}
.refreshable {
  await photoStore.update()
}
.task {
  await photoStore.update()
}

Key points:

  • task starts async work on appear, cancels on disappear
  • Good for initial load and live data listening
  • Used with AsyncSequence, it can continuously receive data updates:
.task {
  for await photo in photoStore.newestPhotos {
    photoStore.push(photo)
  }
}

List Binding Elements and Interaction Enhancements

(08:08

When list rows need editable data, index-based bindings redrew the whole list. Now pass binding arrays directly to List:

List($directions) { $direction in
  Label {
    TextField("Instructions", text: $direction.text)
  } icon: {
    DirectionsIcon(direction)
  }
}

Key points:

  • $directions makes array bindings; $direction is per-element binding
  • Read-only code unchanged; edit with $direction.text
  • Swift language feature; back-deploys to earlier SwiftUI versions
  • Works in ForEach too

List separators are customizable too:

.listRowSeparatorTint(direction.color)  // Change the separator color
.listRowSeparator(.hidden)              // Hide the separator

Swipe actions make list interaction more flexible:

ForEach(characters) { $character in
  CharacterProfile(character)
    .swipeActions(edge: .leading) {
      Button {
        togglePinned(for: $character)
      } label: {
        Label(character.isPinned ? "Unpin" : "Pin", systemImage: character.isPinned ? "pin.slash" : "pin")
      }
      .tint(.yellow)
    }
    .swipeActions(edge: .trailing) {
      Button(role: .destructive) {
        delete(character)
      } label: {
        Label("Delete", systemImage: "trash")
      }
    }
}

Key points:

  • swipeActions defines actions with Button; color via tint
  • edge controls left or right side
  • Can add both leading and trailing action groups
  • role: .destructive uses red for delete

macOS Exclusive: Multi-Column Table

(12:13

macOS apps often need tabular data. SwiftUI adds Table this year:

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

Key points:

  • Table takes a collection; TableColumn defines each column
  • value: \.name shorthand displays text via key path
  • Or pass closures for custom column content

Table supports single and multi-select:

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

Table(characters, selection: $singleSelection) {
  // ...columns
}

Header click sorting is supported too:

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

Table(sorted ?? characters, selection: $selection, sortOrder: $sortOrder) {
  // ...columns
}
.onChange(of: characters) { sorted = $0.sorted(using: sortOrder) }
.onChange(of: sortOrder) { sorted = characters.sorted(using: $0) }

Key points:

  • sortOrder is binding; updates when user clicks headers
  • KeyPathComparator specifies sort field via key path
  • With @FetchRequest sort binding, Core Data tables need few lines

(15:20

Search is cross-platform—from Apple TV to Apple Watch. SwiftUI provides unified searchable:

NavigationView {
  List {
    if characters.filterText.isEmpty {
      Section("Pinned") { sectionContent(for: characters.pinned) }
      Section("Heroes & Villains") { sectionContent(for: characters.unpinned) }
    } else {
      sectionContent(for: characters.filtered)
    }
  }
  .listStyle(.sidebar)
  .searchable(text: $characters.filterText)
  .navigationTitle("Characters")
}

Key points:

  • searchable(text:) takes binding; places search field per platform
  • iOS below nav bar, macOS in toolbar, Apple TV at top
  • Combine with search suggestions for richer search

Canvas and TimelineView: High-Performance Custom Drawing

(21:31

Drawing many graphical elements is expensive with regular SwiftUI views. Canvas provides immediate-mode drawing like UIKit’s drawRect:

Canvas { context, size in
  let metrics = gridMetrics(in: size)
  for (index, symbol) in symbols.enumerated() {
    let rect = metrics[index]
    let image = context.resolve(symbol.image)
    context.draw(image, in: rect.fit(image.size))
  }
}

Key points:

  • Canvas takes draw closure; context provides drawing API
  • context.resolve() converts SwiftUI Image to drawable
  • Good for many geometric shapes without per-shape state

Canvas can attach gestures and accessibility:

Canvas { context, size in
  // Drawing code...
}
.gesture(DragGesture(minimumDistance: 0).updating($focalPoint) { value, focalPoint, _ in
  focalPoint = value.location
})
.accessibilityLabel("Symbol Browser")
.accessibilityChildren {
  List(symbols) { Text($0.name) }
}

Animate with TimelineView:

TimelineView(.animation) { timeline in
  let time = timeline.date.timeIntervalSince1970
  Canvas { context, size in
    // Use time to update drawing parameters...
  }
}

Key points:

  • TimelineView redraws on schedule; .animation updates every frame
  • Good for tvOS screensavers, watchOS Always On, etc.
  • privacySensitive(true) hides sensitive content in Always On

Materials and safeAreaInset

(25:03

Materials were system-only; now available to developers:

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

Key points:

  • .ultraThinMaterial, .thinMaterial, etc. control blur level
  • Auto vibrancy; .primary through .quaternary work correctly on materials
  • Emoji excluded from vibrancy processing

safeAreaInset keeps floating toolbars from covering content:

ScrollView {
  symbolGrid
}
.safeAreaInset(edge: .bottom, spacing: 0) {
  VStack(spacing: 0) {
    Divider()
    Text("\(newSymbols.count) new symbols")
      .foregroundStyle(.primary)
      .font(.body.bold())
  }
  .padding()
  .background(.regularMaterial)
}

Key points:

  • safeAreaInset inserts content at scroll edge; content avoids the area
  • Good for bottom toolbars and floating buttons

Text and Formatting

(27:15

Text now supports Markdown:

Text("**Hello**, World!")
Text("""
Have a *happy* [WWDC](https://developer.apple.com/wwdc21/)!
""")

AttributedString provides type-safe attribute operations:

var formattedDate: AttributedString {
  var result = Date().formatted(
    Date.FormatStyle()
      .day()
      .month(.wide)
      .weekday(.wide)
      .attributed
  )

  let weekday = AttributeContainer.dateField(.weekday)
  let color = AttributeContainer.foregroundColor(.orange)
  result.replaceAttributes(weekday, with: color)

  return result
}

Key points:

  • .attributed gets attributed format result
  • replaceAttributes batch-replaces styles by attribute container
  • Supports custom attributes in Markdown syntax

Text selection:

Text(activity.info)
  .textSelection(.enabled)

Key points:

  • textSelection(.enabled) allows selecting and copying non-editable text
  • Applies at any view level; affects all inner Text
  • iOS: long-press copy menu; macOS: drag to select

Format styles make formatting type-safe:

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

Key points:

  • .list formats arrays as “A, B and C”
  • .name(style:) formats person names
  • Auto-localization with correct conjunctions per language

TextField also supports format styles:

@State private var newAttendee = PersonNameComponents()

TextField("New Person", value: $newAttendee, format: .name(style: .medium))

Key points:

  • value binds to PersonNameComponents, not String
  • Format style handles input parsing and output formatting
  • Type-safe; compiler checks binding and format style match

Keyboard and Focus

(31:38

onSubmit and submitLabel control keyboard submit behavior:

TextField("New Person", value: $newAttendee, format: .name(style: .medium))
  .onSubmit {
    activity.append(Person(newAttendee))
    newAttendee = PersonNameComponents()
  }
  .submitLabel(.done)

Key points:

  • onSubmit fires when user presses Return
  • submitLabel(.done) changes Return key to “Done”
  • Works on single TextField or entire Form

Keyboard toolbar:

Form {
  TextField("Name", text: $activity.name, prompt: Text("New Activity"))
  TextField("Location", text: $activity.location)
  DatePicker("Date", selection: $activity.date)
}
.toolbar {
  ToolbarItemGroup(placement: .keyboard) {
    Button(action: selectPreviousField) {
      Label("Previous", systemImage: "chevron.up")
    }
    .disabled(!hasPreviousField)

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

Key points:

  • placement: .keyboard puts toolbar above keyboard (iOS) or Touch Bar (macOS)
  • Good for form navigation without dismissing keyboard

FocusState for precise focus control:

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

struct ContentView: View {
  @State private var activity: Activity = .sample
  @FocusState private var focusedField: Field?

  var body: some View {
    VStack {
      Form {
        TextField("Name:", text: $activity.name, prompt: Text("New Activity"))
          .focused($focusedField, equals: .name)
        TextField("Location:", text: $activity.location)
          .focused($focusedField, equals: .location)
        DatePicker("Date:", selection: $activity.date)
          .focused($focusedField, equals: .date)
      }

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

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

Key points:

  • FocusState property wrapper tracks focus
  • Bind to Bool (simple) or any Hashable (multi-field)
  • .focused($focusedField, equals: .name) links field to enum
  • Assign focusedField = .addAttendee to move focus
  • Set focusedField = nil to dismiss keyboard

New Button Styles

(34:55

Standard bordered buttons:

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

Key points:

  • .buttonStyle(.bordered) adds standard bordered style
  • Apply on container to affect all child buttons
  • Auto handles pressed, disabled, Dark Mode

Control size and visual hierarchy:

HStack {
  ForEach(entry.tags) { tag in
    Button(tag.name) { }
      .tint(tag.color)
  }
}
.buttonStyle(.bordered)
.controlSize(.small)
.controlProminence(.increased)

Key points:

  • .controlSize(.small / .regular / .large) controls button size
  • .controlProminence(.increased) uses high-contrast accent fill
  • Marks primary actions; don’t overuse on one screen

Large buttons:

VStack {
  Button(action: addToJar) {
    Text("Add to Jar").frame(maxWidth: 300)
  }
  .controlProminence(.increased)
  .keyboardShortcut(.defaultAction)

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

Key points:

  • .controlSize(.large) produces large rounded-rect buttons
  • .keyboardShortcut(.defaultAction) triggers button with Return
  • Secondary buttons can use .tint(.accentColor) without fill

Destructive buttons and confirmation dialogs:

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

Key points:

  • role: .destructive uses red for dangerous actions
  • confirmationDialog shows as action sheet (iOS), popover (iPad), alert (macOS)
  • SwiftUI handles presentation per platform guidelines

Menu buttons support primary action:

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

Key points:

  • primaryAction defines default tap action
  • .menuIndicator(.hidden) hides dropdown arrow
  • macOS: split button—left triggers primary, right opens menu
  • iOS: tap triggers primary; long-press opens menu

Toggle button style and ControlGroup:

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

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

Key points:

  • .toggleStyle(.button) makes Toggle a tappable button
  • ControlGroup groups related buttons—compact on iOS, grouped on macOS
  • Can nest, e.g. ControlGroup with two Menus using primaryAction

Core Takeaways

1. Refactor your image list with AsyncImage + refreshable + task

What: Replace your image list with native SwiftUI. Why: Drop third-party deps; dozens of lines become a few; get placeholders, transitions, and error handling automatically. How: Swap Image for AsyncImage(url:); add .refreshable { await load() } on List and .task { await load() } on the view.

2. Add Table view to your macOS app

What: Use multi-column tables in macOS data management UI. Why: Table gives instant Finder-style native feel with sorting and multi-select—orders of magnitude less code than NSTableView. How: Replace List with Table(data) { TableColumn(...) }; add selection and sortOrder bindings.

3. Use Canvas for high-performance data visualization

What: Draw lots of geometry with Canvas—charts, grids, particle effects. Why: Avoids creating thousands of SwiftUI views; pairs easily with TimelineView for animation. How: Replace ZStack { ForEach(...) } with Canvas { context, size in ... }; draw with context.draw().

4. Optimize forms with FocusState

What: Add keyboard navigation and focus control to multi-field forms. Why: Users jump between fields with keyboard up/down instead of tapping each field. How: Define a Field enum; use @FocusState private var focusedField: Field?; add .focused($focusedField, equals: .fieldName) per field.

5. Unify cross-platform search with searchable

What: Add search to list pages—one codebase for iPhone, iPad, Mac, Apple TV, Apple Watch. Why: No per-platform search UI; SwiftUI places the search field correctly. How: Add .searchable(text: $searchText) on NavigationView; filter list data by searchText.

Comments

GitHub Issues · utterances