mcpField
S
swiftui-patterns

swiftui-patterns

github.com/dpearson2699開発1.2k4.6k 回インストール13 週間前に更新
リポジトリ

swiftui-patterns について

SwiftUI 開発者向けのパターンガイドです。アーキテクチャ、状態管理、ビュー構成、環境値、非同期読み込みを扱い、MV 構造とビルド・プレビュー・操作の検証を重視します。

主な機能

  1. 01MV アーキテクチャと状態所有権
  2. 02ビューの分割と構成
  3. 03環境値と依存性注入
  4. 04非同期データ読み込みパターン
  5. 05パフォーマンスと HIG の指針

活用シーン

  1. 01SwiftUI アプリ構造の計画
  2. 02大規模ビューのリファクタリング
  3. 03ビュー状態と依存関係の管理
  4. 04読み込み・空・エラー状態のプレビュー確認

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/dpearson2699/swift-ios-skills --skill swiftui-patterns

インストール後、Claude Code で/swiftui-patternsを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

evals/
evals.json
references/
architecture-patterns.md
deprecated-migration.md
design-polish.md
platform-and-sharing.md
preview-isolation.md
view-refactoring.md

詳細

SwiftUI パターン

iOS 26 以降を対象とした、Swift 6.3 対応の最新の SwiftUI パターンです。アーキテクチャ、状態管理、ビューの構成、環境の配線、非同期読み込み、デザインの仕上げ、プラットフォーム/共有機能との統合を扱います。ナビゲーション、レイアウト、アニメーション、Liquid Glass のパターンは、専用の関連スキルで扱います。特に記載がない限り、パターンは iOS 17 以降と後方互換性があります。

目次

対象範囲: このスキルでは、アーキテクチャ、状態の所有権、構成、環境の配線、非同期読み込み、および関連する SwiftUI アプリの構造パターンを扱います。詳細なナビゲーションパターンは swiftui-navigation スキルで扱います。これには NavigationStack、NavigationSplitView、シート、タブ、ディープリンクのパターンが含まれます。詳細なレイアウト、コンテナー、コンポーネントのパターンは swiftui-layout-components スキルで扱います。これにはスタック、グリッド、リスト、スクロールビューのパターン、フォーム、コントロール、.searchable を使った検索 UI、オーバーレイ、および関連するレイアウトコンポーネントが含まれます。詳細なアニメーションの演出は swiftui-animation で扱います。Liquid Glass の導入、カスタムガラスコントロール、スクロールエッジ効果、.scrollEdgeEffectStyle、.backgroundExtensionEffect は swiftui-liquid-glass で扱います。

ワークフロー

  1. 現在の状態の所有権、アクション、副作用、ナビゲーション、ライフサイクルの動作を記録します。
  2. 既存の契約を維持できる、MV/状態管理/構成に関する最小限の変更を選びます。
  3. 構造を変更するたびにビルドし、先へ進む前にコンパイラーおよび分離に関するエラーを修正します。
  4. 該当する場合は、読み込み済み、読み込み中、空、エラーの状態を対象に、必要な環境依存関係も含めて、再現可能なプレビューを表示します。
  5. 重要な操作と副作用を確認します。動作が変わった場合は、フィクスチャを元に戻し、最小の境界を修正して、同じビルド、プレビュー、操作確認を再実行します。

既存のビューの構造変更については 動作を維持したビューのリファクタリング を、フィクスチャと依存関係のパターンについては 分離されたプレビューの構築 を読み込んでください。

アーキテクチャ: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オーバーレイパネルの機能を制限コードエディタ、検証付きフォーム
.disabledWriting Tools を完全に非表示パスワード、検索バー
.automaticコンテキストに応じてシステムが選択(デフォルト)ほとんどのビュー
TextEditor(text: $body)
    .writingToolsBehavior(.complete)
TextField("Search…", text: $query)
    .writingToolsBehavior(.disabled)

アクティブなセッションの検出: UITextView(UIKit)の isWritingToolsActive を読み取り、書き換えが完了するまで検証を延期するか、Undo のグループ化を一時停止します。

ドキュメント: WritingToolsBehavior · writingToolsBehavior(_:)

よくある間違い

  1. オブジェクトの作成に @ObservedObject を使う — @StateObject(旧式)または @State(最新)を使います

  2. ビューの body 内で重い計算を行う — モデルまたは計算プロパティに移します

  3. 非同期処理に .task を使わない — onAppear 内で手動作成した Task は、キャンセルしないとリークします

  4. ForEach の ID に配列のインデックスを使う — 差分検出が正しく行われず、UI の不具合につながります

  5. @Bindable を付け忘れる — @Observable で $property 構文を使うには @Bindable が必要です

  6. @State を使いすぎる — ビュー内だけの状態に使います。共有状態には @Observable を使います

  7. 複雑なセクションや、単独でプレビューできるセクションを計算プロパティのままにする — View 型として切り出します。拡張と // MARK: は整理にしか使えません

  8. NavigationView を使う — 非推奨です。NavigationStack を使います

  9. foregroundStyle(_:) のほうがセマンティックなスタイルに適している場合に foregroundColor(_:) を使う

  10. body 内にクロージャを直接記述する — 複雑なクロージャはメソッドに切り出します

  11. 状態がモデルを表す場合に .sheet(isPresented:) を使う — 代わりに .sheet(item:) を使います

  12. 通常の分岐処理に AnyView を使う — 型消去によって構造が隠れ、パフォーマンスや ID に依存するトランジションに悪影響が出る場合があります。API で異種ビューの保存が本当に必要な場合を除き、@ViewBuilder、Group、またはジェネリクスを使います。references/deprecated-migration.md を参照してください

  13. @Observable クラス内に @AppStorage を置く。 @AppStorage はビューの DynamicProperty です。View 内に置くか、モデル内で UserDefaults を使って通常の監視対象プロパティを公開します。

  14. すべてのスタックで spacing: をハードコードする — 省略するとプラットフォームに適応した間隔が使われます。値を明示する意図がある場合にのみ指定します

  15. .copyable、.cuttable、またはコマンドベースの .pasteDestination(for:action:validator:) を iOS 16/iOS 26 の API とみなす — 現在の Apple ドキュメントでは、これらは macOS 13 以降、および iOS/iPadOS/Mac Catalyst 27 のベータ版で利用できます。iOS 26 を対象にする場合は、UIPasteboard、ドラッグ&ドロップ、または ShareLink を使います。

  16. 最新の標準的な方法を正式な非推奨 API とみなす — #Preview は最新のプレビューで標準的に使われますが、PreviewProvider は旧式であり、コンパイラによる非推奨指定はありません。EditButton、.onDelete、.onMove は引き続き編集モードのリスト操作に使用できます。コンテキストに応じた行アクションには .swipeActions を使います。

  17. プレビューのクラッシュを防ぐために必須の依存関係をオプショナルにする — 代わりに、ライブネットワーク、認証、本番データベース、グローバルシングルトンを使わずに、決定論的なプレビュー用依存関係を設定します

レビューチェックリスト

  • 共有状態モデルには @Observable を使用しています(iOS 17 以降では ObservableObject を使用しません)
  • オブジェクトは @State で所有し、let/@Bindable で受け取っています
  • 移行と利用可能性に関する記述は、現在のプラットフォーム対応状況を確認しています。特にクリップボードと共有 API を確認しています
  • NavigationStack を使用しています(NavigationView は使用しません)
  • 非同期のデータ読み込みには .task 修飾子を使用しています
  • 大規模なコレクションには LazyVStack/LazyHStack を使用しています
  • 安定した Identifiable ID を使用しています(配列インデックスは使用しません)
  • 抽出の判断には分岐/レイアウト、ライフサイクル、依存関係、プレビュー、親フローのシグナルを使用し、小さくステートレスな断片は計算プロパティのままにしています
  • 拡張と // MARK: はファイルの整理にのみ使用しています
  • 構造のみのリファクタリングでは動作を維持しています。薄いアクション/ライフサイクルメソッドを使い、再利用可能なロジックはサービス/モデルに保持してから、ビルドし、有用なプレビューをレンダリングしています
  • プレビューでは、決定論的なフィクスチャと必須の環境依存関係をすべて用いて、意味のある読み込み済み/読み込み中/空/エラー状態を網羅しています
  • ビューの body で重い計算を行っていません
  • 広く共有される状態には Environment を使用しています
  • 固定色よりセマンティックなスタイル設定が適切な場合は、foregroundStyle(_:) を使用しています
  • スタイルを繰り返し適用する場合は、カスタムの ViewModifier を使用しています
  • .sheet(isPresented:) より .sheet(item:) を優先しています
  • シートは自身のアクションを管理し、内部で dismiss() を呼び出しています
  • MV パターンに従っています(不要なビューモデルは使用しません)
  • UI にバインドされる @Observable ストアとビューモデルは @MainActor で分離されています
  • 並行処理の境界を越えて渡されるモデル型は Sendable です
  • 特定の値が必要な場合を除き、Stack の spacing: は省略しています(適応型のデフォルトを優先)

参考資料

コメント