Avoids SwiftUI layout pitfalls like frame-safeAreaInset conflicts, button hit areas, ForEach crashes; enforces best practices for code generation, fixes, multi-device layouts.
How this skill is triggered — by the user, by Claude, or both
Slash command
/swiftui-best-practice:swiftui-best-practiceThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Always check [references/layout-pitfalls.md](references/layout-pitfalls.md) for known SwiftUI layout issues. Key ones:
Always check references/layout-pitfalls.md for known SwiftUI layout issues. Key ones:
.frame(width:height:) + .safeAreaInset -> use .frame(maxWidth:maxHeight:) insteadViewThatFits + .frame(maxWidth: .infinity) -> remove the frame from items inside ViewThatFitscontentFooterClearance must match custom bottom bar height.clipShape / .cornerRadius must come directly after .background — .padding を間に挟むと角丸が見た目に反映されない.buttonStyle(.plain).buttonStyle(.plain) はテキスト/アイコン部分だけがクリック可能になり、.frame() で確保した余白はタップに反応しない。必ず .contentShape(Rectangle()) を併用する。
// ❌ BAD — frame の余白部分がクリックできない
Button { action() } label: {
Text("ボタン")
.frame(maxWidth: .infinity)
.frame(height: 48)
}
.buttonStyle(.plain)
.background(Color.green, in: RoundedRectangle(cornerRadius: 10))
// ✅ GOOD — frame 全体がクリック可能
Button { action() } label: {
Text("ボタン")
.frame(maxWidth: .infinity)
.frame(height: 48)
.contentShape(Rectangle()) // ← これが必須
}
.buttonStyle(.plain)
.background(Color.green, in: RoundedRectangle(cornerRadius: 10))
最小タップサイズは 44pt (Apple HIG 推奨)。小さいアイコンボタンでも .frame(width: 44, height: 44).contentShape(Rectangle()) で包む。
丸ボタンの場合は .contentShape(Circle()) を使う:
// ❌ BAD — 丸い背景の外側も含めて四角にタップ判定される or アイコンだけしか反応しない
Button { action() } label: {
Image(systemName: "plus")
.frame(width: 52, height: 52)
}
.buttonStyle(.plain)
.background(Color.gray, in: Circle())
// ✅ GOOD — 丸い背景全体がタップ可能
Button { action() } label: {
Image(systemName: "plus")
.frame(width: 52, height: 52)
.contentShape(Circle()) // ← 丸ボタンはこちら
}
.buttonStyle(.plain)
.background(Color.gray, in: Circle())
.background() は label の外に置く: .background(in: Shape) を label 内に入れると contentShape とバッティングして押せない領域ができる。.buttonStyle(.plain) の直後に .background() を付ける。
ForEach(array.indices, id: \.self) や ForEach($array) で配列を表示し、ボタンで要素を削除すると index out of range でクラッシュ する。SwiftUI の差分更新と配列インデックスのタイミング不整合が原因。
詳細は references/foreach-mutation.md を参照。
// ❌ BAD — 削除時にインデックスがずれてクラッシュ
ForEach(items.indices, id: \.self) { index in
HStack {
TextField("", text: $items[index])
Button { items.remove(at: index) } label: { Image(systemName: "xmark") }
}
}
// ❌ STILL BAD — Identifiable でも削除アクション内でキャプチャした item が古い
ForEach($viewModel.items) { $item in
Button {
let idx = viewModel.items.firstIndex(where: { $0.id == item.id }) ?? 0
let prevID = viewModel.items[max(0, idx - 1)].id // ← 削除前のインデックスで参照→クラッシュ
viewModel.items.removeAll { $0.id == item.id }
focusedID = prevID
} label: { Image(systemName: "xmark") }
}
// ✅ GOOD — 削除前に次のフォーカス先を安全に算出し、DispatchQueue で遅延実行
ForEach($viewModel.items) { $item in
Button {
let targetID: UUID? = {
guard viewModel.items.count > 1,
let idx = viewModel.items.firstIndex(where: { $0.id == item.id }) else { return nil }
return idx > 0 ? viewModel.items[idx - 1].id : viewModel.items[idx + 1].id
}()
viewModel.removeItem(id: item.id)
if let targetID {
DispatchQueue.main.async { focusedID = targetID }
}
} label: { Image(systemName: "xmark") }
}
要点:
Identifiable にする(id: \.self は NG)removeAll { $0.id == id })DispatchQueue.main.async で1フレーム遅延させる@FocusState は 宣言した View と同じビュー階層 のフォーカスしか制御できない。.sheet は独立したビュー階層を作るため、親 View の @FocusState を sheet 内で使っても動かない。
詳細は references/focusstate-ownership.md を参照。
// ❌ BAD — 親の @FocusState を sheet 内で使う → フォーカスが当たらない
struct ParentView: View {
@FocusState private var focusedID: UUID?
@State private var items: [Item] = [...]
var body: some View {
Button("Show") { showSheet = true }
.sheet(isPresented: $showSheet) {
ForEach($items) { $item in
TextField("", text: $item.text)
.focused($focusedID, equals: item.id) // ← 動かない
}
}
}
}
// ✅ GOOD — sheet 内のビューが自身の @FocusState を持つ
struct ItemListView: View {
@Binding var items: [Item]
@FocusState private var focusedID: UUID? // ← ここで宣言
var body: some View {
ForEach($items) { $item in
TextField("", text: $item.text)
.focused($focusedID, equals: item.id) // ← 正常に動く
}
}
}
// 親は ItemListView を sheet に渡すだけ
.sheet(isPresented: $showSheet) {
ItemListView(items: $items)
}
要点:
@FocusState は使用する TextField と同じ View struct 内で宣言する.sheet / .fullScreenCover 内で使うなら、専用の子 View struct に切り出すFocusState<T>.Binding を渡しても動かないATTrackingManager.requestTrackingAuthorization() は .onAppear ではなく scenePhase == .active のタイミングで呼ぶ。.onAppear で呼ぶと iPadOS(MultiScene)でダイアログが表示されずリジェクトされる。
詳細は references/att-scene-phase.md を参照。
// ❌ BAD — .onAppear で ATT → iPadOS でダイアログが出ない
.onAppear {
Task { await ATTrackingManager.requestTrackingAuthorization() }
}
// ✅ GOOD — scenePhase == .active で ATT
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
Task { await AdService.shared.requestTrackingAndInitialize() }
}
}
See references/adaptive-layout.md for breakpoint patterns and responsive design best practices.
When writing or modifying SwiftUI layout code:
.frame(width:height:) that may block .safeAreaInset or .overlay propagation.frame(maxWidth: .infinity) on items inside ViewThatFits — it defeats intrinsic sizingGeometryReader, prefer .frame(maxWidth:maxHeight:) over fixed .frame(width:height:) for child views.safeAreaInset(edge: .bottom) require matching scroll content padding.background, .clipShape, .overlay, .shadow) must be grouped together — .padding between .background and .clipShape breaks visible rounding.buttonStyle(.plain) を使うときは、label 内の最外 .frame() の直後に .contentShape(Rectangle()) を付ける — これがないとテキスト部分しかクリックできないForEach で動的配列を表示するとき、要素は必ず Identifiable にする — ForEach(array.indices, id: \.self) + 要素削除は 確実にクラッシュ するDispatchQueue.main.async で遅延させる — 同一フレーム内だと SwiftUI の差分更新と競合する@FocusState は sheet 内で使うなら sheet のコンテンツ View 自体が所有すること — 親 View の @FocusState を sheet 越しに渡しても動かないButton ではなく Image + .onTapGesture を使う — Button タップはキーボードを一瞬閉じてしまう.onSubmit もキーボードを一瞬閉じるため、連続フォーカス移動には TextField(axis: .vertical) + onChange で改行検知する方式を使うATTrackingManager.requestTrackingAuthorization() は .onAppear ではなく scenePhase == .active で呼ぶ — .onAppear では iPadOS でダイアログが表示されずリジェクトされるSwiftUI のボタンヒットエリア問題を自動検出するサブエージェント。 SwiftUI の View ファイルを作成・修正した後に起動して、漏れを防ぐ。
Agent(
subagent_type: "swiftui-best-practice:swiftui-hit-area-auditor",
prompt: "以下のファイルを監査してください: {対象ファイルパス}"
)
| ルール | 検出内容 |
|---|---|
| Rule 1 | .buttonStyle(.plain) の label 内に .contentShape() がない |
| Rule 2 | .background() が label 内にある(label 外に置くべき) |
| Rule 3 | ボタンの frame が 44pt 未満(Apple HIG 違反) |
| Rule 4 | 丸ボタン (.background(in: Circle())) に .contentShape(Rectangle()) を使っている |
npx claudepluginhub sean-sunagaku/claude-code-plugin --plugin swiftui-best-practiceBuilds SwiftUI layouts with stacks, grids, lists, scroll views, forms, controls, search, and overlays. Use for data-driven layouts, collection views, settings screens, search interfaces, or overlay UI.
Provides best practices and examples for SwiftUI views, components, navigation hierarchies, custom modifiers, responsive layouts with stacks/grids, and state management (@State/@Binding). Use for creating/refactoring iOS UI.
Builds, reviews, and refactors SwiftUI code with guidance on state management, view composition, performance, accessibility, and iOS 26+ Liquid Glass styling.