swiftui-patterns について
SwiftUI 開発者向けのパターンガイドです。アーキテクチャ、状態管理、ビュー構成、環境値、非同期読み込みを扱い、MV 構造とビルド・プレビュー・操作の検証を重視します。
主な機能
- 01MV アーキテクチャと状態所有権
- 02ビューの分割と構成
- 03環境値と依存性注入
- 04非同期データ読み込みパターン
- 05パフォーマンスと HIG の指針
活用シーン
- 01SwiftUI アプリ構造の計画
- 02大規模ビューのリファクタリング
- 03ビュー状態と依存関係の管理
- 04読み込み・空・エラー状態のプレビュー確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ dpearson2699/ swift-ios-skills --skill swiftui-patternsインストール後、Claude Code で/swiftui-patternsを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
evals/evals.jsonreferences/architecture-patterns.mddeprecated-migration.mddesign-polish.mdplatform-and-sharing.mdpreview-isolation.mdview-refactoring.md詳細
SwiftUI パターン
iOS 26 以降を対象とした、Swift 6.3 対応の最新の SwiftUI パターンです。アーキテクチャ、状態管理、ビューの構成、環境の配線、非同期読み込み、デザインの仕上げ、プラットフォーム/共有機能との統合を扱います。ナビゲーション、レイアウト、アニメーション、Liquid Glass のパターンは、専用の関連スキルで扱います。特に記載がない限り、パターンは iOS 17 以降と後方互換性があります。
目次
- アーキテクチャ:Model-View(MV)パターン
- ワークフロー
- 状態管理
- ビューの順序に関する規約
- ビューの構成
- 環境
- 非同期データ読み込み
- iOS 26 以降の新しい API
- パフォーマンスに関するガイドライン
- HIG との整合性
- ライティングツール(iOS 18 以降)
- よくある間違い
- レビュー用チェックリスト
- 参考資料
対象範囲: このスキルでは、アーキテクチャ、状態の所有権、構成、環境の配線、非同期読み込み、および関連する SwiftUI アプリの構造パターンを扱います。詳細なナビゲーションパターンは swiftui-navigation スキルで扱います。これには NavigationStack、NavigationSplitView、シート、タブ、ディープリンクのパターンが含まれます。詳細なレイアウト、コンテナー、コンポーネントのパターンは swiftui-layout-components スキルで扱います。これにはスタック、グリッド、リスト、スクロールビューのパターン、フォーム、コントロール、.searchable を使った検索 UI、オーバーレイ、および関連するレイアウトコンポーネントが含まれます。詳細なアニメーションの演出は swiftui-animation で扱います。Liquid Glass の導入、カスタムガラスコントロール、スクロールエッジ効果、.scrollEdgeEffectStyle、.backgroundExtensionEffect は swiftui-liquid-glass で扱います。
ワークフロー
- 現在の状態の所有権、アクション、副作用、ナビゲーション、ライフサイクルの動作を記録します。
- 既存の契約を維持できる、MV/状態管理/構成に関する最小限の変更を選びます。
- 構造を変更するたびにビルドし、先へ進む前にコンパイラーおよび分離に関するエラーを修正します。
- 該当する場合は、読み込み済み、読み込み中、空、エラーの状態を対象に、必要な環境依存関係も含めて、再現可能なプレビューを表示します。
- 重要な操作と副作用を確認します。動作が変わった場合は、フィクスチャを元に戻し、最小の境界を修正して、同じビルド、プレビュー、操作確認を再実行します。
既存のビューの構造変更については 動作を維持したビューのリファクタリング を、フィクスチャと依存関係のパターンについては 分離されたプレビューの構築 を読み込んでください。
アーキテクチャ:Model-View(MV)パターン
基本方針として MV を採用します。ビューは軽量な状態表現とし、ビジネスロジックはモデルとサービスが担います。既存のコードですでにビュー・モデルを使用している場合を除き、新たに導入しないでください。
基本原則:
- オーケストレーションには
@State、@Environment、@Query、.task、.onChangeを優先します - サービスと共有モデルは
@Environment経由で注入し、ビューは小さく構成可能に保ちます - ビュー・モデルを導入するのではなく、大きなビューを小さなサブビューに分割します
- モデル、サービス、ビジネスロジックをテストし、ビューはシンプルで宣言的に保ちます
struct FeedView: View {
@Environment(FeedClient.self) private var client
enum ViewState {
case loading, error(String), loaded([Post])
}
@State private var viewState: ViewState = .loading
var body: some View {
List {
switch viewState {
case .loading:
ProgressView()
case .error(let message):
ContentUnavailableView("Error", systemImage: "exclamationmark.triangle",
description: Text(message))
case .loaded(let posts):
ForEach(posts) { post in
PostRow(post: post)
}
}
}
.task { await loadFeed() }
.refreshable { await loadFeed() }
}
private func loadFeed() async {
do {
let posts = try await client.getFeed()
viewState = .loaded(posts)
} catch {
viewState = .error(error.localizedDescription)
}
}
}
MV パターンの理由、アプリの配線、軽量なクライアントの例については、references/architecture-patterns.md を参照してください。
状態管理
@Observable の所有権ルール
重要: SwiftUI のビューが所有、変更、またはプロパティにバインドする UI に紐づく @Observable ストアやビューモデルは、@MainActor で分離してください。Observation は変更を追跡しますが、共有された可変状態をスレッドセーフにするものではありません。UI 状態に触れないドメインモデルには、独自の分離戦略を使用できます。
| ラッパー | 使用する場面 |
|---|---|
@State | ビューがオブジェクトまたは値を所有する場合。ライフサイクルを作成して管理します。 |
let | ビューが @Observable オブジェクトを受け取る場合。読み取り専用の Observation にはラッパーは不要です。 |
@Bindable | ビューが @Observable オブジェクトを受け取り、双方向バインディング($property)が必要な場合。 |
@Environment(Type.self) | 環境から共有 @Observable オブジェクトにアクセスする場合。 |
@State(値型) | トグル、カウンター、テキストフィールドの値など、ビュー内だけで使う単純な状態。常に private にします。 |
@Binding | 親の @State または @Bindable プロパティへの双方向接続。 |
所有権パターン
// UI-bound @Observable store -- main-actor isolated
@MainActor
@Observable final class ItemStore {
var title = ""
var items: [Item] = []
}
// View that OWNS the model
struct ParentView: View {
@State private var viewModel = ItemStore()
var body: some View {
ChildView(store: viewModel)
.environment(viewModel)
}
}
// View that READS (no wrapper needed for @Observable)
struct ChildView: View {
let store: ItemStore
var body: some View { Text(store.title) }
}
// View that BINDS (needs two-way access)
struct EditView: View {
@Bindable var store: ItemStore
var body: some View {
TextField("Title", text: $store.title)
}
}
// View that reads from ENVIRONMENT
struct DeepView: View {
@Environment(ItemStore.self) private var store
var body: some View {
@Bindable var s = store
TextField("Title", text: $s.title)
}
}
細粒度の追跡: SwiftUI が再レンダリングするのは、変更されたプロパティを読み取るビューだけです。ビューが items は読み取るものの isLoading は読み取らない場合、isLoading を変更しても再レンダリングは発生しません。これは ObservableObject と比べて大きなパフォーマンス上の利点です。
レガシーの ObservableObject
iOS 16 以前をサポートする場合にのみ使用してください。@StateObject → @State、@ObservedObject → let、@EnvironmentObject → @Environment(Type.self)。
ビューの並び順の規約
メンバーは上から下へ、1)@Environment 2)let プロパティ 3)@State/保存プロパティ 4)計算プロパティ var 5)init 6)body 7)ビュー構築関数/ヘルパー 8)非同期関数の順に並べます。
ビューの構成
サブビューの抽出
ビューを責務が明確なサブビューに分割します。それぞれが単一の責務を持つようにします。 既存のビューを再構成する場合は、アクション/副作用の境界とビルド/プレビューによる検証のために、動作を維持するビューのリファクタリングを読み込みます。
var body: some View {
VStack {
HeaderSection(title: title, isPinned: isPinned)
DetailsSection(details: details)
ActionsSection(onSave: onSave, onCancel: onCancel)
}
}
計算されたビューのプロパティ
小さくステートレスな部分には、計算された some View プロパティを使用します。次のいずれかに該当する場合は、セクションを専用の View 型として抽出します。
- 意味のある分岐や大規模なレイアウトがある
- 独自の状態または非同期ライフサイクルがある
- 親よりも Observation の依存関係を絞れる
- 独立したプレビューが役立つ
- 親のデータフローが分かりにくくなるほど複雑である
依存関係を絞る場合は、子ビューが必要とする値、バインディング、アクションだけを渡します。それらが大規模でも一貫性のあるインターフェースを形成する場合は、機能単位にスコープを限定した @Observable モデルを渡します。Observation によって無効化は子ビューが読み取るプロパティに限定されますが、アプリ全体のストアではインターフェースが広くなります。その一貫性のある状態を本当に必要とする子ビューに限って使用してください。
再利用できることは分割の有益な成果ですが、必須条件ではありません。
Extensions と // MARK: - は大きなファイルを整理するものです。ビューの境界を作るものではなく、抽出の代わりにもなりません。
ViewBuilder 関数
別の構造体にするほどではない条件分岐には、次のようにします。
@ViewBuilder
private func statusBadge(for status: Status) -> some View {
switch status {
case .active: Text("Active").foregroundStyle(.green)
case .inactive: Text("Inactive").foregroundStyle(.secondary)
}
}
カスタムビューモディファイア
繰り返し使うスタイルは ViewModifier に抽出します。
struct CardStyle: ViewModifier {
func body(content: Content) -> some View {
content
.padding()
.background(.background)
.clipShape(.rect(cornerRadius: 12))
.shadow(radius: 2)
}
}
extension View { func cardStyle() -> some View { modifier(CardStyle()) } }
安定したビュー階層
最上位で条件に応じてビューを切り替えるのは避けてください。条件はセクションまたはモディファイア内に置き、単一で安定した基本ビューを優先します。
抽出したビューに独立した状態の網羅、決定論的なフィクスチャ、または環境設定が必要な場合は、分離されたプレビューの構築を読み込みます。
環境
カスタム環境値
カスタム環境値やアクションには @Entry を使用します。これにより、EnvironmentValues 用のエントリ定義コードが生成されます。
extension EnvironmentValues {
@Entry var theme: Theme = .default
@Entry var refreshFeed: @Sendable () async -> Void = {}
}
// Usage
.environment(\.theme, customTheme)
.environment(\.refreshFeed) { await feedStore.refresh() }
@Environment(\.theme) private var theme
@Environment(\.refreshFeed) private var refreshFeed
iOS 17 対応のコードや、古いバージョンとの互換性を保つためのシムには、代わりに手動で EnvironmentKey 型を使用します。
よく使う組み込み環境値
@Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var colorScheme
@Environment(\.dynamicTypeSize) var dynamicTypeSize
@Environment(\.horizontalSizeClass) var sizeClass
@Environment(\.isSearching) var isSearching
@Environment(\.openURL) var openURL
@Environment(\.modelContext) var modelContext
非同期データの読み込み
常に .task を使用してください。ビューが表示されなくなると、自動的にキャンセルされます。
struct ItemListView: View {
@State var store = ItemStore()
var body: some View {
List(store.items) { item in
ItemRow(item: item)
}
.task { await store.load() }
.refreshable { await store.refresh() }
}
}
依存関係が変化したときに再実行するには、.task(id:) を使用してください。
.task(id: searchText) {
guard !searchText.isEmpty else { return }
await search(query: searchText)
}
キャンセル用の参照を保存する必要がない限り、onAppear 内で手動の Task を作成しないでください。例外として、同期アクションクロージャ(例:Button アクション)では、非同期処理の前に状態をすぐに更新するために Task {} を使用できます。
キャンセルハンドラー、デバウンスとクロック、AsyncSequence、またはアクター分離には swift-concurrency を使用してください。
iOS 26 以降の新しい API
.scrollEdgeEffectStyle、.backgroundExtensionEffect、ガラスコントロールは swiftui-liquid-glass に、@Animatable は swiftui-animation に振り分けてください。TextEditor(text: Binding<AttributedString>) は、iOS 26 でリッチテキストを編集する方法です。これらの API を採用するコードの近くに、利用可能性チェックを配置してください。
クリップボードのコマンド修飾子は iOS 26 の標準ではありません。.copyable、.cuttable、コマンドベースの .pasteDestination(for:action:validator:) は、現在の Apple ドキュメントでは macOS 13 以降および iOS/iPadOS/Mac Catalyst 27 ベータ版が対象です。iOS 26 を対象とする場合は、カスタムのクリップボードコマンドに UIPasteboard を使用するか、Transferable のフローにドラッグ&ドロップと ShareLink を使用してください。references/platform-and-sharing.md を参照してください。
パフォーマンスのガイドライン
- 遅延スタック/グリッド: 大規模なコレクションには
LazyVStack、LazyHStack、LazyVGrid、LazyHGridを使用してください。通常のスタックは、すべての子ビューをすぐに描画します。 - 安定した ID:
List/ForEachのすべての項目は、安定した ID を持つIdentifiableに準拠する必要があります。配列のインデックスは使用しないでください。 - body の再計算を避ける: フィルタリングと並べ替えは
body内に直接記述せず、計算プロパティまたはモデルに移してください。 - Equatable ビュー: 複雑なビューが不必要に再描画される場合は、
Equatableに準拠させてください。
HIG への準拠
レイアウト、タイポグラフィ、色、アクセシビリティについては、Apple Human Interface Guidelines に従ってください。主なルールは次のとおりです。
- 自動的なライト/ダークモードに対応するには、セマンティックカラー(
Color.primary、.secondary、Color(uiColor: .systemBackground))を使用します。 - Dynamic Type に対応するには、システムフォントスタイル(
.title、.headline、.body、.caption)を使用します。 - 空の状態やエラー状態には
ContentUnavailableViewを使用します。 - 特定の値が必要でない限り、スタックでは
spacing:を省略します。デフォルトのnilにより、プラットフォームに適したアダプティブな間隔が使用されます。 horizontalSizeClassを使用して、アダプティブなレイアウトに対応します。- VoiceOver ラベル(
.accessibilityLabel)を指定し、レイアウトの向きを切り替えて Dynamic Type のアクセシビリティサイズに対応します。
HIG、テーマ設定、触覚フィードバック、フォーカス、トランジション、読み込みパターンについては、references/design-polish.md を参照してください。
Writing Tools(iOS 18 以降)
.writingToolsBehavior(_:) を使用して、テキストビューでの Apple Intelligence Writing Tools の動作を制御します。
| レベル | 効果 | 使用する場面 |
|---|---|---|
.complete | インラインでの完全な書き換え(校正、書き換え、変換) | メモ、メール、ドキュメント |
.limited | オーバーレイパネルの機能を制限 | コードエディタ、検証付きフォーム |
.disabled | Writing Tools を完全に非表示 | パスワード、検索バー |
.automatic | コンテキストに応じてシステムが選択(デフォルト) | ほとんどのビュー |
TextEditor(text: $body)
.writingToolsBehavior(.complete)
TextField("Search…", text: $query)
.writingToolsBehavior(.disabled)
アクティブなセッションの検出: UITextView(UIKit)の isWritingToolsActive を読み取り、書き換えが完了するまで検証を延期するか、Undo のグループ化を一時停止します。
ドキュメント: WritingToolsBehavior · writingToolsBehavior(_:)
よくある間違い
-
オブジェクトの作成に
@ObservedObjectを使う —@StateObject(旧式)または@State(最新)を使います -
ビューの
body内で重い計算を行う — モデルまたは計算プロパティに移します -
非同期処理に
.taskを使わない —onAppear内で手動作成したTaskは、キャンセルしないとリークします -
ForEachの ID に配列のインデックスを使う — 差分検出が正しく行われず、UI の不具合につながります -
@Bindableを付け忘れる —@Observableで$property構文を使うには@Bindableが必要です -
@Stateを使いすぎる — ビュー内だけの状態に使います。共有状態には@Observableを使います -
複雑なセクションや、単独でプレビューできるセクションを計算プロパティのままにする —
View型として切り出します。拡張と// MARK:は整理にしか使えません -
NavigationViewを使う — 非推奨です。NavigationStackを使います -
foregroundStyle(_:)のほうがセマンティックなスタイルに適している場合にforegroundColor(_:)を使う -
body内にクロージャを直接記述する — 複雑なクロージャはメソッドに切り出します -
状態がモデルを表す場合に
.sheet(isPresented:)を使う — 代わりに.sheet(item:)を使います -
通常の分岐処理に
AnyViewを使う — 型消去によって構造が隠れ、パフォーマンスや ID に依存するトランジションに悪影響が出る場合があります。API で異種ビューの保存が本当に必要な場合を除き、@ViewBuilder、Group、またはジェネリクスを使います。references/deprecated-migration.md を参照してください -
@Observableクラス内に@AppStorageを置く。@AppStorageはビューのDynamicPropertyです。View内に置くか、モデル内でUserDefaultsを使って通常の監視対象プロパティを公開します。 -
すべてのスタックで
spacing:をハードコードする — 省略するとプラットフォームに適応した間隔が使われます。値を明示する意図がある場合にのみ指定します -
.copyable、.cuttable、またはコマンドベースの.pasteDestination(for:action:validator:)を iOS 16/iOS 26 の API とみなす — 現在の Apple ドキュメントでは、これらは macOS 13 以降、および iOS/iPadOS/Mac Catalyst 27 のベータ版で利用できます。iOS 26 を対象にする場合は、UIPasteboard、ドラッグ&ドロップ、またはShareLinkを使います。 -
最新の標準的な方法を正式な非推奨 API とみなす —
#Previewは最新のプレビューで標準的に使われますが、PreviewProviderは旧式であり、コンパイラによる非推奨指定はありません。EditButton、.onDelete、.onMoveは引き続き編集モードのリスト操作に使用できます。コンテキストに応じた行アクションには.swipeActionsを使います。 -
プレビューのクラッシュを防ぐために必須の依存関係をオプショナルにする — 代わりに、ライブネットワーク、認証、本番データベース、グローバルシングルトンを使わずに、決定論的なプレビュー用依存関係を設定します
レビューチェックリスト
- 共有状態モデルには
@Observableを使用しています(iOS 17 以降ではObservableObjectを使用しません) - オブジェクトは
@Stateで所有し、let/@Bindableで受け取っています - 移行と利用可能性に関する記述は、現在のプラットフォーム対応状況を確認しています。特にクリップボードと共有 API を確認しています
-
NavigationStackを使用しています(NavigationViewは使用しません) - 非同期のデータ読み込みには
.task修飾子を使用しています - 大規模なコレクションには
LazyVStack/LazyHStackを使用しています - 安定した
IdentifiableID を使用しています(配列インデックスは使用しません) - 抽出の判断には分岐/レイアウト、ライフサイクル、依存関係、プレビュー、親フローのシグナルを使用し、小さくステートレスな断片は計算プロパティのままにしています
- 拡張と
// MARK:はファイルの整理にのみ使用しています - 構造のみのリファクタリングでは動作を維持しています。薄いアクション/ライフサイクルメソッドを使い、再利用可能なロジックはサービス/モデルに保持してから、ビルドし、有用なプレビューをレンダリングしています
- プレビューでは、決定論的なフィクスチャと必須の環境依存関係をすべて用いて、意味のある読み込み済み/読み込み中/空/エラー状態を網羅しています
- ビューの
bodyで重い計算を行っていません - 広く共有される状態には Environment を使用しています
- 固定色よりセマンティックなスタイル設定が適切な場合は、
foregroundStyle(_:)を使用しています - スタイルを繰り返し適用する場合は、カスタムの
ViewModifierを使用しています -
.sheet(isPresented:)より.sheet(item:)を優先しています - シートは自身のアクションを管理し、内部で
dismiss()を呼び出しています - MV パターンに従っています(不要なビューモデルは使用しません)
- UI にバインドされる
@Observableストアとビューモデルは@MainActorで分離されています - 並行処理の境界を越えて渡されるモデル型は
Sendableです - 特定の値が必要な場合を除き、Stack の
spacing:は省略しています(適応型のデフォルトを優先)
参考資料
- アーキテクチャ、アプリの配線、軽量クライアント:references/architecture-patterns.md
- デザインの仕上げ(HIG、テーマ設定、触覚フィードバック、トランジション、読み込み、フォーカス):references/design-polish.md
- 非推奨 API の移行:references/deprecated-migration.md
- プラットフォームと共有のパターン(Transferable、クリップボードの利用可能性、メディア、メニュー、macOS の設定):references/platform-and-sharing.md
- 分離されたプレビューの構築(状態の網羅、フィクスチャ、環境依存関係):references/preview-isolation.md
- 既存ビューの再構成(動作の契約、アクション/副作用の境界、検証):references/view-refactoring.md