SwiftUIエキスパート は何をしますか?
SwiftUIの画面は状態の所有権でその命運が決まります。間違った場所に状態を置くと、キー入力のたびにツリー全体が再評価され、リストがスタッターし、アニメーションが引き裂かれます。このスキルは、無効化が変更されたデータを実際に読み取るビューに限定され、スクロール中もフレーム予算が維持され、結果的にアクセシビリティが後付けではなくデフォルトで確保されるように、SwiftUIビューを構築およびレビューします。
操作手順
状態設計が下流のすべてを決定するため、この順序で作業します。状態を修正するよりも後にビュー階層が構築されていると、階層を書き直す必要があります。
ステップ1: 入力の収集
ビューコードを記述する前に収集します。ユーザーが不明な場合はデフォルトを使用し、推測であることを明記します。
- ターゲットデバイスとリフレッシュレート(デフォルト:ProMotion 120Hz iPhoneと60Hzベースラインデバイスの両方を想定)。
- データの形状:最大のリスト内のアイテム数、データがどの程度の頻度で変更されるか、ストリーミングされるかどうか。
- 所有権マップ:状態の各ピースについて、どのビューが作成し、どのビューが読み取り、どのビューが変更するか。
- ベースライン以上のアクセシビリティ要件(デフォルト:完全なDynamic Type + VoiceOverサポート)。
ステップ2: 値ごとの状態所有権の選択
機能する最も狭いラッパーを選択します。
@State- ローカル、ビュー所有、値型の状態。ビューが作成する@Observableモデルインスタンスの正しい所有者でもあります。@Binding- 他の場所で所有されている状態への双方向参照。子が変更する場合にのみバインディングを渡し、読み取り専用の場合はプレーンな値を渡します。@Observable(Observationフレームワーク) - モデルオブジェクト。ビューは読み取ったプロパティのみを監視するため、無効化はオブジェクト単位ではなくプロパティ単位です。@Environment- サービスや共有モデルのための依存性注入をツリーに下ろします。
ルール:状態は、それを読み取りまたは書き込みするすべてのビューの最も近い共通祖先に配置します。それより下(ビューが非表示になるとリセットされる)でも上(無効化が広がる)でもありません。
ステップ3: 無効化をスコープするためのビューの構成
bodyを小さく保ちます。サブビューを抽出することで、変更が画面全体ではなく1行のみを無効化します。約50行または3レベルのネストを超えるbodyは分割候補です。- サブビューには最小限のデータのみを渡します。ストア全体ではなく
item.titleを渡すことで、影響を受けるビューのみが再レンダリングされます。 - 巨大なネストされたクロージャの代わりに
@ViewBuilderヘルパーを使用します。 body内で重い処理(フォーマット、ソート、フィルタリング、デコード)を決して行わないでください。モデルで計算し、ビューには完成した値を渡します。bodyはマイクロ秒の範囲に保つ必要があります。アニメーション中は毎フレーム実行される可能性があるためです。
ステップ4: リストとフレーム予算
予算は120Hzで1フレームあたり8.3ms、60Hzで16.7msです。これはそのフレームのメインスレッド上のすべての処理であり、単一の行の処理だけではありません。実用的なルール:
- 約50行を超える場合は
ListまたはLazyVStackを使用し、行が必要に応じてレンダリングされるようにします。ScrollView内のプレーンなVStackはすべての行を事前に構築します。 - アイテムにはデータから安定した
id(item.id)を割り当て、配列インデックスやbody内で計算されたUUID()は決して使用しないでください。不安定なIDは行の同一性を破壊し、ちらつき、スクロール位置の喪失、行状態の喪失を引き起こします。 - 行は一定コストでなければなりません。行ごとの日付フォーマッターや画像デコードは行わず、フォーマッターをキャッシュし、
AsyncImageまたはキャッシュ画像パイプラインを使用します。 - InstrumentsのSwiftUIテンプレートで検証します。スクロール中のview-body評価回数を確認します。無関係な状態が変更されたときにbodyが評価される行タイプは、状態所有権のバグです。ステップ2を修正し、その周りをメモ化しないでください。
ステップ5: 状態からのアニメーション
- 状態からアニメーションを駆動します:
withAnimation { model.expanded.toggle() }。 - 暗黙的なグローバルアニメーションよりも、特定の値にスコープされた
.animation(_:value:)を優先します。暗黙的なアニメーションは意図しないものをアニメーション化します。 - 共有要素トランジションには
matchedGeometryEffectを、挿入/削除にはtransitionを使用します。 accessibilityReduceMotionを尊重します。設定されている場合は、移動を透明度フェードに置き換えます。
ステップ6: アクセシビリティパス
accessibilityLabel/accessibilityValueを追加します。関連する要素をaccessibilityElement(children: .combine)でグループ化し、行が5つの断片ではなく1つの文として読み上げられるようにします。- Dynamic Typeを尊重します。システムテキストスタイルを使用し、テキストをクリップする固定フレームは避けます。最大のアクセシビリティサイズで検証します。
- コントラストを確認し、ダークモード、大規模Dynamic Type、空/読み込み中/エラー状態の
#Previewバリアントをサンプルデータとともに追加します。
成果物:状態所有権、悪い例と良い例
悪い例 - 検索テキストがツリー全体が監視するモデルにあり、行がストア全体を受け取るため、キー入力のたびに画面全体が無効化されます。
@Observable final class ScreenStore {
var searchText = ""
var items: [Item] = []
}
struct ContactList: View {
@State private var store = ScreenStore()
var body: some View {
VStack {
TextField("Search", text: $store.searchText)
ForEach(store.items) { item in
ContactRow(store: store, item: item) // 行はすべてを監視
}
}
}
}
良い例 - 検索テキストはビューローカルであり、行は読み取る値のみを取得し、リストは安定したIDで遅延ロードされます。
struct ContactList: View {
@State private var searchText = ""
@Environment(ContactModel.self) private var model
var body: some View {
List(model.filtered(searchText)) { item in
ContactRow(name: item.name, isOnline: item.isOnline)
}
.searchable(text: $searchText)
}
}
struct ContactRow: View {
let name: String
let isOnline: Bool
var body: some View {
Label(name, systemImage: isOnline ? "circle.fill" : "circle")
}
}
良いバージョンでの入力は、Listコンテンツクロージャとフィルタリングされたメンバーシップが変更された行のみを無効化します。それ以外は無効化されません。
成果物
動作するSwiftUIビューコードと、状態の各ピースについてその所有者、ラッパー、読み取り/書き込みを行うビューを記載した1段落の状態マップを作成します。レビューの場合は、フレーム予算への影響順に並べられた、各修正を含む所見リストを作成します。
禁止事項
body評価中に状態を変更しないでください。ループしたり、未定義動作の警告がトリガーされます。onAppearで生のTaskを使用して非同期処理を行わないでください。.task {}を使用してください。これは非表示時に自動的にキャンセルされます。- モデルにビュータイプ(
Color、Image、AnyView)を入れないでください。モデルはUIなしでテスト可能である必要があります。 - 「シンプルさのために」すべての状態を1つの画面レベルのストアに持ち上げないでください。それはまさに、キー入力のたびに画面を再描画させるパターンです。
- 所有権を修正する前に
equatable()やキャッシュで無効化バグを隠蔽しないでください。バグは同じオブジェクトを監視する次のビューで再発します。 - テキストに固定フレームを使用しないでください。Dynamic Typeでクリップされます。
品質基準
完成したビューは以下の条件を満たす必要があります。
- 状態の各ピースには正確に1つの所有者があり、機能する最も狭いラッパーが使用されている。
- Instrumentsが、行のbodyが自身のデータが変更された場合にのみ評価されることを示す。
- 予想される最大のリストのスクロールが、ベースラインの60Hzデバイスでフレーム予算を維持する。
- 長寿命の状態は、非表示になる可能性のあるビューの上に持ち上げられ、ナビゲーション後も存続する。
- キーボード回避とセーフエリアが処理されている(
safeAreaInset、scrollDismissesKeyboard)。 - VoiceOverが各インタラクティブ要素を意味のあるラベルで読み上げ、レイアウトが最大のDynamic Typeサイズでも耐え、
reduceMotionが尊重されている。 - 読み込み中、空、エラー、ダークモード、大規模テキスト状態のプレビューが存在する。
単一画面を超えたアプリ全体のプロファイリングについては、mobile-perf-profilerと連携してください。