swiftui-design-principles について
swiftui-design-principles は、SwiftUI の画面、WidgetKit ウィジェット、ネイティブ Apple UI の構築・変更に向けた設計指針です。余白、文字、色、コンポーネント、ナビゲーション、ウィジェットの実践的なルールをまとめています。
主な機能
- 01一貫した余白グリッドの使用
- 02明確なタイポグラフィ階層の構築
- 03システムのセマンティックカラーを優先
- 04ネイティブのコンポーネントとレイアウトに準拠
- 05WidgetKit のサイズと更新方針を網羅
活用シーン
- 01SwiftUI の画面を構築・修正
- 02WidgetKit ウィジェットを設計
- 03アプリとウィジェットの見た目を統一
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ arjitj2/ swiftui-design-principles --skill swiftui-design-principlesインストール後、Claude Code で/swiftui-design-principlesを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
AGENTS.mdLICENSEREADME.mdmetadata.json詳細
このスキルは、磨き上げられたプロダクション品質の SwiftUI アプリと、作りが甘いアプリを比較して導き出された設計原則をまとめたものです。ここに示すパターンは、マージン、スペーシング、テキストサイズがどこか「ずれて」見えるアプリと、そう感じさせない「正しい」アプリを分けるものです。
SwiftUI のインターフェース、WidgetKit ウィジェット、その他 Apple のネイティブ UI を構築・変更するときは常に、これらの原則を適用してください。
基本理念
装飾よりも抑制。 すべてのピクセルはその場所にふさわしい理由を持つべきです。磨き上げられたアプリは、より少ない色、より少ないフォントサイズ、より少ないスペーシング値、より少ない言葉を使います。ただし、それらを一貫して使います。視覚要素の過剰な作り込み(カスタムグラデーション、装飾的なボーダー、独自のディバイダー)は視覚的なノイズを生みます。ネイティブコンポーネントとシステムカラーが調和を生みます。
注意力は貴重です。 UI の文言は、必要だと思うよりも短く保ちましょう。タイトル、サブタイトル、本文、フッターで説明を繰り返すよりも、1 つの明確な見出しと 1 つの簡潔な補足ブロックを優先してください。画面に根拠を示す必要があるなら、ページ全体に散りばめるのではなく、1 つの意図された場所に置きましょう。
1. スペーシングシステム: 一貫したグリッドを使う
重要: スペーシング値は base-4/base-8 グリッドから使ってください。恣意的な値は決して使わないでください。
許可されるスペーシング値
4, 8, 12, 16, 20, 24, 32, 40, 48
悪い例(視覚的な不一致を生む恣意的な値)
// WRONG - these numbers have no relationship to each other
.padding(.bottom, 26)
.padding(.bottom, 34)
.padding(.bottom, 36)
HStack(spacing: 18)
.padding(14)
良い例(一貫したグリッドからの値)
// RIGHT - predictable rhythm the eye can follow
.padding(.horizontal, 20)
.padding(.top, 8)
Spacer().frame(height: 32)
HStack(spacing: 4) // or 8, 12, 16
.padding(.vertical, 12)
.padding(.horizontal, 16)
標準的なパディングの割り当て
- 外側のコンテンツパディング: 水平 16〜20pt
- 主要セクション間: 垂直 24〜32pt
- グループ化されたコンポーネント内: 4〜12pt
- カード/行の内部パディング: 垂直 12〜16pt、水平 16pt
2. タイポグラフィ: サイズだけでなくウェイトによる階層
原則
より少ないフォントサイズを使い、明確なウェイトの差別化を行います。大きなサイズでは軽いウェイト、小さなサイズでは medium/regular を使います。これにより、視覚的な混沌ではなく洗練された印象を生みます。
推奨タイプスケール(データ中心のアプリ向け)
| 役割 | サイズ | ウェイト | 備考 |
|---|---|---|---|
| ヒーロー数値 | 36〜42pt | .light | 大きいが視覚的に軽い -- 重くなくエレガント |
| セカンダリ統計 | 20〜24pt | .light | ヒーローと同じウェイトファミリーで、より小さい |
| 本文 / トグルラベル | 15pt | .regular | 標準的な iOS の本文サイズ |
| セクションヘッダー(大文字) | 11pt | .medium | トラッキング/レタースペーシング付き |
| キャプション / サブタイトル | 11〜13pt | .regular | 補足情報 |
悪い例(サイズが多すぎ、ウェイトが不一致)
// WRONG - 7 different sizes with no clear system
.font(.system(size: 60, weight: .ultraLight)) // hero
.font(.system(size: 44, weight: .regular)) // stat (too close to hero)
.font(.system(size: 31, weight: .ultraLight)) // percent symbol (odd ratio)
.font(.system(size: 18, weight: .regular)) // label (too big for a toggle)
.font(.system(size: 14, weight: .regular)) // header
.font(.system(size: 13, weight: .regular)) // another header
.font(.system(size: 12, weight: .regular)) // button (too small to read)
良い例(明確な階層、より少ないサイズ)
// RIGHT - 5 sizes, clear purpose for each
.font(.system(size: 42, weight: .light, design: .monospaced)) // hero
.font(.system(size: 24, weight: .light, design: .monospaced)) // stat value
.font(.system(size: 15, weight: .regular, design: .monospaced)) // body
.font(.system(size: 14, weight: .regular, design: .monospaced)) // secondary
.font(.system(size: 11, weight: .medium, design: .monospaced)) // label
フォントデザインの一貫性
1 つのフォントデザインを選び、アプリでもウィジェットでもどこでも使ってください:
// If using monospaced, use it everywhere
design: .monospaced // app views, widgets, lock screen -- all of them
// NEVER mix designs between app and widgets
// BAD: .monospaced in app, .rounded in lock screen widget
レタースペーシング(トラッキング)
最大 2 つの値までにし、大文字ラベルにのみ使ってください:
.tracking(1.5) // section labels: "NOTIFICATIONS", "DAY", "LEFT"
.tracking(3) // navigation/toolbar titles
kerning(4)、kerning(4.5)、kerning(5) のような 3 つ以上の異なるトラッキング値を決して使わないでください -- 違いは知覚できないほどですが、その不一致は潜在意識に訴えかけます。
識別子の数値フォーマット
年やその他の固定識別子はロケールのグループ化を行うべきではありません。
// RIGHT - stable, non-grouped identifier text
Text(String(year)) // "2026"
Text(year, format: .number.grouping(.never))
// WRONG - locale grouping can render "2,026"
Text("\(year)")
3. カラー: ハードコードされた値よりもシステムのセマンティックカラー
原則
SwiftUI のセマンティックカラーシステムを使ってください。ライト/ダークモード、アクセシビリティを自動的に処理し、ネイティブに見えます。手動の不透明度値を使ったハードコードされたカラーはメンテナンスの悪夢を生み、人工的に見えます。
悪い例(何十もの不透明度値を持つハードコードされた白)
// WRONG - impossible to maintain, doesn't adapt to light mode
Color.black.ignoresSafeArea() // forced dark
Color.white.opacity(0.08) // ring background
Color.white.opacity(0.09) // divider
Color.white.opacity(0.3) // year text
Color.white.opacity(0.32) // stat label
Color.white.opacity(0.42) // percent symbol
Color.white.opacity(0.44) // toggle tint
Color.white.opacity(0.72) // button text
Color.white.opacity(0.88) // toggle label
Color.white.opacity(0.9) // stat value
Color.white.opacity(0.94) // ring fill
良い例(セマンティックなシステムカラー)
// RIGHT - adapts automatically, looks native, easy to maintain
Color(.systemBackground) // main background
Color(.secondarySystemBackground) // card/group backgrounds
Color(.separator) // dividers (with optional opacity)
Color.primary // primary text and UI elements
.foregroundStyle(.secondary) // secondary text
.foregroundStyle(.tertiary) // labels, captions
不透明度が本当に必要な場合
明確な目的を持つ 2〜3 つの値に限定してください:
.opacity(0.15) // subtle background strokes
.opacity(0.3) // separator lines
// That's it. If you need more, you're probably hardcoding what semantic colors handle.
4. コンポーネントのサイズ: 大きすぎず、比例して
プログレスリング / 円形インジケーター
// App main view: 200x200 with thin stroke
.frame(width: 200, height: 200)
Circle().stroke(..., lineWidth: 3)
// Widget (systemSmall): 90x90, same stroke
.frame(width: 90, height: 90)
Circle().stroke(..., lineWidth: 3)
// WRONG: oversized ring with thick inconsistent strokes
.frame(width: 260, height: 260) // too large, dominates screen
Circle().stroke(..., lineWidth: 9) // background
Circle().stroke(..., lineWidth: 8) // fill -- WHY different from background?
ストローク幅の一貫性
同じ要素の背景ストロークと前景ストロークには常に同じ lineWidth を使ってください:
// RIGHT
Circle().stroke(background, lineWidth: 3)
Circle().trim(from: 0, to: fraction).stroke(fill, lineWidth: 3)
// WRONG - creates visual misalignment
Circle().stroke(background, lineWidth: 9)
Circle().trim(from: 0, to: fraction).stroke(fill, lineWidth: 8)
リスト行とトグル行
// RIGHT - natural sizing with proper padding
Toggle(isOn: $value) {
Text(title)
.font(.system(size: 15, weight: .regular, design: .monospaced))
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
// WRONG - fixed oversized height
HStack {
Text(label)
.font(.system(size: 18)) // too big for a toggle label
Spacer()
Toggle("", isOn: $isOn)
.labelsHidden() // why hide the label? Use Toggle properly
}
.frame(height: 70) // way too tall
5. グループ化されたコンテンツとカード: システムパターンを使う
悪い例(過剰に作り込まれたカスタムカード)
// WRONG - custom gradient, overlay border, huge corner radius
VStack { ... }
.padding(.vertical, 4) // too tight
.background(
RoundedRectangle(cornerRadius: 22) // too round
.fill(LinearGradient( // unnecessary gradient
colors: [Color(white: 0.10), Color(white: 0.085)],
startPoint: .topLeading, endPoint: .bottomTrailing
))
)
.overlay(
RoundedRectangle(cornerRadius: 22)
.stroke(Color.white.opacity(0.08), lineWidth: 1) // decorative border
)
良い例(ネイティブなグループ化スタイル)
// RIGHT - simple, native, works in light and dark mode
VStack(spacing: 0) {
row1
Divider().padding(.leading, 16)
row2
Divider().padding(.leading, 16)
row3
}
.background(Color(.secondarySystemBackground))
.clipShape(.rect(cornerRadius: 10))
グループ化されたコンテンツの重要なルール
- 角丸: カード/グループには 10pt(iOS のシステムスタイルに一致)。22pt 以上は決して使用しない。
- 区切り線: システムの
Divider()を.padding(.leading, 16)とともに使用し、iOS 標準のインセットにする。カスタムの区切り線構造体は決して作らない。 - カードのパディング: 垂直 12〜16pt、水平 16pt。垂直 4pt は決して使用しない。
- 背景:
Color(.secondarySystemBackground)-- 標準的なカードにカスタムグラデーションは決して使用しない。
6. ナビゲーション: NavigationStack を使用する
// RIGHT - proper navigation with minimal toolbar
NavigationStack {
ScrollView {
content
}
.toolbar {
ToolbarItem(placement: .principal) {
Text("Title")
.font(.system(size: 13, weight: .medium, design: .monospaced))
.tracking(3)
.foregroundStyle(.tertiary)
}
}
.navigationBarTitleDisplayMode(.inline)
}
// WRONG - no navigation structure, just a ZStack
ZStack {
Color.black.ignoresSafeArea()
ScrollView {
VStack {
Text("2026").font(...) // manually placed "title"
content
}
}
}
7. WidgetKit: ネイティブコンポーネントを使用する
円形のロック画面ウィジェット
// RIGHT - use Gauge, it's purpose-built for this
Gauge(value: entry.fraction) {
Text("")
} currentValueLabel: {
Text("\(Int(entry.percentage))%")
.font(.system(size: 12, weight: .medium, design: .monospaced))
}
.gaugeStyle(.accessoryCircular)
.containerBackground(.fill.tertiary, for: .widget)
// WRONG - manual circle drawing for lock screen
ZStack {
Circle().stroke(Color.primary.opacity(0.18), lineWidth: 4)
Circle().trim(from: 0, to: progress).stroke(...)
Text(percentText)
.font(.system(size: 14, weight: .bold, design: .rounded)) // wrong font design!
}
長方形のロック画面ウィジェット
// RIGHT - use Gauge with linearCapacity
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(year).font(.system(size: 13, weight: .semibold, design: .monospaced))
Spacer()
Text(percentage).font(.system(size: 13, weight: .medium, design: .monospaced))
.foregroundStyle(.secondary)
}
Gauge(value: fraction) { Text("") }
.gaugeStyle(.linearCapacity)
.tint(.primary)
HStack {
Spacer()
Text("\(dayOfYear)/\(totalDays)")
.font(.system(size: 11, weight: .regular, design: .monospaced))
.foregroundStyle(.secondary)
}
}
.containerBackground(.fill.tertiary, for: .widget)
// WRONG - custom GeometryReader progress bar
GeometryReader { proxy in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2).fill(Color.primary.opacity(0.16))
RoundedRectangle(cornerRadius: 2).fill(Color.primary)
.frame(width: max(2, proxy.size.width * progress))
}
}
.frame(height: 6)
ウィジェットの背景
// RIGHT
.containerBackground(.fill.tertiary, for: .widget)
// WRONG - hardcoded color
.containerBackground(.black, for: .widget)
ウィジェットファミリーの網羅
関連するすべてのファミリーをサポートしてください -- 一般的なものを飛ばさないようにします:
.supportedFamilies([
.accessoryCircular, // lock screen circle
.accessoryRectangular, // lock screen rectangle
.accessoryInline, // lock screen inline text
.systemSmall, // home screen small
.systemMedium, // home screen medium
.systemLarge, // home screen large
])
ファミリー間の視覚的な一貫性
中型と大型のホームウィジェットは、同じ構造レイアウトを共有する必要があります:
- ヘッダー: 左に年、右にパーセンテージ
- 中央: プログレスバー
- フッター: 右揃えで
day/total
厳しいサイズ制約がない限り、ファミリーごとに階層を再発明しないでください。
丸い端付近でのクリッピングを避けるため、ホームウィジェットには常に明示的な内部パディングを含めてください:
.padding(.horizontal, 12)
.padding(.vertical, 12)
ウィジェットのメモリ予算(ハードリミット)
ウィジェット拡張には厳しいメモリ予算があります(一般に約 30 MB)。密なビジュアライゼーションは、ネストされたビューが多すぎると EXC_RESOURCE によって強制終了されることがあります。
// RIGHT - draw dense dot grids in one pass
Canvas { context, size in
// draw 365/366 dots here
}
// WRONG - hundreds of nested subviews (high memory overhead)
LazyVGrid(columns: columns) {
ForEach(1...366, id: \.self) { day in
ZStack { Circle(); partialFillLayer }
}
}
タイムラインの更新(データの粒度に合わせる)
// RIGHT - refresh at midnight for day-level data
let tomorrow = calendar.startOfDay(for: calendar.date(byAdding: .day, value: 1, to: now)!)
Timeline(entries: [entry], policy: .after(tomorrow))
// RIGHT - periodic refresh for time-of-day dependent percentages/partial fills
let refresh = Calendar.current.date(byAdding: .minute, value: 15, to: now)!
Timeline(entries: [entry], policy: .after(refresh))
// WRONG - minute-level refresh for static daily data
let tooFrequent = Calendar.current.date(byAdding: .minute, value: 1, to: now)!
Timeline(entries: [entry], policy: .after(tooFrequent))
8. インタラクティブな要素
トグル
// RIGHT - use Toggle with its built-in label, tint with a single accent color
Toggle(isOn: $value) {
Text(title)
.font(.system(size: 15, weight: .regular, design: .monospaced))
}
.tint(.green)
// WRONG - hidden label with manual HStack layout
HStack {
Text(label).font(.system(size: 18))
Spacer()
Toggle("", isOn: $isOn)
.labelsHidden()
.tint(Color.white.opacity(0.44)) // low-contrast tint
}
排他的なオプション
オプションが排他的な場合(例: 毎日/毎週/毎月の頻度)、3 つの独立したトグルではなく、1 つの選択値を使用してください。
// RIGHT - single source of truth
enum Cadence: String, CaseIterable { case daily, weekly, monthly }
@State private var cadence: Cadence = .daily
ForEach(Cadence.allCases, id: \.rawValue) { option in
Button {
cadence = option
} label: {
HStack {
Image(systemName: cadence == option ? "checkmark.circle.fill" : "circle")
Text(option.rawValue.capitalized)
}
}
}
// RIGHT - one preview action when content is shared
Button("Preview") { sendPreview() }
// WRONG - independent toggles allow contradictory state
Toggle("Daily", isOn: $daily)
Toggle("Weekly", isOn: $weekly)
Toggle("Monthly", isOn: $monthly)
変化する数値のアニメーション遷移
// Add to any Text that displays a changing numeric value
Text(String(format: "%.2f", percentage))
.contentTransition(.numericText())
9. インタラクティブエディタ: ジオメトリと状態の一元化
インタラクティブエディタ(コラージュ、クロップ、キャンバス、メディアフレーミングツール、レイアウトピッカー)には、通常のフォームよりも厳格な状態とレイアウトの規律が必要です。
表示状態
エディタのフローは、別個の Bool と個別に管理されたデータからではなく、ペイロードの状態から表示してください。
// RIGHT - presentation only happens when payload exists
@State private var activeCropRequest: CropRequest?
.sheet(item: $activeCropRequest) { request in
CropEditor(request: request)
}
// WRONG - the sheet can open before the backing data is ready
@State private var showCropEditor = false
@State private var selectedImage: UIImage?
.sheet(isPresented: $showCropEditor) {
if let selectedImage { CropEditor(image: selectedImage) }
}
共有ジオメトリモデル
アプリがパン/ズーム/クロップ/レイアウトをライブでプレビューし、後で結果をエクスポートする場合、プレビューとレンダリングの両方に 1 つの共有ジオメトリモデルを使用してください。
// RIGHT - one source of truth for bounds and transforms
let normalized = EditorGeometry.normalizedAdjustment(adjustment, imageSize: image.size, slotSize: slotSize)
let drawRect = EditorGeometry.drawRect(for: image.size, in: slotRect, adjustment: adjustment)
// WRONG - preview and export each invent their own math
let previewOffset = ...
let exportOffset = ...
ユーザーが背景が見えるほど十分にズームアウトできる場合、それはエディタ専用の例外ではなく、共有ジオメトリモデルの意図的な一部でなければなりません。
ジェスチャーの調整
タップ、長押しドラッグ、ピンチは独立した機能ではありません。SwiftUI では、それらの関係を明示的にモデル化しない限り競合します。
- アクティブなタイル/カード/キャンバス項目に対して単一のインタラクション状態を使用します。
- どのジェスチャーが優先され、どのジェスチャーが同時に実行されるべきかを決めます。
- 選択が変わったときは、一時的なジェスチャー状態を意図的にリセットします。
- 個々のジェスチャーに紐づいた散在するブール値よりも、1 つの一貫したステートマシンを優先します。
固定されたエディタレイアウト
画面がスクロールしてはならない場合、いくつかの名前付き領域を使って垂直方向のスペースをトップダウンで割り当てます:
- ヘッダー
- キャンバスステージ
- 設定領域
- 下部ツールバー
そのサイズ計算は 1 か所にまとめてください。各サブビューに独自の高さを発明させないようにします。
カスタムヘッダーとセーフエリア
システムのナビゲーションバーをカスタムヘッダーに置き換える場合:
- 親がすでにセーフエリアを尊重しているかどうかを明示してください。
- 反射的に
safeAreaInsets.topを追加しないでください。二重にカウントすると明らかなデッドスペースが生じます。 - カスタムヘッダーはコンパクトに保ってください。コンテンツのセクション全体ではなく、ナビゲーションクロームのように感じられるべきです。
設定サーフェス
エディタに複数の設定モード(Layout、Border、Ratio、Background など)がある場合、すべてのコントロールを画面に積み重ねるのではなく、一度に 1 つのアクティブな設定サーフェスを表示してください。
これによりキャンバスの視覚的な優位性が保たれ、各コントロールグループが理解しやすくなります。
10. データモデル: アプリとウィジェット間で共有する
// RIGHT - one model used everywhere
struct YearProgress {
// shared calculation logic
static func current() -> YearProgress { ... }
}
// Used by both ContentView and widget TimelineProvider
// If percentage is shown as live progress, include time-of-day in shared math
let dayProgress = elapsedInCurrentDay / totalSecondsInDay
let elapsedDays = Double(dayOfYear - 1) + dayProgress
let fraction = elapsedDays / Double(totalDays)
// WRONG - separate snapshot structs with duplicated date math
struct YearProgressSnapshot { ... } // in app
struct YearProgressWidgetSnapshot { ... } // in widget extension (duplicated!)
11. クイックチェックリスト
SwiftUI ビューを出荷する前に、次を確認してください:
- すべての余白値がグリッド(4、8、12、16、20、24、32)に基づいている
- フォントサイズは 5 種類以下の異なる値に抑えられている
- 単一のフォントデザインを一貫して使用している(ウィジェットを含む)
- 色はセマンティックなシステムカラーを使用しており、不透明度を指定したハードコードされた値を使用していない
- 背景と前景のストロークが同じ lineWidth を使用している
- カードは
Color(.secondarySystemBackground)と 10pt の角丸を使用している - 区切り線は先頭にパディングを付けたシステムの
Divider()を使用している - トグル行は Toggle の組み込みラベルを使用している(
.labelsHidden()ではない) - ロック画面ウィジェットは
Gaugeを使用している(手動の円描画ではない) - ウィジェットの背景は
.containerBackground(.fill.tertiary, for: .widget)を使用している - 年や識別子のテキストは、グループ化が不要な場合にロケールのグループ化を避けている
- トラッキング/カーニングは最大 2 つの値に抑えられている
- NavigationStack を使用している(素の ZStack ではない)
- タイムラインの更新頻度がデータの粒度と一致している(深夜か定期的か)
- 大規模/高密度のウィジェット表示は
Canvasなどの軽量なレンダリングを使用している - 中サイズと大サイズのウィジェットファミリーが一貫した階層と内部パディングを共有している
- 排他的な選択は単一の選択値を使用している(複数のトグルではない)
- UI がライブの進捗を示す場合、パーセンテージに時刻が含まれている
-
minimumScaleFactorのハックを使用していない -- 代わりにレイアウトを修正する - インタラクティブなエディタはペイロードの状態から表示され、
Bool+ 別のデータではない - プレビューとエクスポートがパン/ズーム/クロップ/レイアウトで同じジオメトリモデルを共有している
- カスタムヘッダーが上部のセーフエリアインセットを二重にカウントしていない
- スクロールなしのエディタ画面が集中管理されたレイアウトモデルを通じて高さを配分している
- マルチモードのエディタがすべてのコントロールを一度に表示するのではなく、1 つのフォーカスされた設定画面を表示している