ハイライト
SwiftUI は 2021 年に AsyncImage、refreshable、task、Table、searchable、Canvas、Materials、FocusState など数十の API を追加し、リスト操作、グラフィックス、テキスト、ボタンスタイル、キーボードナビゲーションをカバーして、より少ないコードで豊かなクロスプラットフォーム体験を実現します。
主要内容
ネットワーク画像の読み込みは iOS 開発の基本です。以前は URLSession + UIImageView を自前で書くか、サードパーティライブラリを入れていました。プレースホルダー、失敗処理、プルダウン更新——すべて自分で組み立てる必要がありました。
SwiftUI は今や AsyncImage を内蔵し、URL を渡すだけで自動ダウンロードと表示、デフォルトプレースホルダー付きです。refreshable と task と組み合わせれば、プルダウン更新とページ読み込み時の非同期取得も宣言的な 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)
onSubmit と submitLabel でキーボードの送信動作を制御します:
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 ネイティブ方案に置き換える。
なぜ価値があるか: サードパーティ依存を削除し、数十行が数行に。プレースホルダー、遷移アニメーション、エラー処理を自動取得。
どう始めるか: Image を AsyncImage(url:) に、List に .refreshable { await load() }、ビューに .task { await load() } を追加。
2. macOS App に Table ビューを追加
何をするか: macOS 版のデータ管理画面で多列表を使う。
なぜ価値があるか: Table で Finder 風のネイティブ感がすぐ得られ、ソート・複数選択に対応。AppKit の NSTableView より桁違いにコードが少ない。
どう始めるか: Table(data) { TableColumn(...) } で List を置き換え、selection と sortOrder 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 でリストをフィルタ。
関連セッション
- Discover concurrency in SwiftUI — SwiftUI の並行モデルと async/await が宣言的更新とどう連携するか。
- Demystify SwiftUI — アイデンティティ、ライフサイクル、依存関係から SwiftUI の設計哲学を理解。
- SwiftUI on the Mac: Build the fundamentals — SwiftUI でネイティブ macOS App を構築する手順。
- Add rich graphics to your SwiftUI app — Canvas、Material、セーフエリアなどグラフィックス関連 API。
- Direct and reflect focus in SwiftUI — SwiftUI でフォーカスナビゲーションを精密に制御する方法。
コメント
GitHub Issues · utterances