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

What's new in SwiftUI

元の動画を見る

ハイライト

SwiftUI は 2021 年に AsyncImage、refreshable、task、Table、searchable、Canvas、Materials、FocusState など数十の API を追加し、リスト操作、グラフィックス、テキスト、ボタンスタイル、キーボードナビゲーションをカバーして、より少ないコードで豊かなクロスプラットフォーム体験を実現します。

主要内容

ネットワーク画像の読み込みは iOS 開発の基本です。以前は URLSession + UIImageView を自前で書くか、サードパーティライブラリを入れていました。プレースホルダー、失敗処理、プルダウン更新——すべて自分で組み立てる必要がありました。

SwiftUI は今や AsyncImage を内蔵し、URL を渡すだけで自動ダウンロードと表示、デフォルトプレースホルダー付きです。refreshabletask と組み合わせれば、プルダウン更新とページ読み込み時の非同期取得も宣言的な 1 行になります。

リスト操作では、各行に編集可能な TextField を置くにはインデックスで binding を取る必要があり、リスト全体が再描画されていました。今年は binding 配列を List に直接渡せ、クロージャで各要素の binding を取得できます。swipeActions で左右スワイプのカスタム操作を Button で設定できます。

macOS についに Table が来ました。TableColumn で多列表を宣言し、単一選択・複数選択・ソートに対応——数行で Finder 風ファイルリストを構築できます。@FetchRequest もソート記述子の binding に対応し、Core Data 駆動の表がそのまま使えます。

検索 UI を自分で組む必要はなくなりました。NavigationView に searchable を付けると、プラットフォームごとに検索欄が正しい位置に置かれ、Apple TV から Apple Watch まで同じコードで動きます。

グラフィックスでは SF Symbols に Hierarchical と Palette レンダリングが追加され、タブバーのシンボルバリアントもプラットフォームに自動適応します。Canvas は即時モード描画をサポートし、TimelineView と組み合わせてアニメーションできます。Material は .ultraThinMaterial などの modifier で使え、vibrancy とダークモードを自動処理します。

テキストは Markdown をネイティブサポートし、Text("**Hello**, World!") で太字が直接レンダリングされます。AttributedString は型安全な属性操作を提供します。TextField は format style と prompt に対応し、キーボードツールバーをカスタマイズでき、FocusState でフォーム内のフォーカスを精密に制御できます。

ボタンスタイルも大幅に拡張されました。.bordered で標準的な枠付きボタン、.controlSize(.large).controlProminence(.increased) でサイズと視覚的階層を制御します。Menu は primary action をサポートし、Toggle は .toggleStyle(.button) でボタンスタイルにでき、ControlGroup で関連ボタンをまとめられます。

詳細

AsyncImage: 1 行でネットワーク画像を読み込む

(03:22

リモート画像の読み込みには URLSession タスク、キャッシュ、プレースホルダーロジックの管理が必要でした。SwiftUI は今年 AsyncImage を直接提供します。

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
}

キーポイント:

  • AsyncImage(url:) は URL を受け取り、自動でネットワークリクエストして画像を表示する
  • デフォルトのグレープレースホルダーがあり、読み込み完了後に自動切り替え
  • LazyVGrid と組み合わせ、スクロールで可視域に入ったときに読み込み開始
  • iOS、macOS、watchOS、tvOS すべてで利用可能

カスタムプレースホルダーと読み込みアニメーションが欲しい場合は、完全なクロージャ形式を使います:

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

キーポイント:

  • transaction でアニメーション設定を渡し、読み込み時の遷移を制御
  • phase 列挙に .empty.success.failure の 3 状態
  • 各状態で異なるビューを返し、カスタムプレースホルダーとエラー処理を実装

refreshable と task: 非同期操作の宣言化

(04:23

プルダウン更新はリストでよく使われます。以前は UIRefreshControl を統合し、begin/endRefreshing を管理していました。今は refreshable modifier を使います:

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

キーポイント:

  • refreshable は environment 経由で更新アクションを下に伝播
  • iOS と iPadOS の List は自動でプルダウン更新 UI を表示
  • await は非同期操作を表し、UI をブロックしない

ページ初回読み込みには task modifier を使います:

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

キーポイント:

  • task はビュー表示時に非同期タスクを開始し、消えると自動キャンセル
  • ページ初期読み込みやリアルタイムデータ監視に適する
  • AsyncSequence と組み合わせると、データ更新を継続的に受け取れます。
.task {
  for await photo in photoStore.newestPhotos {
    photoStore.push(photo)
  }
}

List の binding 要素と操作の強化

(08:08

List の各行でデータを編集する必要があるとき、インデックスで binding を取るとリスト全体が再描画されていました。今年は binding 配列を List に直接渡せます:

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

キーポイント:

  • $directions で配列を binding にし、クロージャの $direction が各要素の binding
  • 読み取り専用コードは変更不要。編集は $direction.text を直接使用
  • Swift 言語機能で、以前の SwiftUI 対応バージョンにも back-deploy 可能
  • ForEach でも同様に使える

リストの区切り線もカスタマイズできます:

.listRowSeparatorTint(direction.color)  // 区切り線の色を変更
.listRowSeparator(.hidden)              // 区切り線を非表示

Swipe actions でリスト操作がより柔軟になります:

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")
      }
    }
}

キーポイント:

  • swipeActions は Button で操作を定義し、tint で色を設定
  • edge で左側か右側かを制御
  • leading と trailing の 2 組を同時に追加可能
  • role: .destructive は削除操作に赤を自動使用

macOS 専用: 多列 Table

(12:13

macOS App では表形式データの表示がよく必要です。SwiftUI は今年 Table ビューを追加しました:

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

キーポイント:

  • Table はデータ集合を受け取り、内部で TableColumn が各列を定義
  • value: \.name は key path でテキストを表示する簡略形
  • クロージャで列内容をカスタマイズも可能

Table は単一選択と複数選択をサポートします:

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

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

ヘッダークリックによるソートにも対応しています:

@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) }

キーポイント:

  • sortOrder は binding で、ユーザーがヘッダーをクリックすると自動更新
  • KeyPathComparator は key path でソートフィールドを指定
  • @FetchRequest の sort descriptor binding と組み合わせ、Core Data 表は数行で済む

searchable: クロスプラットフォーム検索

(15:20

検索は Apple TV から Apple Watch までクロスプラットフォームのニーズです。SwiftUI は統一された searchable modifier を提供します:

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")
}

キーポイント:

  • searchable(text:) は binding を受け取り、各プラットフォームで検索欄を自動配置
  • iOS はナビバー下、macOS はツールバー、Apple TV は上部
  • 検索候補と組み合わせてより複雑な検索体験も可能

Canvas と TimelineView: 高性能カスタム描画

(21:31

大量のグラフィック要素を描くとき、通常の SwiftUI ビューではオーバーヘッドが大きいです。Canvas は UIKit の 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))
  }
}

キーポイント:

  • Canvas は描画クロージャを受け取り、context が描画 API を提供
  • context.resolve() で SwiftUI の Image を描画可能なグラフィックに変換
  • 状態を個別追跡しない大量の幾何図形の描画に適する

Canvas にはジェスチャーとアクセシビリティを付けられます:

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

TimelineView と組み合わせてアニメーション:

TimelineView(.animation) { timeline in
  let time = timeline.date.timeIntervalSince1970
  Canvas { context, size in
    // time で描画パラメータを更新...
  }
}

キーポイント:

  • TimelineView は schedule で再描画し、.animation は毎フレーム更新
  • tvOS スクリーンセーバー、watchOS Always On 表示などに適する
  • privacySensitive(true) で Always On 時に機密コンテンツを自動非表示

Materials と safeAreaInset

(25:03

Material 効果は以前システムコントロールのみでしたが、今年は開発者に開放されました:

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

キーポイント:

  • .ultraThinMaterial.thinMaterial.regularMaterial.thickMaterial などでぼかし度を制御
  • vibrancy を自動処理。.primary から .quaternary まで Material 上で正しく表示
  • emoji は vibrancy 処理から自動除外

safeAreaInset でフローティングツールバーがコンテンツを遮らないようにします:

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

キーポイント:

  • safeAreaInset はスクロール端にコンテンツを挿入し、スクロール内容がその領域を避ける
  • 下部ツールバーやフローティングボタンに適する

テキストとフォーマット

(27:15

Text は Markdown をサポートするようになりました:

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

AttributedString は型安全な属性操作を提供します:

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
}

キーポイント:

  • .attributed で属性付きフォーマット結果を取得
  • replaceAttributes で属性コンテナごとにスタイルを一括置換
  • カスタム属性を Markdown 構文で使える

テキスト選択:

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

キーポイント:

  • textSelection(.enabled) で編集不可テキストの選択・コピーが可能
  • 任意のビュー階層に適用でき、内部のすべての Text に効く
  • iOS は長押しでコピーメニュー、macOS はドラッグ選択

Format styles でフォーマットが型安全になります:

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

キーポイント:

  • .list で配列を “A, B and C” 形式にフォーマット
  • .name(style:) で人名をフォーマット
  • ローカライズを自動処理し、言語ごとに正しい接続詞を使用

TextField も format styles をサポートします:

@State private var newAttendee = PersonNameComponents()

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

キーポイント:

  • value は String ではなく PersonNameComponents にバインド
  • format style が入力解析と出力フォーマットを自動処理
  • 型安全で、コンパイラが binding と format style の一致を検査

キーボードとフォーカス

(31:38

onSubmitsubmitLabel でキーボードの送信動作を制御します:

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

キーポイント:

  • onSubmit はユーザーが Return を押したときに発火
  • submitLabel(.done) で Return キーを “Done” に変更
  • 単一 TextField または Form 全体に適用可能

キーボードツールバー:

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)
  }
}

キーポイント:

  • placement: .keyboard でツールバーをキーボード上(iOS)または Touch Bar(macOS)に配置
  • キーボードを閉じずにフォーム間を移動するのに適する

FocusState でフォーカスを精密に制御:

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")
      }
    }
  }
}

キーポイント:

  • FocusState の property wrapper でフォーカス状態を追跡
  • Bool(単純)または任意の Hashable(複数フィールド)にバインド可能
  • .focused($focusedField, equals: .name) でフィールドと列挙値を関連付け
  • focusedField = .addAttendee を代入してフォーカスを移動
  • focusedField = nil でキーボードを閉じる

新しいボタンスタイル

(34:55

標準的な枠付きボタン:

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

キーポイント:

  • .buttonStyle(.bordered) で標準的な枠付きスタイル
  • コンテナビューに適用すると子ボタンすべてに効く
  • pressed、disabled、ダークモードを自動処理

サイズと視覚的階層の制御:

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

キーポイント:

  • .controlSize(.small / .regular / .large) でボタンサイズを制御
  • .controlProminence(.increased) で高コントラストの強調色フィル
  • 主要操作のマークに使うが、同一画面では使いすぎない

大型ボタン:

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)

キーポイント:

  • .controlSize(.large) で大型の角丸ボタン
  • .keyboardShortcut(.defaultAction) で Return キーがそのボタンをトリガー
  • 副次ボタンは .tint(.accentColor) で色だけ付けフィルなしも可能

破壊的ボタンと確認ダイアログ:

.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.")
}

キーポイント:

  • role: .destructive は危険操作に赤を自動使用
  • confirmationDialog は iOS で action sheet、iPad で popover、macOS で alert
  • SwiftUI がプラットフォーム規約に沿って表示を処理

Menu ボタンは 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)

キーポイント:

  • primaryAction でクリック時のデフォルト操作を定義
  • .menuIndicator(.hidden) でドロップダウン矢印を非表示
  • macOS では分割ボタン——左が primary、右がメニュー展開
  • iOS ではタップで primary、長押しでメニュー展開

Toggle ボタンスタイルと 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")
  }
}

キーポイント:

  • .toggleStyle(.button) で Toggle をボタンスタイルに
  • ControlGroup で関連ボタンをまとめ、iOS はコンパクト、macOS は視覚グループ
  • ネストも可能。例: primaryAction 付き Menu を 2 つ入れた ControlGroup

重要ポイント

1. AsyncImage + refreshable + task で画像リストをリファクタリング

何をするか: 既存の画像リストを SwiftUI ネイティブ方案に置き換える。 なぜ価値があるか: サードパーティ依存を削除し、数十行が数行に。プレースホルダー、遷移アニメーション、エラー処理を自動取得。 どう始めるか: ImageAsyncImage(url:) に、List.refreshable { await load() }、ビューに .task { await load() } を追加。

2. macOS App に Table ビューを追加

何をするか: macOS 版のデータ管理画面で多列表を使う。 なぜ価値があるか: Table で Finder 風のネイティブ感がすぐ得られ、ソート・複数選択に対応。AppKit の NSTableView より桁違いにコードが少ない。 どう始めるか: Table(data) { TableColumn(...) } で List を置き換え、selectionsortOrder binding を追加。

3. Canvas で高性能データ可視化

何をするか: Canvas で大量の幾何図形——チャート、グリッド、パーティクル効果を描く。 なぜ価値があるか: 数千の SwiftUI ビューを作るオーバーヘッドを避け、TimelineView と組み合わせてアニメーションも容易。 どう始めるか: ZStack { ForEach(...) }Canvas { context, size in ... } に置き換え、context.draw() で直接描画。

4. FocusState でフォーム体験を最適化

何をするか: 複数入力欄のフォームにキーボードナビゲーションとフォーカス制御を追加。 なぜ価値があるか: 各フィールドをタップせず、キーボードの上下でフォーム間を移動できる。 どう始めるか: Field 列挙を定義し、@FocusState private var focusedField: Field? を使い、各入力に .focused($focusedField, equals: .fieldName) を付ける。

5. searchable で全プラットフォーム検索を統一

何をするか: リストページに検索を追加し、iPhone、iPad、Mac、Apple TV、Apple Watch を 1 套コードでカバー。 なぜ価値があるか: プラットフォームごとの検索 UI が不要。SwiftUI が検索欄を正しい位置に置く。 どう始めるか: NavigationView に .searchable(text: $searchText) を追加し、searchText でリストをフィルタ。

関連セッション

コメント

GitHub Issues · utterances