Avoids SwiftUI layout pitfalls like frame-safeAreaInset conflicts, button hit areas, ForEach crashes; enforces best practices for code generation, fixes, multi-device layouts.
npx claudepluginhub sean-sunagaku/claude-code-plugin --plugin swiftui-best-practiceThis skill uses the workspace's default tool permissions.
Always check [references/layout-pitfalls.md](references/layout-pitfalls.md) for known SwiftUI layout issues. Key ones:
Routes iOS UI issues to specialized skills for SwiftUI, UIKit, layout, navigation, animations, design guidelines, accessibility, and tvOS.
Applies Apple Human Interface Guidelines for iPhone UI in SwiftUI and UIKit. Ensures 44pt touch targets, safe areas, thumb zones, grid alignment, accessibility, Dynamic Type, and Dark Mode compliance.
Enforces Apple Human Interface Guidelines for iPhone apps in SwiftUI/UIKit. Covers layout, 44pt touch targets, safe areas, thumb zones, screen size support, accessibility, Dynamic Type, Dark Mode.
Share bugs, ideas, or general feedback.
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()) を使っている |