swiftui-microinteractions について
legendary-Animo スタイルの、コンパイル可能な完全な SwiftUI アニメーションファイルを生成するスキルです。動きや視覚効果を作成・編集する開発者向けに、意図の推定、アセット確認、触覚フィードバック、プロジェクト内のファイル変更規則を備えています。
主な機能
- 01完全な SwiftUI アニメーションファイルの生成
- 02デザイン表現をアニメーションパターンへ変換
- 03ジェスチャー物理とタイミング付き触覚の対応
- 04既存画像アセットのスキャン
- 05変更対象をプロジェクトファイルに限定
活用シーン
- 01アニメーション付き SwiftUI ボタンやスライダーの作成
- 02スナップ閾値のあるドラッグ操作の構築
- 03SF Symbols や Canvas エフェクトのアニメーション
- 04既存 SwiftUI アニメーションの編集
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ iamvishal16/ swiftui-microinteractions --skill swiftui-microinteractionsインストール後、Claude Code で/swiftui-microinteractionsを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
LICENSECHANGELOG.mdREADME.md詳細
伝説的な Animo スタイルの、完全にコンパイル可能な SwiftUI アニメーションファイルを生成します。プレースホルダーや TODO コメントは使用しません。
使用する場面
- プレミアムな物理挙動を備えた SwiftUI ボタン、ジェスチャー、スライダー、または液体エフェクトを作成する
- アニメーションの各フェーズに正しくタイミングを合わせた触覚フィードバックが必要な場合
- 抵抗、スナップ、またはしきい値トリガーを備えたドラッグ操作を作成する
- 描画、呼吸、バウンス、置換、または可変カラーエフェクトで SF Symbols をアニメーション化する(iOS 17~26)
- コメットトレイルを使って図形の輪郭をトレースする Canvas ローダーを作成する(無限大、星、多角形など)
- 要素を平面グリッドから擬似 3D の回転形状(ドラム、球体、らせん)へ変形させ、元に戻す
- カードリビールパック(スワイプで破る、3D カードフリップ、統計値を順に表示)や Canvas パワーエフェクトのショーケースを作成する
- 実際のコントロール上を移動するオンボーディング用スポットライトツアー/コーチマークを作成する(暗いスクリーン、切り抜き、指し示すツールチップ)
- 既存の SwiftUI アニメーションファイルを編集する
対象範囲と安全性
- ユーザーの現在のプロジェクト内のみを書き換えます。新規または編集対象の
.swift(または.metal)ファイルと、必要な場合はそのプロジェクトのproject.pbxprojに限ります。作業ディレクトリ外には何も書き込みません。 - ネットワークアクセス、パッケージのインストール、シェルスクリプトの実行は行いません。編集モードでは、指定されたファイルのみを変更します。
モードの判定
- 編集モード:プロンプトに「edit」、「update」、「add X to」、「change」、または
.swiftファイル名が含まれる場合 → まずファイルを読み込み、依頼された内容のみを変更し、ディスク上のファイルを上書きします - 作成モード:それ以外の場合 → 新しい完全なファイルを生成してディスクに書き込みます
意図の推定(コード生成前に実行)
コードを生成する前に、プロンプトを解析して、指定が不十分な詳細を特定し、既定値を適用します。ユーザーが確認し、必要に応じて変更できるよう、🔍 Inferred from prompt: ブロックを出力します。推定した項目のみを出力し、ユーザーが明示した項目は出力しません。
Step 0 — デザイナーの用語を標準化する
後続の手順を実行する前に、プロンプト内のデザイナー用語を、スキルが理解できる用語に変換します。これにより、「期待感で弾ける、キレのあるカード」のような技術的でないプロンプトでも、詳細に指定されたプロンプトと同じ出力が得られます。
アニメーションの雰囲気を表す言葉 → 物理プリセット:
| デザイナーの表現 | 対応先 |
|---|---|
| キレのある/シャープ/タイト | UI pop 0.35/0.6 |
| 弾む/バネのような/弾力のある | Snap 0.35/0.5 |
| 伸びる/ゴムのような/揺れる | 3フェーズのラバーバンド(iOS 26 のセクションを参照) |
| 溶ける/浮かぶ/柔らかい/穏やかな | Slow morph 0.6/0.8 |
| 硬い/正確/機械的 | Precision stiff interpolatingSpring(220, 22) |
| リニア/一定/変化のない | .linear(duration:) |
モーションの原則を表す言葉 → アーキタイプ/アニメーションシグナル:
| デザイナーの表現 | 意味するもの |
|---|---|
| 予備動作 | メインの動きの前に伸びるフェーズ |
| フォロースルー | アニメーションが静止する際にオーバーシュートを加える |
| スカッシュ&ストレッチ | 衝撃時のスケール変形(x を拡大し、y を圧縮) |
| ステージング/ずらし | 要素ごとに .animation(delay:) で時間差をつける |
| パルス/呼吸 | .breathe の isActive ループ |
| 波紋 | タップ位置から Circle をスケールさせて放射状に広げる |
| 洪水 | Liquid Toggle アーキタイプ、メタボールの塗りつぶし |
| 揺れ/シェイク | .wiggle value: — エラー/拒否のシグナル |
| ポップ | .scaleEffect ですばやくオーバーシュートさせ、跳ね戻す |
| モーフ | Glass Morph アーキタイプまたはシェイプ補間 |
| リキッドクローム/溶けた金属/水銀/ブラッシュドスチール | Metal Shader — スティッチ可能な .colorEffect(Metal Shaders のセクションを参照) |
| ホログラフィック/玉虫色/油膜/箔/プリズム | Metal Shader — .colorEffect 内のフレネル虹色効果 |
| プラズマ/溶岩/星雲/オーロラ/流体インク | Metal Shader — .colorEffect 内の FBM フィールド |
UI 要素の名前 → SwiftUI プリミティブ:
| デザイナーの表現 | 別名 | SwiftUI |
|---|---|---|
| ボトムシート/トレイ/ドロワー/パネル | 引き上げ式、モーダルシート | .sheet + .presentationDetents |
| モーダル/ダイアログ/オーバーレイ/ライトボックス | 全画面シート | .sheet(全画面)または .fullScreenCover |
| ポップオーバー/ツールチップ/コールアウト/吹き出し | ホバーカード | .popover |
| トースト/スナックバー/ナッジ/バナー | インライン通知 | カスタムオーバーレイ + 自動非表示の .task |
| アラート/警告/確認 | ダイアログ | .alert または .confirmationDialog |
| カード/タイル/サーフェス | パネル | RoundedRectangle + マテリアル |
| チップ/タグ/ピル/バッジ | フィルタータグ | カスタム Capsule |
| FAB/フローティングアクションボタン | フローティングボタン | ZStack 内で絶対配置した Button |
| セグメントコントロール/ピルセレクター/タブストリップ | トグルグループ | Picker(.segmented) またはカスタムピル |
| スケルトン/シマー/プレースホルダー | 読み込みプレースホルダー | redacted(.placeholder) または不透明度のアニメーション |
| スピナー/ローダー/スロバー | アクティビティインジケーター | ProgressView |
| コンテキストメニュー/長押しメニュー | 長押しメニュー | .contextMenu |
| アクションシート/下部アクションメニュー | オプションシート | .confirmationDialog |
Step 1 — シンボルからエフェクトを検索する
プロンプトでシンボルが指定されている(または題材からシンボルが説明されている)ものの、アニメーションエフェクトが指定されていない場合は、この表を適用します。
| シンボル/題材 | 種類 | 自動エフェクト |
|---|---|---|
signature、pencil、pencil.line、scribble、lasso | ストローク | .drawOn の自動ループ |
checkmark、heart(アウトライン)、star(アウトライン)、wifi、antenna.radiowaves | ストローク | .drawOn の自動ループ |
heart.fill、star.fill、circle.fill | 塗りつぶし | .bounce value:(塗りつぶしシェイプでは drawOn は見えません) |
circle.dotted、rays、waveform | アニメーション構造 | .variableColor.iterative.reversing または .breathe |
arrow.clockwise、arrow.2.circlepath、gear | 回転 | .rotate(無期限、isActive) |
bell、bell.fill | 個別のアクション | .bounce value: |
ステップ 2 — ショーケースモードとインタラクションモード
- ユーザーの操作について説明がない(「タップして」「送信時に」「ユーザーが操作したとき」などがない)→ ショーケースモード:
.taskの自動ループで動かします。シンボルはタップしなくても自動でアニメーションします。 - ユーザーの操作について説明がある → その操作があった場合にのみアニメーションを実行します。自動ループは追加しないでください。
ステップ 3 — デフォルト契約を適用する
指定されていない UI 要素は、デフォルト契約(以下を参照)に従って解決します。
ステップ 4 — 推論ログを出力する
🔍 Inferred from prompt:
Vocab → "<designer word>" → <normalized term> (only if Step 0 fired)
Symbol → <name> (<stroke|fill>) → <effect>
Mode → showcase (auto-loop) | interaction (tap/submit)
Container → .sheet (Apple default) | inline | none
Controls → Clear · Done | none
デフォルト契約
記載がない場合は Apple HIG のデフォルトに従います。 プロンプトで言及されていない UI 要素には、Apple のシステムプリミティブを使用します。システム標準のものが適している場合、カスタムのモーダル、オーバーレイ、コンテナを独自に作成してはいけません。
| 要素 | Apple のデフォルト | 変更する場合 |
|---|---|---|
| コンテナ | .sheet(isPresented:) | プロンプトでカスタムモーダル/全画面表示が指定されている場合 |
| 閉じる | .toolbar(.confirmationAction) 内の Button("Done") | プロンプトでカスタムボタンが指定されている場合 |
| クリア/リセット | .toolbar(.cancellationAction) 内の Button("Clear") | 描画/署名のコンテキストに限る |
| シート内の背景 | システムのシート背景(カスタムの暗い塗りつぶしなし) | プロンプトで暗い背景/カスタム背景が明示的に指定されている場合 |
| ナビゲーション | NavigationStack | 複数ステップのフロー、またはタイトルが暗示されている場合 |
| 読み込み状態 | .progressView() またはシンボルの .variableColor | プロンプトで特定のローダー形状が指定されている場合 |
描画/署名のコンテキストルール — プロンプトに「signature」「draw」「sketch」「handwrite」が含まれる場合、自動的に次を適用します。
- コンテナ:
.sheet(isPresented:) - ツールバー:
Button("Clear")(.cancellationAction)+Button("Done")(.confirmationAction) - シート内にカスタムの暗い背景を設定しない(システムのシート背景を表示する)
アセットのスキャン(画像に依存するアニメーションを生成する前に実行)
アニメーションに画像、写真、カードが含まれる場合は、まず既存のアセットをスキャンします。
-
次のパスを順番に確認します。
legendary-Animo/Res/Assets.xcassets/Images/Assets.xcassets/Images/Assets.xcassets/
-
.imagesetフォルダー名を一覧表示し、.imagesetの接尾辞を取り除いてImage("name")の文字列を取得します。 -
見つかった場合 → そのまま使用します。次を出力します。
🖼️ Assets found: photo1, photo2 … (using in file) -
見つからない場合 → 色付きの
RoundedRectangle内でImage(systemName: "photo.fill")を使用します。末尾にアセットに関する注記を追加します。
アセット名を独自に作成してはいけません。ディスク上に存在すると確認できた名前のみを参照してください。
ハプティクス — まずプロジェクトを確認する
ハプティクスのコードを含める前に、プロジェクト内のどこかに HapticFeedback.swift が存在するか確認します。
- 見つかった場合 →
HapticFeedback.lightImpact()などを直接呼び出します。構造体を再宣言してはいけません。 - 見つからない場合 →
UIImpactFeedbackGenerator/UISelectionFeedbackGeneratorをインラインで直接呼び出し、先頭にimport UIKitを追加します。
ハプティクスを追加する場合と省略する場合
ハプティクスを追加する場合:
- ジェスチャーに、取り消せない操作を実行するしきい値がある場合(ドラッグして削除、スワイプして閉じる)→ しきい値を超えたときに
heavyImpact - トグルが意味のある2つの状態を切り替える場合 → 確定時に
mediumImpact - 破壊的な操作(削除、クリア、リセット)が確定した場合 →
heavyImpact - スクラブ操作やダイヤル操作で、離散的なデータポイント間を移動する場合 → ステップごとに
selectionChanged - グラスモーフやメタボールの融合/分離が完了した場合 →
mediumImpact
ハプティクスを省略する場合:
- 自動ループするだけでユーザー操作がない、純粋なシンボルの紹介
- ローディングインジケーターや背景のアンビエントアニメーション(星、漂うパーティクル)
- プロンプトにユーザー操作がまったく記述されていない場合
ジェスチャーの流れに合わせたハプティクスの段階:
lightImpact— ドラッグ開始/タッチダウンmediumImpact— しきい値を超えたとき/中間地点heavyImpact— 操作の確定/破壊的操作の確定selectionChanged— 離散値のスクラブ操作/ステップ
しきい値ハプティクスにはゲートと再アームを適用します。一度きりではなく、しきい値を超えるたびに発火させてください。 閉じる/確定のしきい値を超えた後に戻り、再びしきい値を超えるドラッグでは、最初の1回だけでなく、超えるたびに振動させる必要があります。リセットされない単純な「発火済み」ブール値では、ジェスチャー中に1回しか発火しません。進入時にゲートし、退出時に再アームします。
@State private var didCrossThreshold = false
// in onChanged:
if !didCrossThreshold, y > threshold {
didCrossThreshold = true
impact(.medium) // fires on every fresh crossing
} else if didCrossThreshold, y <= threshold {
didCrossThreshold = false // rearm — retreating past the line resets it
}
// reset both didCrossThreshold and any didStartDrag flag in onEnded
タッチダウン時の lightImpact にも同じゲートを適用してください(発生するのは1回だけなので、再アームは不要です)。ジェスチャー開始時のサブピクセルの揺れで早まって発火しないよう、小さなデッドゾーン(y > 2)を設けます。
TimelineView のハプティクス — クロックではなくフェーズの列挙型をトリガーにします。 .animation スケジュールの本文は毎秒約60回再実行されるため、生の時刻値(または clock >= tLiftEnd のようなテスト)を .sensoryFeedback(trigger:) に指定すると、境界でなく境界を超えた後のすべてのフレームで発火します。クロックを小さな Equatable フェーズ列挙型(.flat / .lifting / .spinning / .settling)に変換し、その値をトリガーにしてください。「フェーズが変わった」というイベントは、各境界で正確に1回発生します。フェーズはタップの Bool ではなく、クロックから導出してください。タップからわかるのはモーフィングが始まったことだけで、完了を示すのはクロックだけです。通常、到達を示す瞬間こそ、ハプティクスで伝える価値が最も高いタイミングです。発生させないフェーズについては、クロージャから nil を返してください。
SourceKit の HapticFeedback に関する誤検出 — 常に無視してください:
Carousels/ または Animations/ に作成されたファイルでは、SourceKit が Cannot find 'HapticFeedback' in scope と報告します。これは実際のエラーではありません。SourceKit は新しいファイルを単独で解析するため、ほかのモジュールメンバーを認識できないのです。HapticFeedback.swift はビルドフェーズの Sources に登録されており、実際のコンパイラーは正しく解決します。import UIKit を追加したり、構造体を再宣言したり、コードを変更したりしないでください。
スタイルルール
背景: 単独の場合は Color(white: 0.06).ignoresSafeArea() · プロジェクト内では Color("BgColor").ignoresSafeArea()
グラスサーフェス(iOS 26 より前): .ultraThinMaterial または .thickMaterial をシェイプでクリップします · トラックの背景には .white.opacity(0.06) と、.white.opacity(0.08) の 1pt のストロークを使用します
不透明度のレベル: ごく薄い 0.06–0.08 · 控えめ 0.12 · 非アクティブ 0.3 · セカンダリ 0.5 · アクティブ 0.8–0.9 · 完全 1.0
色: 白と不透明度を主体にします · シアン Color(red: 0.45, green: 0.65, blue: 1.0) · 緑 Color(red: 0.55, green: 0.95, blue: 0.75) · ゴールド/評価 Color(red: 1.0, green: 0.84, blue: 0.35) · 暗い背景では .blue/.green/.red を使いません
グロスオーバーレイ(写真カード): ポスター/写真カードに斜めの光沢を加えると、画像に手を加えずに高級感のあるガラスのような見た目になります。LinearGradient(colors: [.white.opacity(0.18), .clear], startPoint: .topLeading, endPoint: .center) をカードのシェイプでクリップし、.blendMode(.softLight) を適用してオーバーレイします。.softLight は(.screen ではなく)、下の写真が白っぽくなりすぎない程度にハイライトを控えめに保ちます。
画像の色を反映したアンビエントシャドウ(グロー): ポスター/写真カードの下に通常の黒い .shadow を付けると、光ではなく汎用的な重みを感じさせます。特定のアートワークになじむシャドウにするには、画像そのものをカードの背後に複製し、強くぼかして暗くします。
.background {
Image(item.imageName)
.resizable().scaledToFill()
.frame(width: cardWidth, height: cardHeight)
.clipShape(cardShape)
.blur(radius: 34)
.opacity(0.65)
.offset(y: 22)
.scaleEffect(0.96) // slightly smaller so the blurred edge doesn't peek past the card
}
結果として、無彩色のドロップシャドウではなく、色づいた柔らかなハローが生まれます。画像の複製描画が必要なため、すべての行ではなく、ヒーローカード(デッキの先頭、選択中のアイテム)に限定して使います。ポスター、アルバムアート、商品写真など、画像を背景にしたあらゆるカードで使えます。
グラデーション: 2 色のみ · ブロブの塗り [.white, Color(white: 0.88)] · 進捗 [cyan, green]
ライトテーマ
上記のスタイルルールはダークテーマを優先しています。ライト UI(ダッシュボード、カード、白いシート)では、奥行きはシャドウではなく、色調の差で生まれます。高級感のあるライトデザインでは、重なったサーフェス間にドロップシャドウを使いません。
色調の階層 — 明るさでサーフェスを分けます:
| 役割 | 値 |
|---|---|
| 画面の背景 | Color(white: 0.92)(または 0.93 → 0.87 のグラデーション) |
| カード/行のサーフェス | Color.white |
| インセット部分(アイコンの円、フィールド) | Color(white: 0.95) |
| トラック/区切り線 | Color(white: 0.88) |
| インク(メインテキスト) | Color(white: 0.10) |
| ミュート(セカンダリテキスト) | Color(white: 0.55) |
ルール:
- 重なったライトサーフェスの間に
.shadowを使いません。0.92の背景上にある白い行は、それだけで浮き上がって見えます。シャドウは単独で浮かせるメイン要素(例:暗い CTA)に限定し、すべての行には使いません。 - 白の上で十分なコントラストを確保できるよう、アクセントカラーは暗くします。たとえばシアンは
Color(red: 0.30, green: 0.52, blue: 0.95)のようにします。ダーク背景向けのシアンとは逆です。 - 暗いカプセル型 CTA(
Color(white: 0.10)の塗り、白いラベル)は、ライトテーマにおけるグラスボタンに相当します。
アダプティブ(ライトとダークの両方に対応)
画面をシステムの外観に従わせる必要がある場合は、.white/.black をハードコードしたり、.preferredColorScheme を強制したりしないでください。すべてを @Environment(\.colorScheme) とセマンティックカラーに基づいて設定します。
@Environment(\.colorScheme) private var scheme
private var isDark: Bool { scheme == .dark }
- テキスト →
.primary/.secondary(自動で反転)。.white.opacity(x)はすべて.primary.opacity(x)/.secondaryに置き換えます。 - ガラスカード — 正式な方法(iOS 26): 本物の Liquid Glass API を使い、素材でガラスを模倣するのではなく、暗く色付けします。
.glassEffect(.regular.tint((isDark ? .black : .white).opacity(0.28)), in: shape)を使うと、本物の鏡面反射と屈折を保ちながら、暗色(または明色)の通知サーフェスとして表示できます。if #available(iOS 26.0, *)で条件分岐し、iOS 18〜25 では.ultraThinMaterialと、カラースキームに応じた色合いのオーバーレイ(isDark ? .black.opacity(0.45) : .white.opacity(0.35))にフォールバックします。(色付けしていないglassEffectは明るく見えます。暗いサーフェスには必ず色を付けてください。iOS 26 では素材を使わないでください。) - グラデーション/ストローク/シャドウ →
isDarkに応じて切り替えます(例:ダークではネイビーの背景、ライトでは青白い背景、シャドウはダークで0.35、ライトで0.12)。 - アクセントグラデーション → アクセントがどちらの背景でも見えるように、ダークでは明るく、ライトでは深みのある色にします。
- 保存された
letは環境を参照できません。カラースキームに依存する値は計算プロパティのvarにします。
iOS 26 Liquid Glass
プロンプトに「Liquid Glass」とある場合は、必ず本物のエフェクトを使います。 譲れない条件は 2 つです。
- 本物の API を使い、似せたものは使わないでください。 iOS 26 では、本物の
.glassEffect(.regular)(または押下可能なコントロールには.regular.interactive())を使います。塗りつぶしをほぼ不透明にして主なサーフェスとして使うなど、見た目だけのガラスを作らないでください。Capsule().fill(.ultraThinMaterial)や濃い.glassEffect(.regular.tint(.white.opacity(0.5)))は、ガラスではなく平坦なチップのように見えます。.ultraThinMaterialはif #available(iOS 26.0, *)の後ろに置く、iOS 26 未満でのみ使うフォールバックです。 - 屈折や鏡面反射が実際に見えるよう、色鮮やかで情報量の多いコンテンツをガラスの背後に配置します。 画像グリッド、写真の壁、鮮やかなグラデーションなどを使います。単色や淡色の背景では Liquid Glass はほとんど見えず、単なる素材のように見えます。「ガラス」のビルドが不自然になる最大の理由はこれです。(単体で効果を見せる場合は、画像タイルのグリッドを背景にするのが最も確実です。)
.regular と .clear の違い: .glassEffect(.regular, in:) は標準的なすりガラスの強さです。特に色鮮やかで情報量が多く、コントラストの高いコンテンツ(ポスター、写真の背景など)の上に配置するバーやコントロールで、.regular のすりガラス効果によってアートワークが必要以上に平坦化される場合は、.glassEffect(.clear, in:) を使います。.clear は背景の色やディテールをより多く透過させます。
動いているビューに重ねたチップに、動作中の .glassEffect() を適用しないでください。(スワイプ中のカード上のメタ情報ピル、スクラバーのつまみ上のバッジなど。)Glass は背景を継続的に再サンプリングします。速く動く要素では、この再サンプリングによってガラスが安定して見えず、目に見えて膨らんだり脈打ったりします。これは微妙な不具合ではなく、明らかに壊れた見た目になります。動いているコンテンツに付随する要素には、代わりに通常の静的な半透明塗りつぶし(同じ形状の Color.black.opacity(0.45))を使い、本物のガラスは静止している要素か、ゆっくり動く要素にのみ使います。
iOS 26 以降では .glassEffect() を使い、それ以前の OS では .ultraThinMaterial にフォールバックします。両方の処理を同じ呼び出し箇所で使えるよう、必ず @ViewBuilder ヘルパーでラップします。
@ViewBuilder
private func glassShape(radius: CGFloat) -> some View {
if #available(iOS 26.0, *) {
RoundedRectangle(cornerRadius: radius, style: .continuous)
.fill(.clear)
.glassEffect(in: RoundedRectangle(cornerRadius: radius, style: .continuous))
} else {
RoundedRectangle(cornerRadius: radius, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: radius, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)
)
}
}
ピル、カプセル、円など、どのような形状にも適用できます。iOS 26 が対象の場合、新しいコンポーネントで .ultraThinMaterial をハードコードしないでください。
色付きガラス(色付きの状態)
破壊的な操作やアクセントボタンには、組み込みの tint API を使います。ガラスの上に色付きの形状を重ねないでください。
.glassEffect(.regular.tint(dangerColor), in: Circle())
危険を示す赤:Color(red: 0.87, green: 0.32, blue: 0.42) · アクセントの青:Color(red: 0.45, green: 0.65, blue: 1.0)。
GlassEffectContainer — 変形するクラスター
複数のガラス要素を、iOS 26 のメタボール効果で融合・分離させたい場合(たとえば、カプセルが独立した円に変形する場合)は、それらを GlassEffectContainer で囲みます。
@Namespace private var glassNS
GlassEffectContainer(spacing: 18) {
HStack(spacing: 28) { // see spacing trap below
capsule
.glassEffect(in: Capsule())
.glassEffectID("capsule", in: glassNS)
if isExpanded {
circle
.glassEffect(.regular.tint(.red), in: Circle())
.glassEffectID("circle", in: glassNS)
}
}
}
融合しきい値の間隔に関する落とし穴 — GlassEffectContainer(spacing:) は融合のしきい値であり、視覚的なパディングではありません。この距離以内にあるガラス要素は、静止時も含めて常に視覚的に一体化します。レイアウトの間隔は、必ず containerSpacing より大きくしてください。
| containerSpacing | HStack spacing | 結果 |
|---|---|---|
| 18 | 10 | ❌ 常に融合 — 静止時もエッジが歪む |
| 18 | 28 | ✅ 静止時は形状を保ち、遷移時のみメタボール状に変形 |
ルール:安全な余裕を確保するには、layoutSpacing > containerSpacing + 6pt とします。
エッジの歪みに関する落とし穴
.scaleEffect(x:y:anchor:) を、オフセットのあるアンカー(.leading / .trailing)と組み合わせて、個々のガラス要素に適用しないでください。アンカーのエッジでサブピクセルレンダリングが発生し、scale = 1.0 でも目に見えるエッジの歪みが生じます。代わりに、コンテナー全体にラバーストレッチを適用します。
GlassEffectContainer(spacing: 18) { ... }
.scaleEffect(x: 1.0 + stretch * 0.05, y: 1.0 - stretch * 0.025, anchor: .center)
また、.glassEffect(in: Capsule()) に .clipShape(Capsule()) を重ねて追加しないでください。glassEffect がすでにクリッピングを行います。
3フェーズのラバーバンド切り替え
タップ時に分離または融合するガラス要素には、3つのアニメーションを重ねて、本物のゴムのような感触にします。
private func toggle() {
HapticFeedback.mediumImpact()
// Phase 1 — pre-stretch (rubber tensions)
withAnimation(.spring(response: 0.28, dampingFraction: 0.55)) {
stretchAmount = 1.0
}
// Phase 2 — morph fires at peak tension
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
withAnimation(.spring(response: 0.55, dampingFraction: 0.72)) {
isExpanded.toggle()
}
}
// Phase 3 — release snap-back with overshoot
DispatchQueue.main.asyncAfter(deadline: .now() + 0.28) {
withAnimation(.spring(response: 0.45, dampingFraction: 0.55)) {
stretchAmount = 0
}
}
}
ガラスのデモに使う明るい背景
iOS 26 のガラス効果を見せるには、明るいグラデーションの背景を使用します。暗い背景では屈折が見えにくくなります。単体のデモ:
LinearGradient(colors: [Color(white: 0.97), Color(white: 0.88)],
startPoint: .topLeading, endPoint: .bottomTrailing)
.ignoresSafeArea()
SF Symbols のアニメーション(iOS 17–26)
効果の自動選択: 上記の「Intent Inference § Symbol → Effect lookup」を参照してください。表では、シンボル名に対応する効果が自動的に割り当てられます。既定の効果を上書きする場合を除き、プロンプトで
.drawOnや.breatheなどを指定する必要はありません。
.drawOn — iOS 26 のみ(SF Symbols 7)
isActive は反転しています。 これが最もよくある間違いです。
| isActive | 表示状態 | 再生される動作 |
|---|---|---|
true | 非表示(描画前) | 初回のレンダリングでは何も再生されません |
false | 表示 | drawOn によってストロークが描画されます |
false → true | 非表示 | 逆方向(描画オフ)が自動的に再生されます |
正しいパターン — 状態変数名は常に「非表示」を表す名前にします。
@State private var isDrawSymbolHidden = true // starts hidden
Image(systemName: "checkmark")
.symbolEffect(.drawOn, options: .speed(0.5), isActive: isDrawSymbolHidden)
.task {
try? await Task.sleep(for: .seconds(0.5))
while !Task.isCancelled {
isDrawSymbolHidden.toggle() // false → draws on; true → draws off
try? await Task.sleep(for: .seconds(2.0))
}
}
.drawOn ではシンボルの選択が重要です。
heart.fill、star.fill— 塗りつぶし形状で、ストロークパスがありません。drawOn は非表示から一瞬で塗りつぶされた状態に切り替わるように見えます。ペンでなぞる効果を見せる用途には適していません。checkmark、heart(アウトライン)、signature、wifi、star(アウトライン) — ストロークベースで、軌跡がはっきり表示されます。
同じ画像に .drawOn と .drawOff を重ねて適用しないでください。競合してシンボルが非表示のままになります。
.drawOn は 遷移時 にのみ再生され、表示時には再生されません。 isActive: は、ビューが表示されている間にバインドされた値が 変化した ときにパスを描画します。ビューがすでに表示状態で現れたときには再生されません。これには次の 2 つの影響があります。
- 「…を描画するデモ」/「…をアニメーション化する」→ デフォルトで自動再生します。 プロンプトがシンボル自体の描画やアニメーションを求めている場合(ショーケース、ヒント、ローディング状態など)は、上記の
.taskループで動かして自動的に軌跡を描画させます。プロンプトでユーザー操作が明示されている場合(「タップして署名」「送信時」など)に限り、タップや状態変更をトリガーにします。ユーザーが自動再生するデモを求めているのにインタラクションをトリガーとして使うのは、drawOn のビルドが「動かない」ように見える最もよくある原因です。API は正しく使われていますが、再生されることがありません。 - タップで動かす場合、最初のタップまではパッド/領域が空です。 これは正しい動作で、バグではありません。ただし、プロンプトの意図に合っているか確認してください。
検証時の落とし穴 — 状態を遷移先にあらかじめ設定して、遷移を検証しないでください。 初期化時に @State private var isDrawSymbolHidden = false を設定すると、シンボルは すでに完全に描画された状態で、遷移なし にレンダリングされます。そのため、スクリーンショットには完成したシンボルが表示され、「動作している」と誤って判断してしまいます。これはレイアウトの検証であり、動きの検証ではありません。軌跡を実際に検証するには、自動ループを動かして起動するか(またはタップをスクリプトで実行して)、描画途中のフレーム(ストロークの一部だけが描かれた状態)をキャプチャしてください。スクリーンショットに完成したシンボルが写っていても、描画がアニメーションしたかどうかの証明にはなりません。これは drawOn に限らず、すべての isActive:/value: を使う symbolEffect に当てはまります。
その他の Symbol Effect(iOS 17 以降)
// Discrete — fires once per value change (user actions, additions)
.symbolEffect(.bounce, value: count)
.symbolEffect(.wiggle, value: errorTrigger)
// Indefinite — runs while active (states: recording, loading, scanning)
.symbolEffect(.breathe, isActive: isRecording)
.symbolEffect(.variableColor.iterative.reversing, isActive: isLoading)
.symbolEffect(.rotate, isActive: isSyncing)
// Content transition — swapping symbols on state toggle (always needs withAnimation)
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.contentTransition(.symbolEffect(.replace))
// trigger with: withAnimation(.smooth) { isPlaying.toggle() }
ループの駆動には Timer ではなく .task を使用します。
.task {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(interval))
count += 1 // or toggle state
}
}
ビューが消えると .task は自動的にキャンセルされます。Timer はリークします。
スプリングのプリセット
| 触感 | 値 |
|---|---|
| パッと跳ねる/バウンド | .spring(response: 0.35, dampingFraction: 0.5) |
| UI のポップ | .spring(response: 0.35, dampingFraction: 0.6) |
| 標準的な収束 | .spring(response: 0.4, dampingFraction: 0.65) |
| 物理的な収束 | .spring(response: 0.45, dampingFraction: 0.7) |
| ゆっくりと変形 | .spring(response: 0.6, dampingFraction: 0.8) |
| 高精度で硬め | .interpolatingSpring(stiffness: 220, damping: 22) |
| ダイヤル/スクラブ | .interactiveSpring(response: 0.3, dampingFraction: 0.7) |
単独の .spring() は使用せず、必ず response と dampingFraction を明示してください。ユーザーが値を指定した場合は、そのまま使用してください。触感を表す言葉の対応:”伸びるような” → 0.4/0.5、”キビキビした” → 0.35/0.6、”溶けるような” → 0.6/0.8。
アーキタイプ → 物理パラメーターのデフォルト(プロンプトに触感の指定がない場合に使用します)。
| アーキタイプ | 物理パラメーターのデフォルト |
|---|---|
| シンボルのショーケース | .easeInOut(duration: 0.4) — シンボルエフェクトがタイミングを制御します |
| 液体トグル | UI のポップ 0.35/0.6 |
| ジェスチャーカード | 物理的な収束 0.45/0.7 |
| シートのインタラクション | 標準的な収束 0.4/0.65 |
| データダッシュボード | ダイヤル/スクラブ .interactiveSpring(response: 0.3, dampingFraction: 0.7) |
| ガラスの変形 | 3 フェーズのラバーバンド(iOS 26 のセクションを参照) |
| パーティクル/物理シミュレーション | SwiftUI のスプリングは使用せず、カスタム物理ループを使用します |
| ローディングインジケーター | .linear(duration: 0.8) — ループがぎくしゃくして見えるため、スプリングは使用しません |
押下フィードバック — PressableScale
タップ可能な行やボタンは、指で押したときに縮み、本物の押し心地を感じられるようにします。Button/ButtonStyle ではなく、ジェスチャー駆動型のモディファイアを使います。ButtonStyle.isPressed は押し下げた瞬間に触覚フィードバックを発生させられませんが、DragGesture(minimumDistance: 0) なら可能です。
private struct PressableScale: ViewModifier {
var pressedScale: CGFloat = 0.96
let action: () -> Void
@State private var isPressed = false
func body(content: Content) -> some View {
content
.scaleEffect(isPressed ? pressedScale : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: isPressed)
.contentShape(Rectangle())
.simultaneousGesture( // simultaneous → won't block parent scroll
DragGesture(minimumDistance: 0)
.onChanged { _ in
if !isPressed { // haptic on the DOWN edge only
isPressed = true
HapticFeedback.lightImpact()
}
}
.onEnded { v in
isPressed = false
// lift-inside-to-fire: dragging off cancels, like a real UIButton
if abs(v.translation.width) < 20, abs(v.translation.height) < 20 { action() }
}
)
}
}
extension View {
func pressable(scale: CGFloat = 0.96, action: @escaping () -> Void = {}) -> some View {
modifier(PressableScale(pressedScale: scale, action: action))
}
}
一見わかりにくいルールが組み込まれています。
.gestureではなく.simultaneousGestureを使うことで、ScrollView/List内の行でもスクロールできます。- 押し下げた瞬間にのみ触覚フィードバックを発生させます(
if !isPressed)。onChangedが呼ばれるたびに発生させてはいけません。 - 指が要素内で離れた場合にのみ実行します。
actionを実行する前に、指が約20pt以内の位置で離れたことを確認します。指をずらして離れた場合はキャンセルします。 pressedScaleは、丸いアイコンボタンでは約0.90、横長の行では約0.965にします。
表示時のアニメーション
上質な画面は、自ら組み上がるように見えます。@State フラグを1つ使い、表示時に子要素を順番に登場させます。
@State private var appeared = false
ForEach(Array(items.enumerated()), id: \.element.id) { i, item in
rowView(item)
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 16)
.scaleEffect(appeared ? 1 : 0.97, anchor: .top)
.animation(.spring(response: 0.5, dampingFraction: 0.82)
.delay(0.10 + Double(i) * 0.07), value: appeared)
}
.onAppear { appeared = true } // trigger once — NEVER init appeared = true
opacity + offset(y:) + scaleEffect(anchor:.top)を組み合わせると、柔らかく浮き上がって落ち着く動きになります。インデックスごとに0.07s遅らせると、連続して登場するように見えます。.sheet内では、表示のたびに@Stateがリセットされます。そのためシートを開くたびにアニメーションが再生され、追加のコードなしで画面が「自ら組み上がる」ように見せられます。- 検証時の注意点(
.drawOnと同じです)。フラグを遷移後の値で初期化してはいけません。遷移が再生されなくなります。
カスタムトランジション — ゴムのように弾む登場
.transition(.scale) は、スケールを1.0まで補間するだけです。そのため、要素を大きめに登場させてから元のサイズに落ち着かせることはできません。「幅広く登場し、ゴムのように弾んで本来のサイズに戻る」ポップ表示には、カスタムのモディファイアトランジションを作成し、減衰の小さい(弾む)スプリングで動かしてオーバーシュートさせます。
private struct WidthPopModifier: ViewModifier {
let active: Bool
func body(content: Content) -> some View {
content
.scaleEffect(x: active ? 1.16 : 1.0, y: active ? 1.07 : 1.0, anchor: .top) // anisotropic → wider
.offset(y: active ? -34 : 0)
.opacity(active ? 0 : 1)
}
}
private extension AnyTransition {
static var widthPop: AnyTransition {
.modifier(active: WidthPopModifier(active: true), identity: WidthPopModifier(active: false))
}
}
// usage — the surrounding withAnimation's spring governs the transition:
withAnimation(.spring(response: 0.5, dampingFraction: 0.55)) { items.insert(item, at: 0) }
// on the view: .transition(.asymmetric(insertion: .widthPop, removal: .move(edge: .top).combined(with: .opacity)))
- 異方性スケール(
x > y)を使うと、均等なズームではなく、幅が伸びるように見えます。 - ゴムのような弾みはトランジションではなく、スプリングの減衰によって生まれます。
dampingFraction ≈ 0.55ではオーバーシュートし、≈ 0.8では弾まずに落ち着きます。値が小さいほど弾みが強くなります。 - 組み込みトランジションでは表現できない登場アニメーションには、
.modifier(active:identity:)を使うのが汎用的な方法です。
データダッシュボードのパターン
- 比率に応じてアニメーションするバー — カプセル形のトラックと、表示時に幅0から伸び、データが変わるたびにスプリングで再びアニメーションする塗りつぶし部分を使います。
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(track).frame(height: 6)
Capsule().fill(ink.opacity(0.85))
.frame(width: geo.size.width * (appeared ? share : 0), height: 6)
.animation(.spring(response: 0.6, dampingFraction: 0.85), value: appeared)
.animation(.spring(response: 0.5, dampingFraction: 0.8), value: selection)
}
}.frame(height: 6)
.numericTextで数値をカウントアップ — 通貨だけでなく、さまざまな数値に使えます。セグメント切り替えで変化する主要指標では、.contentTransition(.numericText(value: Double(total)))と.animation(..., value: period)を組み合わせることで、数字をロールさせます。セグメントの切り替えにはselectionChangedの触覚フィードバックとスライドするインジケーターを組み合わせます(「タブバーのパターン」を参照)。
状態とコードのルール
- デフォルトでは、すべてのジェスチャー追跡に純粋な
@Stateを使います。値を自動リセットする必要がある場合に限り、@GestureStateを使います。 - 定数は、構造体レベルで
private letとしてキャメルケースで定義します(2~5個)。SCREAMING_SNAKE_CASEは使いません。 - 液体メタボール:
Canvasでブロブを描画し、.addFilter(.alphaThreshold)と.addFilter(.blur)を使います。「Canvasメタボール」セクションを参照してください。(旧方式:黒い背景に白い円を置き、.blur、.contrast(50)、.blendMode(.screen)を適用します。) - 複数段階のシーケンス:遅延が重なるように、
DispatchQueue.main.asyncAfterを連続して使います。
アイテムごとの重い計算は、ViewBuilderクロージャーではなく通常の関数に記述します。 SwiftUIはViewBuilderの本体を1つの大きな式として型チェックします。そのため、ForEachクロージャー内で三角関数やpowを使ったletを数十個連鎖させると、型チェックが指数関数的に遅くなります。フルアプリのビルドは成功する一方で、Xcode Previewsのビルドサンクはタイムアウトするほどです。アイテムごとのジオメトリは、簡潔な構造体を返すprivate func layoutFor(_ item:) -> ItemLayoutで計算します。通常の関数は文ごとに型チェックされます。フレームごとに変わらない値(セルサイズ、中心、半径)は、アイテムごとに再計算するのではなく、GeometryReaderの各パスで一度だけ生成するコンテキスト構造体にまとめます。
累積するrotation3DEffectの反転補正。 (見出し) Y軸のrotation3DEffectは、180°の奇数倍ごとに、テキスト、画像、オーバーレイなど、内部のすべてのコンテンツを鏡像反転します。カードを複数回めくるたびに回転を累積させる場合(例:「次のカード」ごとに180°)、カードが切り替わるたびにコンテンツが交互に逆向きに表示されます。対処法:let halfTurns = Int(round(angle / 180))を計算し、rotation3DEffectより前に内部コンテンツへ.scaleEffect(x: halfTurns % 2 != 0 ? -1 : 1)を適用します。3D回転ですでにビューが鏡像反転されているため、反転補正はユーザーには見えません。両者が相殺され、コンテンツは常に正しい向きで表示されます。
Canvasのパーティクルを決定論的にするSeededRNG。 Canvasの描画クロージャ(または任意の@ViewBuilder)内のCGFloat.random()は、フレームごとに異なる値を生成するため、パーティクルがちらついたり、突然移動したりします。代わりに、シンプルなシード付きXorshift RNG(struct SeededRNG { var state: UInt64; mutating func unit() -> CGFloat })を使います。シードをフレーム間で一定に保つことで位置が安定します。パターンを再生成するには、イベント(新しい稲妻の発生、新しい衝突の衝撃など)でシードを変更します。Canvas以外のForEach本体では、通常の関数を使って静的なパーティクル配列を事前計算してください。@ViewBuilder内で.random()を呼び出してはいけません。
安定したForEachの識別子。 ForEach(items.indices, id: \.self)よりもForEach(Array(items.enumerated()), id: \.element.id)を優先してください。インデックスベースの識別では、配列の順序が変わるとSwiftUIがビューを破棄して再構築するため、実行中のアニメーションが中断され、ビューの識別情報も無駄になります。
ZStackのフレームに関する落とし穴。常に次の2つのルールを併用してください。
ZStackに固定の.frame(height:)があり、LazyVGrid、List、または高さのあるコンポーネントが含まれている場合:
- 高さのある子ビューは必要なときだけ条件付きで描画します(
if isExpanded || childVisible) - ZStackに
.clipped()を追加します
.opacity(0)だけでは、はみ出しを防げません。コンポーネントはフレームの外側にも描画され続けるため、heightの定数を変更しても見た目に反映されません。
フローティングバーのレイアウト: ピル型の要素と独立したアクションボタンなど、複数のコンポーネントで構成されるバーは、1つの巨大なZStackではなく、独立したビューを並べたHStackで構築します。各コンポーネントには、それぞれ独自のガラス背景とシャドウを設定します。
シートのラップ。外側の起動用ビューと、内側のプライベートなコンテンツ:
デモでネイティブの.sheetが必要な場合は、同じファイル内に2つの構造体を用意します。ファイル内で定義されたprivateなモデル型は両方の構造体から参照できるため、アクセスレベルを変更する必要はありません。
struct MyDemoView: View { // public — registered in ContentView
@State private var showSheet = false
var body: some View {
ZStack { Color("BgColor").ignoresSafeArea(); triggerButton }
.sheet(isPresented: $showSheet) {
MySheetContent()
.presentationDetents([.height(540)])
.presentationCornerRadius(28)
.presentationDragIndicator(.visible)
}
}
}
private struct MySheetContent: View { // private — only used as sheet body
@Environment(\.dismiss) private var dismiss
// sheet body here — X button calls dismiss()
}
数値テキストのトランジション。通貨記号の接頭辞を分離:
"$173.11"のような通貨文字列に.contentTransition(.numericText(value:))を適用すると、数字が切り替わる際に$がちらつくことがあります。接頭辞と数値を別々のTextビューに分け、数値部分だけにトランジションを適用します。
HStack(alignment: .firstTextBaseline, spacing: 1) {
Text("$") // static — no transition
.font(.system(size: 32, weight: .bold))
Text(String(format: "%.2f", amount))
.font(.system(size: 44, weight: .bold))
.contentTransition(.numericText(value: amount))
.animation(.spring(response: 0.4, dampingFraction: 0.65), value: triggerValue)
}
カードカルーセルやダイヤルの目盛りのように、離散的なステップでトランジションを駆動する場合は、mediumImpactではなくselectionChangedの触覚フィードバックを使います。数値が切り替わる感覚に合っています。
ファイルの構成(必須のMARK順序):
// MARK: - Model
// MARK: - Main View (tokens → @State → body → subviews → gesture → actions)
// MARK: - Supporting Shapes
// MARK: - Preview
アーキタイプカタログ
アーキタイプによって、物理挙動、触覚フィードバック、コンテナーのデフォルトが決まります。プロンプトに最も近いものを選びます。判断が難しい場合は、よりシンプルなものを選んでください。
| アーキタイプ | プロンプトの手がかり | コンテナー | 物理挙動 | 触覚フィードバック |
|---|---|---|---|---|
| シンボルの表示 | 「animate」「draw」「show」+シンボル名 | なし(インライン) | .easeInOut | なし |
| リキッドトグル | 「toggle」「switch」「flood」「metaball」 | インライン | UI ポップ 0.35/0.6 | 切り替え時に mediumImpact |
| ジェスチャーカード | 「drag」「pull」「rip」「swipe」「dismiss」 | インライン ZStack | 物理挙動に基づく静止 0.45/0.7 | 開始時に light → 確定時に heavy |
| シート操作 | 「sheet」「sign」「draw」「pick」「form」 | .sheet | 標準的な静止 0.4/0.65 | コントロール操作時に selectionChanged |
| データダッシュボード | 「chart」「graph」「metric」「scrub」「data」 | 全画面 | ダイヤル/スクラブ .interactiveSpring | ステップごとに selectionChanged |
| グラスモーフ | 「glass」「fuse」「morph」「capsule」 | インライン | 3 フェーズのラバーバンド | モーフ時に mediumImpact |
| パーティクル/物理シミュレーション | 「gravity」「fluid」「rope」「cloth」「sand」 | 全画面 | カスタム物理ループ | タッチ時に lightImpact |
| Metal シェーダー | 「liquid chrome」「molten metal」「holographic」「plasma」「shader」 | 全画面 | TimelineView のクロック(spring は使用しない) | タッチ時に lightImpact |
| ローディングインジケーター | 「loading」「spinner」「progress」「scanning」 | インライン | .linear(duration:) | なし |
| 3D オブジェクトの表示 | 「3D」「SceneKit」「spin the cover」「album」「boxed product」 | 埋め込みまたは全画面 | SceneKit リグ、駆動するプロパティには SCNTransaction.disableActions を使用(spring は使用しない) | 静止するたびに selectionChanged /受動的な場合はなし |
| コーチマークツアー | 「tour」「coach mark」「spotlight」「walkthrough」「onboarding tooltip」 | 透明な fullScreenCover、一度だけ表示 | カットアウトの移動 .spring(0.5/0.86)、吹き出しのクロスフェード .opacity、フェードアウト .easeInOut(0.25) | なし(最大でも「次へ」で selectionChanged) |
| カードパックの開封 | 「pack」「tear open」「card flip」「reveal」「unbox」「foil」 | 全画面 | .easeInOut による開封+.spring(0.6/0.7) によるフリップ+Canvas エフェクト用の TimelineView クロック | 開封時に heavy、フリップ時に medium、レアカードでは success |
確定したアーキタイプを 🎯 Archetype: の行に出力してください。ユーザーのプロンプトがこの表のデフォルトを上書きする場合は、その値を使用し、括弧内に上書きであることを記してください。
タブバーのパターン
スライド式選択インジケーター — 手動で位置を計算するのではなく、GeometryReader と offset(x:) を使用します。
GeometryReader { geo in
let tabW = geo.size.width / CGFloat(tabCount)
RoundedRectangle(cornerRadius: indicatorRadius, style: .continuous)
.fill(Color.white.opacity(0.14))
.padding(5)
.frame(width: tabW)
.offset(x: tabW * CGFloat(selectedIndex))
.animation(.spring(response: 0.35, dampingFraction: 0.65), value: selectedIndex)
}
アクティブインジケーターの仕様(Apple Music/iOS 26 HIG に準拠):
- 塗りつぶし:
Color.white.opacity(0.14)— しっかりとした存在感があり、ガラスの背景と明確に区別できます - パディング:全辺に
5ptのインセット(タブの高さ全体から各端を5ptずつ差し引いた範囲を塗りつぶします) - 角丸:
outerRadius - 5(外側のピルより柔らかく、独立した値として約 16~18pt) - 幅:正確に
pillWidth / tabCount— 推測に頼らず、スロットをくっきり分割します
タブセルのレイアウト(HIG に準拠したアイコン+ラベル):
- アイコン:22pt、アクティブ時は
.semibold、非アクティブ時は.regular - ラベル:10~11pt、アクティブ時は
.semibold、非アクティブ時は.regular - 選択時はどちらも
activeColorで着色し、非アクティブ時はwhite.opacity(0.5)にします .frame(maxWidth: .infinity).frame(height: barHeight)の内側にVStack(spacing: 4)を配置します
SF Symbol の置き換えトランジション — 状態を切り替えてアイコンを変更するボタン(例:その他 → 閉じる、再生 → 一時停止、追加 → 完了)には次を使用します。
Image(systemName: isExpanded ? "xmark" : "ellipsis")
.contentTransition(.symbolEffect(.replace.downUp))
.animation(.spring(response: 0.35, dampingFraction: 0.6), value: isExpanded)
.replace.downUp— 古いアイコンが縮小し、新しいアイコンが拡大します。開閉の切り替えに使用します。.replace.upUp— 両方のアイコンが拡大します。順次進む操作に使用します。- ボタンのアクションでは状態を切り替えます:
isExpanded ? collapse() : expand()— 別の閉じるボタンを表示するために、元のボタンを隠してはいけません。 - iOS 17 以降で利用できます。legendary-Animo ではフォールバックは不要です(最低対応バージョンが iOS 16 以降の場合、iOS 16 を対象にするときだけ
if #available(iOS 17, *)で条件分岐します)。
標準的な高さ: barHeight = 49pt(アイコンのみ)・barHeight = 68pt(アイコン+ラベル)
拡張するタブバー(コンテンツに応じた幅、インジケーター計算なし) — 固定幅のセルが不要な場合(画面全幅のバーではなく、コンテンツ上に浮かぶガラスのカプセルなど)は、GeometryReader とインジケーターを省略します。選択中のタブだけにラベルを表示することで、Button が自然に広がり、それに合わせてコンテナーが再配置されます。
HStack(spacing: 10) {
ForEach(NavTab.allCases, id: \.self) { tab in
let isSelected = tab == selectedTab
Button { select(tab) } label: {
HStack(spacing: 6) {
Image(systemName: tab.symbol).symbolEffect(.bounce, value: isSelected)
if isSelected { Text(tab.title) } // label only exists when selected — no width reserved
}
.foregroundStyle(.white.opacity(isSelected ? 1 : 0.6))
.padding(.horizontal, 14).padding(.vertical, 9)
}
.buttonStyle(.plain)
}
}
.padding(6)
.glassEffect(.clear, in: .capsule) // capsule autosizes to content
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: selectedTab)
画面幅いっぱいに広げるのではなく、豊かなコンテンツ上にバーを浮かべる場合は、スライド式インジケーターの方法よりもシンプルです。固定スロットがないため、スロットの計算もありません。トレードオフとして、ラベルの表示・非表示に応じてタブの位置が変わります。そのため、安定したグリッドというより「1つのタブが自分の存在を知らせる、コンパクトなアイコンバー」のように見えます。タブを固定位置に保つ必要がある場合は、代わりにスライド式インジケーターを使用してください。
カルーセルとページング
速度を考慮したページング — 生の translation ではなく、predictedEndTranslation をしきい値にします。 ゆっくり短くドラッグした場合はページを切り替えず、素早くフリックした場合は、指の移動距離がわずかでも切り替えます。予測される終了位置は速度を考慮します。
DragGesture(minimumDistance: 12)
.onChanged { v in dragOffset = v.translation.width * 0.5 } // 0.5 = drag resistance
.onEnded { v in
let threshold: CGFloat = 52
withAnimation(.spring(response: 0.42, dampingFraction: 0.72)) {
if v.predictedEndTranslation.width < -threshold, index < count - 1 {
index += 1; HapticFeedback.selectionChanged()
} else if v.predictedEndTranslation.width > threshold, index > 0 {
index -= 1; HapticFeedback.selectionChanged()
}
dragOffset = 0
}
}
扇形/カードスタックのレイアウト — 選択中のインデックスからの距離に基づいて各カードを配置し、隣接するカードを縮小して、近さに応じて重なり順を設定します。
let diff = CGFloat(index - selectedIndex)
card
.scaleEffect(index == selectedIndex ? 1.0 : 0.86)
.offset(x: diff * xStep + dragOffset, y: index == selectedIndex ? -10 : 14)
.zIndex(index == selectedIndex ? 10 : Double(5 - abs(index - selectedIndex)))
.animation(.spring(response: 0.42, dampingFraction: 0.72), value: selectedIndex)
- カードを切り替えるたびに
mediumImpactではなくselectionChangedを使用します。細かく選択を調整する感覚が得られます。 dragOffsetに 抵抗倍率(* 0.5)を適用し、指の下でスタックに重みがあるように感じさせます。MeshGradient(iOS 18 以降、9 ポイント)は、カードやオーブを高級感のある塗りつぶしにします。制御点をアニメーションさせると、生き生きとした表面になります。
カバーフロー/連続的な奥行きのあるファン表示
上記の2状態のファン表示(selected と not)でシンプルなスタックには対応できますが、ポスター/カードをめくるカバーフローでは、直近の隣だけでなく、すべてのカードが連続的に遠ざかっていく必要があります。また、ジェスチャーが終わった後だけでなく、指でドラッグしている間もその動きを反映させます。ブール値ではなく、符号付きでドラッグに応じてブレンドされる距離を基準に、すべての変形を適用します。
private func effectiveDistance(for i: Int) -> CGFloat {
CGFloat(i - selectedIndex) - (dragOffset / cardWidth) // blends live drag in
}
private func scale(for i: Int) -> CGFloat { max(0.82, 1.0 - 0.12 * abs(effectiveDistance(for: i))) }
private func rotation(for i: Int) -> Double { Double(max(-2, min(2, effectiveDistance(for: i))) * 7) } // cap at ±14°
private func yOffset(for i: Int) -> CGFloat { abs(effectiveDistance(for: i)) * 18 }
private func opacity(for i: Int) -> Double { max(0.0, 1.0 - 0.28 * abs(effectiveDistance(for: i))) }
private func blur(for i: Int) -> CGFloat { min(4, abs(effectiveDistance(for: i)) * 2) }
card
.scaleEffect(scale(for: i))
.rotationEffect(.degrees(rotation(for: i)))
.offset(x: CGFloat(i - selectedIndex) * xStep + dragOffset, y: yOffset(for: i))
.opacity(opacity(for: i))
.blur(radius: blur(for: i))
.zIndex(-abs(effectiveDistance(for: i))) // closest to center draws on top
dragOffsetは固定値ではなくカード幅で割ります — これにより、ドラッグ量が「移動したカードの端数」に正規化され、指の動きに合わせて隣のカードがリアルタイムに拡大・縮小し、指を離すとスプリングの動きで残りの距離を移動します。こうしないと、ジェスチャーが終わったときにファン表示が一気に変わるだけです。- 回転を制限し、スケールと不透明度には下限を設けます(
max(-2, min(2, d))、max(0.82, …))— 約2枚分より遠くでは、線形の計算式によってカードが垂直を通り越して回転したり、不透明度が反転したりします。値を制限することで、表示の乱れを防ぎ、遠くのカードを小さく、暗く、ぼかして表示できます。 - 距離に応じたぼかしは、手軽に奥行きを表現できます — 中央からずれたカードに2〜4ptのぼかしを加えると、スケールや不透明度だけの場合よりも奥行きがはっきり伝わります。実際の被写界深度レンダリングと比べても、処理負荷はほとんどありません。
- インデックスの変更には
selectionChangedの触覚フィードバックを使います(上記の速度を考慮したページングを参照)。連続的な変形は視覚的なものであり、触覚フィードバックの段階には影響しません。 - 2つの方向で異なる感触にしたい場合は、非対称な応答を使ってもかまいません。多くの場合、より自然な動きになります。 距離に対する応答曲線を対称にする必要はありません。「すでにめくり終えた」カードのスタックと「これからめくる」カードのスタックで、1ステップあたりの角度を変えられます(たとえば、先のカードは傾きを浅く、後ろのカードはより直立させます)。これにより、デッキの両側が鏡像ではなく、異なる物理的なまとまりとして見えます。距離の絶対値だけでなく、符号に応じて計算式を分岐させます。
選択状態に同期する没入感のある背景
映画のポスター、アルバムアート、商品写真などで「見ているものに合わせて画面全体が反応する」演出にするには、カルーセルの背後に画面全体に広がるぼかし背景を重ね、常に選択中のカードと一致するようにクロスフェードします。
GeometryReader { geo in
ZStack {
ForEach(items.indices, id: \.self) { i in
Image(items[i].imageName)
.resizable().scaledToFill()
.frame(width: geo.size.width, height: geo.size.height)
.clipped()
.opacity(i == selectedIndex ? 1 : 0)
.animation(.easeInOut(duration: 0.55), value: selectedIndex)
}
}
.blur(radius: 80, opaque: true) // opaque: true — avoids edge transparency artifacts at high radius
.saturation(1.3) // blur flattens color; push saturation back up
}
Imageビューを差し替えるのではなく、候補となる画像をすべて重ねて、不透明度をクロスフェードします。 表示階層内の画像を差し替えると、読み込みとレイアウトが再び始まり、クロスフェードできません。常に配置されているビューのopacityを切り替えると滑らかにアニメーションし、.easeInOut(duration: 0.55)がディゾルブとして見えるようになります。- **
opaque: trueを指定した.blur(radius:opaque:)**は処理が速く、画面全体に広がる画像に大きな半径の通常の.blurを適用したときに生じる、半透明の縁のアーティファクトも防ぎます。 .blur/.saturationは画像ごとではなく、ZStack全体に一度だけ適用します — N回ではなく、1回のぼかし処理で済みます。- 上部と下部に
LinearGradientのスクリーンを重ねます(black.opacity(0.55) → clear → black.opacity(0.70))。これにより、どの写真の上でもヘッダーとタイトルの文字が読みやすくなります。 - フェードアウトする方法は2つあります。スクリーンとマスクのどちらにするかは、背景の下にあるものに応じて選びます。 上記のスクリーン(上から重ねる半透明のグラデーション)は、背景全体に読みやすいコンテンツを載せる場合に適しています。背景を画面の上部だけに表示し、その下の単色カードへ自然につなげるには、画像自体に
.maskを適用し、white → clearのグラデーションを使います。背後には単色のColor.blackを置きます。.mask(LinearGradient(stops: [.init(color: .white, location: 0), .init(color: .white, location: 0.18), .init(color: .clear, location: 0.42)], startPoint: .top, endPoint: .bottom))。マスクは背後のレイヤーを見せるため、黒まで完全に透明になります。一方、スクリーンは画像を暗くするだけです。単色の面へ明確かつきれいにつなげるにはマスクを使い、写真全体にわたって文字を読みやすく重ねるにはスクリーンを使います。
ページインジケーターのドット(可変幅)
カルーセル/ページングビューの下に置く、軽量な位置インジケーターです(タブコンテンツに合わせる必要がないため、タブバーのスライドインジケーターよりシンプルです)。
HStack(spacing: 7) {
ForEach(items.indices, id: \.self) { i in
Capsule()
.fill(.white.opacity(i == selectedIndex ? 0.95 : 0.3))
.frame(width: i == selectedIndex ? 22 : 7, height: 7)
}
}
.animation(.spring(response: 0.4, dampingFraction: 0.7), value: selectedIndex)
アクティブなドットが単に色や不透明度を変えるのではなく、カプセル状に伸びる(22pt、円形のドットは7pt)ことで、より上質な印象になります。色だけが変わる普通のドットインジケーターは、それに比べて古く見えます。
スライドインジケーター版(ピクセル単位で正確に中央揃えする場合)。 アクティブなカプセルを対象のドットの真上に正確に配置する必要がある場合(たとえば、中立色のドットの上に階層色のカプセルを重ねる場合)は、すべてのドットを静的な円として描画し、.matchedGeometryEffect(id: "activeDot", in: namespace) を指定したスライドする Capsule を重ねます。SwiftUI が位置を正確に補間します。複数のドットでアクティブ時の色が異なる(インデックスに応じてカプセルの色が変わる)ため、手動のオフセット計算にずれが生じる場合に使います。すべてのドットで色が同じ場合は、上記のシンプルなドットごとの幅を使う方法を選んでください。
選択時の詳細テキストの切り替え(.numericText の軽量な代替手段)
カルーセル/リストの選択によって、数値ではない隣接テキスト(タイトル、メタデータ)が切り替わる場合、.numericText は適用できません。カスタムトランジションを使う代わりに、選択に応じて変わる .id() を指定し、.contentTransition(.opacity) を使います。
Text(current.title)
.contentTransition(.opacity)
.id(current.id) // changing id forces the transition to fire
.animation(.easeInOut, value: current.id) // implicit, or wrap the mutation in withAnimation
- 変える必要があるのは文字列ではなく、
.id()です —.contentTransitionは基になるビューの識別子が変わったときに動作するため、2つの項目間でたまたま変わらない安定した文字列(たとえば、ジャンルのチップが偶然同じ2つの映画)をキーにすると、クロスフェードがひそかにスキップされます。項目自体のid、またはフィールドごとのキーとしてtext + current.id(同じ項目の異なるフィールドを表示する再利用可能な「チップ」サブビューなど)をキーにしてください。
ネイティブ物理のスクロールスタック(不可視のUIScrollViewドライバー)
DragGesture駆動のスタック(上記のカルーセルとページングを参照)では、常にスプリングで勢いを近似します。実際のUIKitの減速やラバーバンドの動きと完全に一致することはありません。スタックをネイティブリストのスクロールのように感じさせたい場合(Music.appやPhotosのように自由にスクロールして減速する、カードやアルバムを重ねたデッキなど)は、**実際の不可視なUIScrollView**で駆動し、まったく別のSwiftUI描画スタックがカードの位置決めに使うプレーンなCGFloatバインディングを更新させます。
private struct ScrollDriver: UIViewRepresentable {
@Binding var scrollOffset: CGFloat // expressed in "items", not points
let itemSpacing: CGFloat
let maxOffset: CGFloat
func makeUIView(context: Context) -> UIScrollView {
let sv = UIScrollView()
sv.delegate = context.coordinator
sv.decelerationRate = .normal // real momentum curve — not a spring
sv.backgroundColor = .clear
return sv
}
func updateUIView(_ sv: UIScrollView, context: Context) {
sv.contentSize = CGSize(width: sv.bounds.width, height: maxOffset * itemSpacing + sv.bounds.height)
guard !context.coordinator.isTracking, !context.coordinator.isDecelerating else { return }
let target = scrollOffset * itemSpacing
if abs(sv.contentOffset.y - target) > 0.5 {
sv.setContentOffset(CGPoint(x: 0, y: target), animated: false)
}
}
// Coordinator (UIScrollViewDelegate): scrollViewDidScroll → scrollOffset = contentOffset.y / itemSpacing
}
// The rendered card stack sits ABOVE the driver in a ZStack with .allowsHitTesting(false) on the
// stack — every touch lands on the invisible UIScrollView, which never draws any content of its own.
見落としやすいルール — どれも実際に陥りやすい罠です:
- スクロールビューには何も描画しません。
contentSizeはスクロール可能な範囲を定義し、実際の減速やラバーバンドの物理挙動を発生させるためだけに存在します。実際のカードは、scrollOffsetをプレーンな浮動小数点値として読み取る、完全に別のレイヤーにあります。この分離 — UIKitが「どこに」を計算し、SwiftUI(またはSceneKitに埋め込まれたカード)が「何を」を描画する — が核となる考え方で、ネイティブなスクロール感を求めるあらゆるカスタム描画スタックに応用できます。 - プログラムからの
setContentOffsetはすべて、!isTracking && !isDeceleratingの条件下で実行します。 このガードがないと、往復処理(ドライバー →@Binding→updateUIView→setContentOffset)がユーザー自身のタッチやネイティブの減速と競合し、動きがカクつきます。ユーザー自身が操作していないとき(たとえば外部から「インデックスにジャンプ」する呼び出し)にのみ、オフセットを強制的に同期します。 - 静止時の触覚フィードバック — 最後に発火したインデックスとの差分を取り、最も近い値に丸めます。生のピクセルしきい値は使いません:
let crossed = Int(floor(offset + 0.5)); if crossed != lastHapticIndex { tick(); lastHapticIndex = crossed }。どちらの方向でもアイテム境界を越えるたびに、selectionChangedが正確に1回発火します。「ポイント」ではなく「アイテム」で表現されるため、固定ピクセルしきい値より堅牢で、itemSpacingに関係なく同じように動作します。 - 境界/ラバーバンドの触覚フィードバック — フレームごとではなく、オーバースクロールするたびに1回だけ発火させます。
contentOffsetがどちらかの端を越えた最初のフレームでboundaryFiredフラグを設定し、scrollViewWillBeginDraggingでリセットします。そうしないと、ユーザーがビューを限界より先まで引っ張ったままにしている間、scrollViewDidScrollのたびに強いインパクトが発火し、1回の明確な振動ではなく何十回も振動します。 - ウィンドウ内のみを描画 — 表示範囲から遠いカードは除外します。 アイテムごとに重いビュー(SceneKit、Metal、ビデオ)が多数ある場合は、スタック全体を描画しないでください。
ForEach内でif abs(scrollProgress) <= renderWindow { cardView }とします。除外されたアイテムのビューはSwiftUIによって構築されません。各インスタンスが実際のSCNViewと描画パスを所有するため、特にSceneKitでは不可欠です。
スタックカード(通知スタック)
Appleの折りたたまれた通知スタックのように、新しいカードが前面にフルサイズで表示され、古いカードは背後から覗きます(奥行きに応じて縮小、オフセット、減光)。タップするとリストに展開し、前面のカードをスワイプして閉じることができ、件数に上限を設けて、最も古いカードが最後尾から外れます。
// front = index 0. Transform each card from its depth + an `expanded` flag:
let yOffset = expanded ? CGFloat(depth) * (cardHeight + 10) : CGFloat(depth) * 16 // peek step
let scale = expanded ? 1.0 : max(0.88, 1.0 - CGFloat(depth) * 0.06)
let opacity = expanded ? 1.0 : (depth < 3 ? 1.0 - Double(depth) * 0.22 : 0.0) // only ~3 peek
card.scaleEffect(scale).opacity(opacity).offset(y: yOffset).zIndex(Double(count - depth))
- 先頭に挿入して上限を適用:
withAnimation(.spring) { items.insert(new, at: 0); if items.count > cap { items.removeLast() } }— 新しいカードがポップインし(widthPopと組み合わせます)、最も古いカードは後方にフェードアウトします。 - 展開の切り替え: スタックの
onTapGestureでexpandedを切り替え、selectionChangedの触覚フィードバックと.spring(0.5/0.8)を適用します。 - スワイプで閉じる(前面のカードのみ):
DragGestureをdepth == 0の場合に限定します。dragOffsetをラバーバンドさせ、開始時にlightImpactを発生させます。約120ptを超えたらremoveFirst()とheavyImpactを実行し、それ以外の場合はスプリングで元に戻します。 - 各カードに
.transition(.asymmetric(insertion: .widthPop, removal: .move(edge: .top).combined(with: .opacity)))を設定します。 - 「通知」カードの表面: iOS 26では、本格的なLiquid Glassを使います —
.glassEffect(.regular.tint((isDark ? .black : .white).opacity(0.28)), in: shape)。それ以外の場合は、下記の.ultraThinMaterialとスキームに応じた色合いを使います(ライトテーマ → アダプティブを参照)。角丸は連続的な28ptにし、柔らかな影を付けます。
自動で進むカードデッキ(スワイプまたはタイマー、無限ループ)
Tinder風のデッキ(映画/曲/今日のおすすめ商品、ストーリーカードなど)です。手前のカードは、ユーザーがスワイプして取り除くか、タイマーで自動的に次へ進み、無限にループします。次のカードは、取り除いたカードが画面外へ落ち終わるのを待たずに、すぐに手前へ上がる必要があります。
剰余距離で無限ループするため、index の範囲チェックは不要です:
private func slot(for i: Int) -> Int { (i - index + items.count) % items.count } // 0 = front
advance() 関数は1つ、呼び出し元は2つ。 スワイプジェスチャー(しきい値を超えたとき)と .task の自動進行タイマーの両方が同じ関数を呼び出します。これにより、落下アニメーション、触覚フィードバック、状態の変更を一度だけ定義でき、手動と自動の処理で食い違いが生じません:
.task {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(2.5))
if flying == nil { advance(fromDrag: 0) } // don't double-fire if a swipe is already mid-flight
}
}
重要な落とし穴 — 退出するカードを、カードが抜けるデッキから切り離します。 取り除くカードをデッキを描画する同じ ForEach 内に残したままにすると、index の更新(残りのカードの奥行き/オフセットがすべて入れ替わります)と、そのカードの削除アニメーションが同じ要素を取り合い、動きがぎこちなくなったり、目に見えるちらつきが発生したりします。代わりに、落下するカードをスタックのループ外にある独立したオーバーレイとして描画し、専用の状態を持たせます:
@State private var flying: Item? = nil
@State private var flyY: CGFloat = 0
@State private var flyRot: Double = 0
private func advance(fromDrag startY: CGFloat) {
flying = items[index]; flyY = startY; flyRot = tilt(for: startY)
withAnimation(.easeInOut(duration: 0.55)) { index = (index + 1) % items.count } // stack reshuffles NOW, fast
withAnimation(.easeIn(duration: 0.85)) { flyY = 1100; flyRot = 6 } // discarded card keeps falling, slower
DispatchQueue.main.asyncAfter(deadline: .now() + 0.87) { flying = nil }
}
// in the stack ForEach: skip any card whose id == flying?.id
// render the flying card separately: cardView(f).offset(y: flyY).rotationEffect(.degrees(flyRot)).zIndex(100)
- 同時に起きる2つの動作には、それぞれ異なる時間を設定します。スタックの入れ替え(
0.55s)は、カードの落下(0.85s)より意図的に短くします。これにより、取り除いたカードが背景でまだ目に見える形で退出を終えている間に、次のカードはすでに所定の位置に収まっています。この工夫により、「前のカードが去るのを待った」ではなく、「次のカードが手前に上がった」と感じられます。 advance()の呼び出しと同時にindexが更新されます。落下アニメーションが更新を妨げることはありません。- 段階的に変化するスタックのオフセット(奥へ行くほど間隔を狭めます)にすると、奥行きごとの間隔が一定の場合よりも、実際に手で積んだデッキのように見えます:
private func stackOffset(_ depth: Int) -> CGFloat {
var y: CGFloat = 0, step: CGFloat = 18
for _ in 0..<depth { y += step; step = max(9, step - 3) } // 18, 15, 12, 9, 9…
return -y
}
Canvas のメタボール(グー/液体の押し出し)
SwiftUIだけで液体のように融合・分離させたい要素(iOS 26 の GlassEffectContainer を使わない場合)には、たとえばボタンからアクションが押し出されるスピードダイヤルFABなど、グー・フィルターを使って Canvas にブロブを描画します:
Canvas { ctx, size in
ctx.addFilter(.alphaThreshold(min: 0.5, color: blobColor)) // hard edge after blur
ctx.addFilter(.blur(radius: 16)) // halos overlap → merge
ctx.drawLayer { layer in
layer.fill(Path(ellipseIn: fabRect), with: .color(.white))
for b in bubbles { layer.fill(Path(ellipseIn: b.rect), with: .color(.white)) }
}
}
わかりにくいルールがあります。どれも実際につまずきやすいポイントです。
alphaThresholdは輪郭をくっきりさせます。 静止時のブロブは鮮明な円になり、重なった部分だけがグーのようになります。「液体」=「柔らかい/ぼやけた」ではありません。- レイヤーを
View, Animatableにし、animatableData = progressを設定してCanvasをアニメーションさせます。Canvasのクロージャ内で@Stateを直接読むと、値が飛びます。Animatable を使うと、SwiftUI がprogressをフレームごとに補間し、各段階でグーを再描画できます。 - すべてのブロブをほぼ同じ大きさに保ちます。 1つのグローバルなぼかし設定で適切に見える円の大きさは1種類だけです。移動中にバブルが小さくなる(たとえば
0.45·r)と、固定のぼかし幅が半径とほぼ同じになり、しきい値の結果が線状に潰れます。ブロブはほぼ元の大きさ(0.9–1.0·r)に保ちます。 - 静止時の間隔は
diameter + 2·blurより大きくします。 そうしないと、隣り合うブロブのぼかし部分のアルファが互いに消えず、涙滴形のままになります。くびれは遷移中だけ現れるようにします。 - 変形をゆっくりにします(
spring(response ≈ 1.0))。速いスプリングでは、グー全体が見えにくくなります。 Canvasは見た目を描画するだけです。 タップとアイコンのために、同じ位置に実際のButtonを重ねます。アイコンは.transitionで移動先に突然現れるのではなく、ブロブと同じスプリングで一緒に移動する必要があります(アニメーション付きの.position)。そうしないと、動きが不均一に見えます。FABに重なっている閉じた状態のボタンには.allowsHitTesting(false)を設定し、FABのタップを奪わないようにします。
スタイルのコツ: 液体のFABは、フラット(影なし)が最もよく見えます。ブロブとアイコンの色はパラメーターにし、背景に合わせて反転させます。暗い背景なら白いブロブと暗いグリフ、明るい背景なら暗いブロブと白いグリフにします。+ → ✕ は、1つの plus シンボルを 135° 回転させるだけです。.thin のウェイトにすると、高級感のあるグリフになります。
Canvas パスローダー(輪郭トレース/彗星の尾)
フェードする彗星の尾で図形の輪郭をなぞるローダー(無限大、星、多角形、ハート、バラなど)では、TimelineView のクロックで Canvas を動かし、あらかじめ計算した輪郭サンプルのセットを使います。フレームごとに図形の数式を再評価してはいけません。
// Build ONCE — cache unit samples per shape (e.g. in a static dictionary).
let samples: [CGPoint] = makeSamples(for: shape) // arc-length-even points
TimelineView(.animation(minimumInterval: 1.0/60.0)) { ctx in
let progress = (ctx.date.timeIntervalSince(start) / duration).wrappedUnit
Canvas(rendersAsynchronously: true) { gc, size in
// stroke the static outline faintly …
// … then draw N trail dots, each at interpolatedPoint(in: samples,
// progress: progress - tail*trailLength), radius/opacity fading by tail.
}
}
見落としやすいルール――どれも実際に落とし穴になります。
- サンプルを作成してからアニメーションさせます。フレームごとの評価は避けます。 輪郭を単位サンプルの
[CGPoint]として事前計算し、キャッシュします。アニメーションさせるのは、キャッシュ済みのサンプルに沿って補間する先頭位置のprogressだけです。レンダラー(輪郭のストロークと尾)は図形に依存しません。新しい図形の追加に必要なのはサンプル生成器を1つ追加することだけで、動きのコードを変更する必要はまったくありません。「どんな図形か」と「どう動くか」を完全に切り離せます。 - 弧長を均等にサンプリングすると、動きが一定速度になります。 パラメーター
tを均等にするのではなく、輪郭を弧長に沿って等間隔にたどります。tを均等にすると、曲線の進みが遅い部分では点が密集し、速い部分では点の間隔が広がります。そのため、先頭位置がprogressを進めるにつれて目に見えて加速/減速します(多角形の角付近で特に顕著です)。弧長を均等にすれば、先頭位置は一定速度で滑らかに進みます。図形の鋭さと動きの滑らかさは独立しています。シルエットに鋭い角があっても、移動がぎくしゃくすることはありません。 - シルエットに応じて2種類のサンプラーを使います。 ポリラインの弧長サンプラー(直線の辺とくっきりした頂点に適し、星/多角形向け)と、閉じた Catmull-Rom スプライン(有機的で流れるような曲線向け)です。Catmull-Rom には制御点が
> 3個必要です。それより少ない場合はポリラインサンプラーにフォールバックします。図形に角を付けるべきかどうかで選択します。 - 図形の作り方。 N点の星は、外側の半径
1.0と内側の半径~0.42を交互に配置した2N個の頂点で構成します。正多角形は単位円上のN個の頂点で構成します。回転オフセットで向きを調整します(頂点が上向きの星なら-π/2、上辺が平らな六角形なら+π/6)。 - スプリングや
withAnimationではなく、TimelineViewのクロックを使います。 ローダーは状態遷移ではなく、連続ループです。すべてをtimeから導きます。progress = (time/duration).wrappedUnitとし、同じクロックから独立したrotationとbreatheも算出します(これは Archetype Catalog の ローディングインジケーター → スプリングは使わないルールを Canvas に適用したものです)。
コンテンツの選び方: 汎用ローダーのショーケースでは、ブランドや商標のロゴをなぞるよりも、汎用的な幾何学図形(星、五角形、六角形、無限大)を優先します。高級感はそのままに商標への露出を避けられ、さまざまな用途に使える図形になります。
Liquid Glass のトーストとステータスバナー
Liquid Glass で表示する Apple スタイルの一時的なトースト/バナー(成功/エラー/警告/情報/「コピー済み」)では、ガラス効果が際立つのは背後に色鮮やかなコンテンツがある場合だけです。トーストレイヤーの背後に画像グリッド(または鮮やかな背景)を配置して、屈折を見えるようにしてください。単調な暗色/明色の背景では効果が見えません。
// neutral glass = maximum refraction; status color lives in the icon, NOT the glass tint
.background(glassBackground(Capsule(), tint: nil)) // .glassEffect(.regular) / .ultraThinMaterial fallback
一見わかりにくいルールです。どれも実際に陥りやすい落とし穴です。
- 画面より高さのある背景は、ZStack の兄弟要素ではなく、必ず
.backgroundに配置してください。 (headline) 固有の高さが画面を超える画像グリッド/LazyVGridを ZStack の兄弟要素として配置すると、コンテナの高さが増えてしまいます。その結果、Spacer()で位置を決めたコントロール(たとえば画面下部のボタン)が画面外に押し出され、気づかないうちに表示されなくなります。背景を.background { grid }.clipped()に移動して、画面全体を覆いつつ、前景のレイアウトに影響を与えないようにしてください。そうすれば、前景は実際のビューポートに合わせてレイアウトされます。補足:不透明なColorを.backgroundレイヤーの手前に重ねないでください。背景が隠れてしまいます。 - すべてのトーストでガラス効果を統一し、ステータスは表面ではなくアイコンで示してください。 ステータスごとにガラスを色付けすると(
.glassEffect(.regular.tint(statusColor)))、そのトーストはほぼ不透明な色付きの塗りつぶしのように見える一方、ニュートラルなトーストは屈折するため、見た目に一貫性がなくなります。カプセル型、カード型を問わず、すべてのトーストの表面にはニュートラルな.regularガラスを使い、ステータスの色は SF Symbol または小さな色付きアイコン領域で示してください。これは破壊的なボタンには色付きガラスを使うルールとは逆です。トーストには一貫性を、コントロールには意味の伝達を重視します。 - トーストの位置はセーフエリアに自動的に合わせられます。
.overlay(alignment: .top) { toast }はセーフエリアの上端に揃うため、.padding(.top, 10)を少し加えると Dynamic Island のすぐ下に配置できます。固定値の上部パディング(.padding(.top, 70))は、ビューが上部のセーフエリアを無視する場合にノッチの下に配置されてしまうため、値を決め打ちしないでください。 - 単なる遅延ではなく、トークンを使って自動的に閉じてください。 表示時に
toast = newを設定し、約2.5秒後に閉じる処理をスケジュールします。ただし、if toast?.id == new.idの場合に限ってクリアし、新しいトーストが早く消されないようにしてください。.transition(.move(edge: .top).combined(with: .opacity))をwithAnimationの中で使って、スライドイン/スライドアウトさせます。 - 形状: 1行のステータスには
Capsule、情報量の多いステータスにはアイコン領域+タイトル+サブタイトルのRoundedRectangleカードを使います。どちらも同じニュートラルなガラスを適用します。
アイコンのみの Liquid Glass アクションボタン(切り替えトリガー) — タップするたびにアイコンが変化する、Apple ネイティブなガラスボタンです。
Button(action: trigger) {
Image(systemName: kind.icon)
.contentTransition(.symbolEffect(.replace)) // morph to the next action
.frame(width: 72, height: 72)
}
.buttonStyle(.glass) // iOS 26 — material-circle fallback below
.buttonBorderShape(.circle)
.tint(kind.tint ?? .white)
// trigger(): show(current); withAnimation { index = (index + 1) % count }
- アイコンだけにします。ガラスとその屈折をボタンのすべてとして活かしてください。
.replaceトランジション(withAnimationの中で実行)は、この操作の主役です。テキストラベルや独自のリングは追加しないでください。 - ステータスに応じたハプティクス: 該当するステータスには
notificationSuccess / Warning / Error、ニュートラルな情報/コピー済みにはlightImpactを使います。
重ねたカードとその場での並べ替え
親ビューですでにドラッグまたは長押しが可能になっている行(.draggable を持つリスト項目や .contextMenu)の中に配置するカードのセットです。よくある落とし穴が2つ、パターンは1つです。
ドラッグを入れ子にしないでください。 すでにドラッグまたは長押しが有効な親ビューの中に子の .draggable を配置すると、入れ子になったジェスチャー認識器が同じタッチを奪い合います。親の長押しと子のドラッグが同じジェスチャーの流れで認識を開始するため、どちらも安定して動作しません。子のドラッグを小さなグリップハンドルに限定しても解決しません。その場所で親の長押しが引き続き認識されます。
- ドラッグではなく、タップ操作で並べ替えます。 各行に上/下のシェブロンボタンを配置します(最初の行では ▲ を無効にし、最後の行では ▼ を無効にします)。ボタンを押すと項目が1つ分移動します。ボタンは タップ で動作し、長押しと競合することがないため、どのカード内でも並べ替えが機能します。これは、ドラッグを利用できない場合に Apple が採用している代替方法と同じです。
- その場で位置に基づいて永続化します。 元データの i 番目のスロットを書き換えます(たとえば、スニペット内の URL トークンを並べ替え、間にある文章は元の位置にそのまま残します。項目数が一致しない場合は何もせず、テキストが壊れないようにします)。これにより、新しい順序が再読み込み後も維持されます。
通知デッキのパターン(折りたたんだ状態の覗き見スタックから展開)では、すべてのカードを ZStack(alignment: .top) に配置し、単一の isExpanded アニメーションで各カードのジオメトリを切り替えます。
let depth = CGFloat(min(index, maxPeeks)) // 0,1,2 — only top few peek
card
.frame(height: rowHeight)
.scaleEffect(isExpanded ? 1 : 1 - depth * 0.05, anchor: .top) // peeks shrink
.brightness(isExpanded ? 0 : -Double(depth) * 0.03) // …and dim
.offset(y: isExpanded ? CGFloat(index) * rowStride // fanned list
: depth * peekStep) // stacked peek
.opacity(!isExpanded && index > maxPeeks ? 0 : 1)
.zIndex(Double(count - index))
- 折りたたまれた状態では、デッキ全体が展開する単一のタップ領域になります(透明な
Buttonを重ねます)。展開するまでは、下にあるカードのallowsHitTesting(false)を維持します。 - ガラス風のカウントピル(
rectangle.stackと N)を折りたたまれた状態の一番上のカードに重ねます。 - コンテナーの高さは状態ごとに計算します。展開時は
count * rowHeight + (count-1) * spacing、折りたたまれた状態ではrowHeight + min(count-1, maxPeeks) * peekStepです。 - 展開/折りたたみ時には
mediumImpact、移動するたびにselectionを再生します。
Metal シェーダー(.colorEffect / stitchable)
プレミアムな GPU 表現を実現するには、通常の SwiftUI では届かない表現――触れられるリキッドクローム、ホログラフィックフォイル、プラズマ、溶融金属――を、実際の Metal シェーダーで記述し、.colorEffect で適用します。これは別の .metal ファイルに記述する本物の [[ stitchable ]] シェーダーであり、Canvas で似せたものではありません。(legendary-Animo にはすでに約 11 個が含まれており、確立されたパターンです。まず既存の .metal ファイルを確認してください。)
// LiquidMetal.metal
#include <metal_stdlib>
#include <SwiftUI/SwiftUI_Metal.h>
using namespace metal;
[[ stitchable ]]
half4 liquidMetal(float2 pos, half4 color, float2 res, float time, float4 r0 /*…*/) {
float2 uv = pos / res;
// FBM height field → normal → moving light (diffuse + specular + fresnel)
return half4(half3(col), 1.0h);
}
Rectangle().foregroundStyle(.black)
.colorEffect(ShaderLibrary.liquidMetal(.float2(size), .float(time), .float4(x,y,startT,amp)))
見落としやすいルール――どれも実際につまずきやすい点です。
.metalを pbxproj の Sources フェーズにsourcecode.metalとして登録します。 (見出し) ファイルを追加するだけでは不十分です。Compile Sources に含まれていないとdefault.metallibに取り込まれず、ShaderLibrary.<name>はエラーもクラッシュもなく、ひそかに黒く描画されます。[[ stitchable ]]関数名はShaderLibrary.<name>の呼び出しと完全に一致させる必要があります。- 最初の 2 つのパラメーターは自動的に渡されます。
.colorEffectシェーダーはhalf4 fn(float2 pos, half4 color, <your uniforms…>)という形です。SwiftUI がpos/colorを設定するため、Swift の引数リストは最初の uniform から始まります。(.distortionEffectはジオメトリを変形し、.layerEffectは隣接ピクセルをサンプリングできます。後者には<SwiftUI/SwiftUI_Metal.h>が必要です。) TimelineView(.animation)のクロックで動かし、スプリングは使いません。 シェーダーフィールドは連続ループです。time = ctx.date.timeIntervalSince(start)をfloatuniform として渡し、各ティックで.colorEffectを再適用します。(Loading Indicator/Canvas ローダーと同じルールです。)- タッチデータには構造体の浮動小数点配列がないため、固定長の
float4リングバッファーにパックします。 SwiftUI のShader.Argumentにあるのは.float/.float2/.float3/.float4/.color/.image/.floatArrayだけです。N 個のタッチ波紋を保持するには、Swift のリングバッファーを、たとえば 4 個までに制限し、4 つの固定.float4(x, y, startTime, amp)スロットとして渡します(空きスロットは amp 0 で埋めます)。ドラッグ中に直近の数個に制限すれば、手軽にリキッドウェイクを表現できます。 - 波紋は色ではなく、高さフィールドを変化させます。 各減衰するリング(
sin(d·k − age·ω)·exp(−age)·exp(−d))を、法線を計算する前に表面の高さへ加えます。そうすると、触れた場所で反射が曲がり、「液体らしさ」が生まれます。色を飛び散らせるだけではこの表現になりません。 - シェーダーを使わない代替表示(たとえば
AngularGradientのスイープ)を用意してiOS 17+を対象にします(.colorEffectは 17.0 以降)。これにより、16 でもビルドできます。 - 検証時の落とし穴: シェーダーはコンパイルとリンクに成功しても、黒く描画されることがあります(名前の不一致、Sources エントリーの欠落、引数順序の誤り)。ビルドして実際に確認してください。ログに
CompileMetalFileとMetalLinkがあればコンパイルされたことは分かりますが、描画できていることを証明するのはスクリーンショットだけです。
コンテンツの方向性: Metal の二重の意味を活かしましょう。溶融クローム/水銀/金/油膜のような表現は、高級感があり意外性もあります。合金の色調は tint-pair uniform にして、1 つのシェーダーをさまざまなパレットで使えるようにします。虹色の光沢は平坦なオーバーレイではなく、フレネル効果で表現します。
SceneKit による 3D オブジェクトのショーケース
スクロールやタッチに応じて傾いたり回転したりする実際の 3D オブジェクト(アルバムカバー、本、箱入り製品など)には、偽の rotation3DEffect による疑似 3D ではなく、UIViewRepresentable を使って SCNScene を埋め込み、SwiftUI の状態から動かします。
SceneKit を本当に透明に埋め込む
SCNView の背景はデフォルトで不透明な白です。次の 4 か所すべてでクリアしないと、SwiftUI の背景の上では見えません。
scene.background.contents = nil // the SCNScene itself
view.isOpaque = false
view.backgroundColor = .clear
view.layer.isOpaque = false
どれか 1 つでも欠けると、オブジェクトの後ろに白(または、クリア後かつレイヤー適用前なら黒)の長方形が表示されます。
SceneKit の暗黙的なアニメーションを競合させずに、SwiftUI の状態からノードの変換を動かす
SceneKit はデフォルトでノードのプロパティ変更を暗黙的にアニメーションします。傾きや回転を、スクロールオフセットやドラッグのような継続的に変化する値に追従させる場合、この暗黙的なアニメーションによって遅れが生じ、自分で制御する動きと競合してバネのように揺れます。駆動する更新はすべて、アクションを無効にしたトランザクションで囲みます。
SCNTransaction.begin()
SCNTransaction.disableActions = true
node.eulerAngles = SCNVector3Make(tiltX * .pi / 180, tiltY * .pi / 180, 0)
SCNTransaction.commit()
これで値は、指定したタイミング(各 updateUIView、各スクロールのティック)で正確に更新されます。アニメーションカーブを制御するのは SceneKit のデフォルトではなく、SwiftUI/UIKit です。
ボックスオブジェクトのレシピ(6面の SCNBox、1面をレンダリングしたラベルにする)
SCNBox の6つのマテリアルを SCNBox.materials の順序(+Z 前面、-Z 背面、+X 右側面、-X 左側面、+Y 上面、-Y 下面)に並べることで、背表紙を備えた実際の「スリーブ」オブジェクトを作成できます。前面 = アートワーク、背表紙の面 = 動的にレンダリングした UIImage(タイトルとサブタイトルを、色付き背景に Core Graphics で描画)、残りの面 = 単色のエッジカラーです:
box.materials = [coverFront, edgeColor, coverRight, edgeColor, edgeColor, labelEdge]
- 画像の面では
.lightingModel = .blinnを使用します(ライトに応じて陰影が付きます)。単色の面では.lightingModel = .constantを使用します(フラットに表示され、ライティング計算は不要なため、より軽量です)。 - 背表紙のラベルは
UIGraphicsImageRendererで描画するだけなので、SceneKit のテキスト API は必要ありません。一度だけレンダリングし(フレームごとにはレンダリングしません)、生成したUIImageを.diffuse.contentsに渡します。
ライティング/カメラのリグ —「空間に浮かぶオブジェクト」に再利用できるデフォルト設定
高級感のある単一オブジェクトのショーケースには、ディレクショナルなキーライト1つと、アンビエントなフィルライト1つで十分です。3点照明のリグを一式用意する必要はありません:
keyLight.type = .directional; keyLight.intensity = 900 // tune 700–1000
keyLight.eulerAngles = SCNVector3Make(-40 * .pi/180, 25 * .pi/180, 0) // top-front-left
ambientLight.type = .ambient; ambientLight.color = UIColor(white: 0.30, alpha: 1) // fill so shadow side isn't pitch black
camera.fieldOfView = 46; camera.position = SCNVector3Make(0, 0.3, 5.0)
デュアルモードコンポーネント:単独表示と埋め込み表示
同じオブジェクトビューを、全画面ショーケース(不透明な黒の背景、最大限のアンチエイリアス)としても、スクロールするスタック内に繰り返し表示する複数インスタンスの1つ(透明、複数を同時にレンダリングするためアンチエイリアスを軽量化)としても使用できます:
var embedded: Bool = false
// standalone: ZStack { Color.black.ignoresSafeArea(); sceneView }
// embedded: sceneView alone, background stays .clear
view.antialiasingMode = embedded ? .multisampling2X : .multisampling4X // cheaper AA when many instances render together
検証時の落とし穴: SceneKit のビューはコンパイルされ、実行できていても、上記の透明化処理が1つでも抜けていると、白または黒の単色の四角形が表示されることがあります。また、テクスチャの読み込みに失敗すると(UIImage(named:) が nil を返してもエラーは表示されません)、平坦な灰色のボックスが表示されます。ビューが階層内に存在することを確認するだけでなく、スクリーンショットを撮って、正しいアートワークとライティングでオブジェクトが実際にレンダリングされていることを確認してください。
アートワークからアクセントカラーを導き出す
画面の色合い(オブジェクトの背表紙の面、背景のスクリーン、ボタンのアクセントなど)を固定パレットの値ではなく画像の支配色に基づかせる場合は、縮小した画像をサンプリングし、ピクセルの出現頻度ではなく彩度と明るさで重み付けします。ピクセルの出現頻度だけを使うと、通常は最も多く使われている色(退屈な背景の灰色や黒であることが多い)が選ばれてしまいます:
let sampleSize = 40 // downscale first — sampling a full-res image per pixel is wasteful and noisy
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: sampleSize, height: sampleSize))
// per pixel: visibility = saturation * 0.7 + brightness * 0.3
// bucket by quantized color, accumulate weight * (r,g,b), skip alpha < 0.5
// winner = bucket with highest total weight; divide its accumulated color by its weight
- ピクセル数ではなく、彩度と明るさで重み付けします。 ポスターやカバー画像を単純に「最も頻度の高い色」で処理すると、たいていは退屈な灰色や黒の背景色が返されます。彩度が高く中程度の明るさのピクセルを重視すると、人が実際に「この画像の色」と呼ぶ色を見つけられます。
- 白や黒に近い外れ値を明示的に除外します(
brightnessが0.08–0.95の範囲外のピクセルをスキップします)。そうしないと、ほとんどが白または黒の画像では、「支配色」として白または黒が返されます。それは正しくても、アクセントカラーとしては役に立ちません。 - 白に近い色が選ばれた場合は暗くします(
luminance > 0.85 → multiply channels by 0.75)。重み付けをしても、パステルカラーが多い画像では、単色の背景に塗りつぶすと真っ白なパネルのように見える色が選ばれることがあります。値を下げることで、無色ではなく、色としてはっきり見えるようにします。 - コントラストのある文字色には、チャンネルの平均ではなく知覚輝度を使います:
0.299·r + 0.587·g + 0.114·b > 0.55 → black text, else white。同じチャンネル値でも、緑は赤や青より目に明るく映ります。そのため、単純な(r+g+b)/3の平均では、彩度の高い純青や純緑の背景に対して文字色を誤って選ぶことがあります。
ライブアクティビティと本物のDynamic Island(ActivityKit + WidgetKit)
アプリ内ビューでDynamic Island(モーフィング、ガラス表現、ジェスチャー)を模倣することはできますが、実際のDynamic Islandとロック画面に表示するには、ActivityKit + Widget Extensionが必要で、そのルールはアプリ内のSwiftUIビューとはまったく異なります。マイクロインタラクションには模倣を、実際のDynamic Islandに表示する必要がある場合はLive Activityを構築してください。
- ビューではなく、独立したWidget Extensionターゲットが必要です。
ContentStateを含むstruct XAttributes: ActivityAttributesを宣言し、WidgetKit extensionにActivityConfiguration(for:)+DynamicIsland { }DSLを追加し、アプリのInfo.plistでNSSupportsLiveActivities = trueを設定して、アプリからActivity.request / update / endを呼び出します。このターゲットを作成するCLIはありません。XcodeのFile → New → Target → Widget Extensionを使い、「Include Live Activity」にチェックを入れてください。 - WidgetKit ≠ アプリ内のSwiftUI:ジェスチャー、連続アニメーション、カスタムスプリングはありません。 (見出し) コンパクト表示から展開表示へのモーフィングはシステムが行います(iOS 26では本物のLiquid Glass)。各領域のコンテンツを指定するだけです。操作できるのはApp-Intent Buttonsのみ(
LiveActivityIntentに基づくButton(intent:))であり、DragGestureやonTapGestureは使えません。スクラバーはドラッグできず、プログレスバーはプッシュされた状態更新でのみ進みます。TimelineViewによる60 fpsのループもありません。そのため、ドラッグ/ラバーバンド/リアルタイムのイコライザーといったマイクロインタラクションはアプリ内に置き、Dynamic Islandは宣言型のステータス表示として使います。 - アートワークのメモリ制限が、Dynamic Islandが空白になる主な原因です。 (見出し) Live Activity Extensionのメモリ上限は約30 MBです。フル解像度の写真(デコード後で
4000×6000≈ 96 MB)は気付かれないまま描画に失敗し、灰色のプレースホルダーボックスになります。クラッシュもエラーもありません。アートワークは約400×400で用意してください(表示サイズは小さいためです)。「Dynamic Islandのアートワークが表示されないのはなぜ?」という問題で最もよくある原因です。 - 展開領域はクリップされるため、コンパクトにまとめてください。
DynamicIslandExpandedRegionの各領域(leading / trailing / center / bottom)には高さの上限があります。アートワーク、タイトル、進捗、時刻、大きな再生コントロールの行を置くと、あふれて下部がクリップされます。小さめのコントロール用フォント(再生は.title2、スキップは.body)を使い、間隔を詰めて、経過時間/残り時間は右端に浮かせた数字ではなく、プログレスバーの下に配置してください。 - 共有する
ActivityAttributesは両方のターゲットに含める必要があります。 Attributes/ContentStateファイルは、アクティビティをリクエスト/更新するアプリと、描画するウィジェットの両方でコンパイルされます。1つのPBXFileReferenceを用意し、各ターゲットのSourcesフェーズにビルドファイルのエントリーを追加するか、同期フォルダーのメンバーとして登録します。各モジュールでコンパイルされるため、もう一方からimportする必要はありません。 - 最近のXcodeのウィジェットターゲットはファイルシステムと同期されています(
PBXFileSystemSynchronizedRootGroup)。Extensionのフォルダーに追加したファイルは自動的にターゲットに含まれ、テンプレートのサンプルを削除すると自動的に除外されます。明示的な設定が必要なのは、ターゲット間で共有するファイルだけです。 - Dynamic Islandとアプリを双方向に同期します。 Dynamic IslandのApp-Intentボタンは、実行中のアクティビティを直接変更します(
Activity<Attrs>.activities.first→update(...))。アプリはactivity.contentUpdatesを監視し、Dynamic Islandでの変更を自身のUIに反映することで、両方でisPlaying/進捗を一致させます。 - **コンテンツの見栄え:**黒いピルには、本物の小さなアルバムカバーがあると高級感が出ます。グラデーションとSF-Symbolのプレースホルダーでは「アートワークなし」という印象になります。
SceneKit メタリックバッジ — 平面のライティング、コイン投げのような着地、背面の刻印
回転できる金属製のアチーブメントバッジ(盾/コイン/星)です。押し出し加工したSCNShapeのベゼル+カラーエナメル+浮き彫りのエンブレムで構成し、コード生成した環境グラデーションをPBR金属で反射します。ボックスオブジェクトのショーケースとは異なり、平らで正面を向いた金属ならではの落とし穴があります。
- 平らな金属面が映すのは光ではなく環境です。 (見出し) カメラを向いた純金属(
metalness 1)の平面は、環境マップのカメラ側の領域を反射します。通常その領域は暗いため、暗い背景では黒く表示されます。ディレクショナルキーライトは純金属に拡散光を加えないので、これを補えません。対策:平面にはサテンメタル(metalness ≈ 0.7、roughness ≈ 0.34)を使って色味を出し、ミラークローム(roughness ≈ 0.15)はベベル面/曲面に使います(環境の明るい上部を捉えます)。ベベルのあるクレストは正面から見てもきれいですが、平らなコインはこの対策なしではうまくいきません。 - 静止時/正面向きの金属には正面フィルライトを追加します。 サテンメタルでも、上部ライトとリムライトだけでは静止時にくすんで見えます。ほぼ正面からの弱いディレクショナルライトを当て、静止時に少し傾けて面を環境の明るい上部に向けることで、中央のスペキュラーを含め、正面を明るく保てます。コントラストの強い環境グラデーション(明るい上部→ほぼ黒の下部)と、高めに設定した
lightingEnvironment.intensityを組み合わせると、反射にメリハリが出ます。 - コイン投げのように着地させる(自動回転なし)。 レンダーループから1つの
spinAngleを動かします。ドラッグしていないときは、毎フレーム再計算しながら、ばねのように最も近いπの倍数へ向かわせます。target = round(spinAngle / .pi) * .pi。フリック時にはDragGesture.value.velocityからspinVelを設定します。フレームごとにターゲットを選び直すので、勢いでバッジが表裏まで回転し、わずかに揺れながら最も近い面(表/裏)で止まります。剛性を低め(約50)にすると、フリックが中間点で跳ね返されず、そのまま通過します。 - 刻印する背面プレーンは正面から見えないようにします。 本体の後ろに両面テクスチャ付きのプレーンを置くと、正面ににじみ出て見えます(先細りのシルエットからはみ出したり、暗いエナメル越しに見えたりします)。プレーンを不透明な本体のかなり後ろ(
z方向の背面より奥)に置き、本体のシルエット内(最も幅の広い形状より小さく)に収めて、正面から見たときに本体で隠れるようにします。バッジを裏返したときだけ表示されます。また、背面の文字を事前に反転しないでください。親のy = πの反転ですでにプレーンの表面がカメラを向くため、手動で水平反転すると二重反転になり、文字が逆向きに読めます。 - 同心円リングのコイン(Apple Moveアワードのモチーフ): 金属ベースの
SCNCylinder+色付きのくぼんだ表面+溝を表す浮き彫りのSCNTorusリング+中央のボスで構成します。各ノードをX軸まわりにπ/2回転させ、コインを平らにしてカメラに向けます。 - 再現度の上限 — プロシージャル生成と
.usdz。 押し出し加工したSCNShapeとコード生成した環境マップでは見た目を近似できますが、AppleのバッジはHDRI反射を実際に使う、テクスチャを焼き込んだ.usdzとして制作されています。ピクセル単位で一致させるには、ジオメトリを組み立てる代わりに.usdz(Reality Composer Pro/Blenderからエクスポート)を読み込みます。プロシージャル生成は「自己完結した、十分に良い」レベルであり、完全一致を目指すレベルではありません。
リングゲージ — 塗りつぶしとキャップドットのジオメトリ
Apple Fitness風のアクティビティリング(同心円状のグラデーションリングが塗りつぶされるもの)では:
Circle().strokeリングの先頭のキャップドットは、(side − lineWidth)/2ではなくside/2を周回します。 (headline)Circle().stroke(lineWidth:)はパスの中央にストロークを配置します(半径はside/2で、lineWidth/2分だけフレームの外側にはみ出します)。キャップドットを(side − lineWidth)/2に配置すると、リングの幅の半分だけ内側に入ってしまいます。side/2を周回させます。つまり、.offset(y: -side/2)を適用してから.rotationEffect(.degrees(progress * 360))でZStackの中心を軸に回転させ、バンド上を移動するようにします。- リングごとにずらしてスプリングアニメーションで塗りつぶします。 各リングの
Circle().trim(from: 0, to: min(progress, 1))を0から塗りつぶし、リングごとにwithAnimation(.spring).delay(0.08 * i)を適用します。統計値の数値は.contentTransition(.numericText(value:))でスクロールするように切り替えます。 - 目標超過時は折り返しと影で表現します(100%超過の合図)。
progress > 1の場合、上に2つ目のtrim(0, progress − 1)の弧を描き、progress * 360°回転させた影付きのキャップドットを配置してリングの開始点に重ねます。これが「目標を超えた」と伝える奥行きの合図になります。 - 鮮やかなリングのグラデーション(ピンク→赤はムーブ、緑はエクササイズ、シアン→青はスタンド)は、「暗い背景で純粋な
.red/.green/.blueは使わない」というルールの例外です。これらは調整されたグラデーションであり、くすんだシステムカラーではありません。
Liquid Glassのタブ ⇄ 検索のモーフ
iOS 26を象徴する機能は、検索ボタンが全幅の検索フィールドに変化する、浮遊感のあるグラスモーフィズムのタブバーです。
- これは1つの要素のサイズ変更ではなく、2つの
glassEffectIDsの融合/分離です。 (headline) タブバーのカプセルに"bar"、検索の円に"search"というIDを付けます。切り替え時には、バーを取り除き(溶けるように消えます)、検索要素を円から全幅のフィールドへと拡張します(同じ"search"IDを使います)。GlassEffectContainerがメタボール状の溶ける効果を描画します。1つの要素をサイズ変更するだけでは、「片方が消え、もう片方が広がる」という表現はできません。 - モーフが落ち着いてからキーボードのフォーカスを設定します。 モーフの途中でフィールドに自動フォーカスすると、キーボードがせり上がり、トランジションが妨げられます。モーフのスプリングが終わった後(ゆっくりしたモーフなら、たとえば
+0.6s後)に@FocusState = trueをスケジュールし、円からフィールドへのモーフを先に再生します。 - モーフをゆっくりにして変化を感じられるようにします(スプリングの
response ≈ 0.95)。また、融合しきい値のルール(layoutGap > containerSpacing + 6)を守り、静止時にバーと検索の円が互いにはみ出さないようにします。 - 適切なタブバーの使い心地にします。 アイコンのみを表示し、それぞれに
.frame(maxWidth: .infinity)を指定して均等に配置します(何もない余白を作りません)。選択状態はテキストラベルではなく、アイコンの後ろに柔らかな円を表示して示します。
リキッドステップスライダー(メタボールの塗りつぶし+値に追従する吹き出し)
液体のような感触の離散ステップスライダーです。塗りつぶされたトラックとつまみが、1つの粘り気のあるメタボールになり、値を示す吹き出しがつまみの上に浮かびます。(セグメントコントロールを使えば、1つのエンジンからテーマ別のバリエーションに切り替えられます。たとえば、暗い⚡「Charge」アイランドと、つまみがその位置の色に光る明るいスペクトラムの「Budget」トラックを用意できます。)
- 塗りつぶし+つまみ=1つのメタボールであり、マスクは
Animatableでなければなりません。 塗りつぶしバーとつまみ(ピル型または円形)を、Canvas内に白い図形として描画し(.alphaThreshold(min: 0.5) + .blur)、色付きグラデーションの.maskとして使います。このCanvasビューをView & Animatableにして、animatableData = progressを設定すると、SwiftUIがフレームごとに液体のような形を補間します。Canvas内で@Stateを直接読むと、ステップ間で滑らかに動かず、急に切り替わります。オーバーシュートするスプリング(0.5/0.55)で液体のように揺らし、各ステップでselectionChanged、端ではmediumImpactを発生させます。 - 塗りつぶしが細いときに消える落とし穴。 (見出し) 細い塗りつぶしバーと太いつまみを融合させる場合は、
blur ≤ fillHeight/2を保ってください。そうしないと、alphaThresholdによって細いバーが消えてしまいます(「半径がぼかし幅に近い縮小した塊は線状につぶれる」というメタボールのルールに該当するケースです)。fillHeight 10 → blur 5なら残りますが、blur 9ではバーが消えます。 - つまみに追従する値の吹き出しは、つまみ自身の座標空間に置く必要があります。
PreferenceKeyは使わないでください。 (見出し) つまみのフレームをPreferenceKeyと名前付きcoordinateSpaceで公開し、画面レベルで吹き出しを配置すると、最初の表示時に位置がずれます。Preferenceのコールバックはレイアウト後(表示アニメーションの途中)に呼ばれるため、古いフレームが報告され、最初のドラッグで修正されるまで吹き出しがつまみの上に重なります。代わりに、吹き出しをスライダーのGeometryReader内で.position(x: cx, y: midY - gap)に配置します。つまみの描画に使うものと同じcxを使うため、最初のフレームから正しい位置に表示されます。トラックのフレームより上にはみ出しますが、SwiftUIはクリップしないため、コントロールの上に浮かびます。 - 下端に配置する際の落とし穴。
ZStack内のVStackにSpacer()を置いただけでは、コンテンツは下端に配置されません。高さを強制するものがないと、VStackはコンテンツの高さまで縮み、中央に配置されます。兄弟となるZStackのレイヤーに、明示的な.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)を指定し(固定ヘッダーには別の.topレイヤーを使い)、位置を固定してください。 - 色相をサンプリングするつまみ。 スペクトラムグラデーションのスライダーでは、
progressの位置でグラデーションの色をサンプリングし、その色をつまみのグロー/ブルームの.shadowに指定します。ドラッグすると、つまみがその位置の色で光る、ちょっとした特別な演出です。
グリッド ⇄ 円柱のモーフィング(平面の格子 → 回転するドラム)
ドットの平面グリッド(QRコード、アバターの壁、カレンダー)が平面からはがれ、集まってゆっくり回転する3Dドラムになり、その状態を保ってから再び平面に戻ります。タップするだけで、どちらの方向にも切り替えられます。SceneKitを使わず、TimelineView内で三角関数だけを使って擬似3Dを実現します。
- ピンチポイントから放射状にずらし、戻るときは逆順にします。 (見出し) 各ドットに
pinchFractionを設定します。これは、グリッドの中心からの直線距離 ÷ 中心から角までの距離です。持ち上げるときはpinchFraction * maxStaggerだけ待ち、落ち着かせるときは同じmaxStaggerを使って(1 - pinchFraction) * maxStaggerだけ待ちます。1つの時間枠を共有することで、2つの動きが鏡像になります。持ち上げるときに先行したドットは、落ち着かせるときには遅れます。布をつまんで持ち上げ、縁から内側へ下ろすような動きです。行インデックスによるスタガーはワイプのように見えますが、ユークリッド距離を使うとリングが広がるように見えます。広がりは移動時間と同程度にする必要があります(maxStagger 0.8とmorphDuration 0.9など)。行ごとの0.02(1.1 秒の移動に対して端から端まで約 0.2 秒)では目に見えず、グリッド全体が一斉に動いたように見えます。 - 両方向で同じ五次スムーズステップ曲線を使います。
t*t*t*(t*(t*6 - 15) + 10)は、両端で1次導関数と2次導関数がともにゼロになります。速度がゼロになるためドットが弾かれたように見えず、加速度がゼロになるため動き出しが叩きつけられたように見えません。ここでは三次曲線よりこちらを優先し、バネよりもこちらを優先します。オーバーシュートがあるとドットがスロットを通り越して戻るため、運ばれたのではなく投げられたように見えてしまいます。 - 3D のターゲットには独自の均一な格子が必要です。ソースグリッドの
(row, col)を決して再利用しないでください。 (見出し) 疎なソース(QRコードや穴のあるグリッド)をセルごとにドラムへ割り当てると、リングが不揃いでまだらになります。ドラムのスロットに均等な番号を割り当て(cylRows × cylCols)、順序を固定したうえで並べ替えてスロットを割り当てます((row*7919 + col*104729) % 100003)。こうすることで、ドラムの形成中にスロットが見つからないドットが正方形全体に散らばります。QRコード順にスロットを割り当てると、余ったドットがすべて下部から出てきて、ソースが下から消されるように見えます。 - クランプしたランプではなく、滑らかなフェードで背面をカリングします。
depth = cos(colAngle)(1 は正面、−1 は背面)を、線形のクランプではなく同じスムーズステップ(smoothMorph((depth + 0.10) / 0.55))に通します。深度はコサインなので、シルエットの位置で変化が最も速くなります。そこはクランプしたランプの折れ曲がりが生じる位置でもあり、その結果、すべてのドットはゆっくり現れたあと、最大速度で突然消えます。これが1回転につき2回起こります。 - 回転の立ち上げでは、速度ではなく速度の積分値をアニメーションします。 1フレーム目から最大速度に切り替わるドラムでは、ドットがすでに画面幅分を毎秒移動するスロットへ変形するため、ドットは到着しても静止しません。
v = vmax(1 − e^(−t/τ))を時間変化させ、その厳密な積分値を角度に設定します。angle = vmax * (t − τ(1 − e^(−t/τ)))とすることで、角度は連続し、回転速度はゼロから始まります。τ ≈ 1.2sにすると、変形が終わるころには速度が最大値の約¾に達します。 - タップ駆動のクランプ時計を使い、すべてのレイアウト行が読み取る
cycleTimeは1つに保ちます。 展開中はmoduloループをmin(tForwardEnd, tFlatEnd + timeSince(expandTap))に、非展開中はmin(totalCycle, tHoldEnd + timeSince(collapseTap))に置き換えます。時計は前進し、各端点で停止するため、ジオメトリを計算するコードに変更を認識させることなく、ユーザーが展開状態のままにする時間だけ保持できます。回転時間は展開タップを起点とする別の時計で計測します。cycleTimeで駆動するドラムは、持ち上げ動作がクランプされた瞬間に止まってしまうため、折りたたみ中も回転し続ける必要があります。 - 変形中のタップは拒否します。 2つの動きでは逆順に遅延が割り当てられるため、途中で反転すると各ドットがその移動経路上の別の位置へ1フレームで跳び、波が裂けて見えます。両方の分岐で
timeSince(lastTap) >= morphDuration + maxStaggerをガード条件にします。 - リングの傾きには回転ではなくせん断を使います。
y += x * tan(θ)はドットを左右に動かさずに各リングを傾けるため、垂直方向のスペースだけを使い、水平方向の余裕は半径と軸のスイープにすべて残せます。軸の傾き、湾曲、S 字カーブによる横方向の移動量は、半径を減らして補います。3つすべてを同じ軸の半分の長さを基準に測定します。画面幅を基準にしてはいけません。画面幅は、幅の短いスマートフォンでは比率が崩れるためです。S 字の軸では符号を手動で適用します。負の底に非整数の指数を指定してpowに渡してはいけません。pow(abs(n), e) * (n < 0 ? -1 : 1)を使います。 - 砂時計のシルエットでは、半径だけを変化させます。 行の間隔は完全に均一に保ち、
waist + (edge − waist) * n²(nはドラムの下から上に向かって −1…1)で半径を拡大縮小します。二乗項によってウエスト部分の接線が平らになり、左右それぞれが滑らかな C 字になります。線形に減少させると中央で角になります。広がりを強くするには edge の係数だけを大きくします。両方を大きくすると筒全体が太くなり、最も密集しているピンチ部分で円同士がさらに詰まります。 - 端のスクリーンフェードは、
.clearではなく、不透明度ゼロのページ色へフェードさせます。.clearは透明な黒なので、そこへ補間するとフェードの中央に灰色が混じります。背景と完全に一致するColor(...).opacity(0)を使います。これらは ZStack の兄弟ではなく.overlayとして追加します。overlay は親のサイズに合わせられ、GeometryReaderが報告する高さに影響しません。一方、安全領域を無視する ZStack の兄弟はリーダーを拡大し、ドラムのサイズを気づかないうちに変えてしまいます。上下約20%をフェードさせることで、広がった縁の疎な部分が、ばらばらに散らばった円に見えるのも防げます。
スポットライトのコーチマークツアー(1つのオーバーレイで切り抜きが滑らかに移動)
画面を暗くし、実際のコントロールを1つ切り抜いて、次へ/ツアーを終了のボタンがあるツールチップを指し示します。タブバー、FAB、カードなど、異なるビューが所有するコントロールを順に案内します。
- ツアー全体を通して表示は1回だけにし、ステップの変更はレイアウトの変更として扱います。 (見出し) ステップごとにコーチマークを表示すると、「次へ」を押すたびに画面が閉じるように見えます。スクラムが点滅し、吹き出しが下にスライドしてから戻ってきます。表示は1回だけにし(背景が透過する
fullScreenCoverを、disablesAnimations = trueに設定したTransaction内で表示・削除します)、その後はwithAnimation(.spring(response: 0.5, dampingFraction: 0.86))内でターゲットだけを変更します。切り抜きのフレームがアニメーションし、コントロール間を滑らかに移動します。 - 吹き出しはクロスフェードし、切り抜きは滑らかに移動させます。 ステップごとにツールチップを再識別します(
.id(message)+.transition(.opacity))。これにより、切り抜きが移動する間、吹き出しはその場でフェードアウトし、新しい位置でフェードインします。吹き出しの位置をスプリングアニメーションさせると、移動中にテキストが画面上に引き伸ばされます。 - 終了時はフェードアウトし、決して閉じません。 ツアーを終了 →
isVisible = false→.easeInOut(0.25)で不透明度を変更 → その後で アニメーションなしでカバーを削除 → 最後に後続のアクションを実行します。初回表示も同様です。カバーはアニメーションなしで表示し、オーバーレイ自体の不透明度をフェードインさせます。 - すべてのクロームより上で、グローバル座標のフレームを収集します。 アンカーは
PreferenceKeyを通じてproxy.frame(in: .global)を報告します(カバーは独立したウィンドウです)。onPreferenceChangeは画面のルートに付けます。ScrollViewの外にあるタブバーや FAB は、スクロールコンテンツ上のリーダーには届きません。幅いっぱいのラッパーではなく、タップ可能なコントロールにアンカーを設定します。 GeometryReaderは2つ使います。 内側のGeometryReaderは.ignoresSafeArea()を指定して、グローバルフレームと同じ座標空間にします。ただし、この場合はセーフエリアインセットがゼロになるため、セーフエリアを維持する外側のリーダーからsafeAreaInsetsを読み取ります。- 余裕のある側に吹き出しを配置し、セーフエリア内で測定します。
spaceAbove = minY − safeTop、spaceBelow = height − safeBottom − maxYとし、大きい方の側に配置します。x座標は16ptのマージン内に収めたうえで、ターゲットのmidXが吹き出し内のどこに当たるかに応じてポインターを設定します(<0.33なら先頭側、>0.67なら末尾側)。これにより、吹き出しが画面端に固定されていても、矢印はコントロールを指します。ターゲットに面する吹き出しの端を、そこからgap分手前で止まるフレームに固定し、高さが変わるテキストが切り抜きを覆わないようにします。 - 切り抜きのサイズ調整。 「+」やタブバー中央のボタンなどの丸いコントロールは、半径を辺の長さの半分以上にします。テキストのターゲットには、フレームが字形にぴったり沿うため、x/yそれぞれに余白を設けます(約10 × 6pt)。レイアウトされていないターゲットはスキップし、ゼロの矩形をスポットライト表示することはありません。また、「次へ」は保存されたインデックス + 1ではなく、実際に表示されたステップの次に進みます。
- スクラムは反応せず、切り抜き部分は操作できます。 カードを誤って失わないよう、スクラムのタップには反応しません(
contentShape+ 空のタップ処理)。カバー越しにタッチを通過させることはできないため、切り抜きの上にほぼ透明なヒット領域を配置し、フェード後にコントロール本来のアクションを実行します。ユーザーが戻ってきたら、次のステップから再開します。 - ツアーの終了は控えめな操作にします。 太字、下線付き、不透明度約0.8の白で、装飾は付けず、ピル型のプライマリボタンの隣に配置します。不透明度0.8の黒いスクラム上では、ボタンにドロップシャドウは不要です。ツアーを実行するのは、ホスト画面が最前面にある間(その上にシート、プッシュされた画面、ペイウォールがない状態)だけにしてください。そうしないと、古いフレームにアンカーが付いてしまいます。
出力(作成モード)
進捗メッセージを1つずつ出力します。
⚙️ swiftui-microinteractions v1.25.1
🖼️ Assets: <found: name1, name2… · or · none found, using placeholders>
🎯 Archetype: <archetype name>
⚡ Physics: <spring preset and why — one phrase>
🎮 Haptics: <2–3 haptic moments>
🏗️ State: <tier> — <@State var names>
✍️ Writing <FileName>.swift…
ステップ 1 — ファイルをディスクに書き込みます:
- Carousel →
legendary-Animo/Carousels/が存在する場合はそこに、存在しない場合はCarousels/が存在すればそこに、それ以外の場合は現在の作業ディレクトリに保存します - その他 →
legendary-Animo/Animations/が存在する場合はそこに、存在しない場合はAnimations/が存在すればそこに、それ以外の場合は現在の作業ディレクトリに保存します
出力:✅ Saved to <full relative path>
ステップ 2 — Xcode プロジェクトへの登録:
現在のプロジェクト内で .xcodeproj を検索します(最大でも数階層まで)。
.xcodeproj が見つかった場合 → まずファイルシステム同期グループを確認します。.pbxproj に PBXFileSystemSynchronizedRootGroup が含まれている場合(Xcode 16 以降、objectVersion >= 77)、ソースディレクトリに配置されたファイルは自動的にコンパイルされるため、プロジェクトの編集は不要です。次を出力します:
📦 File-system-synchronized project — no .pbxproj edit needed.
File placed in: <directory>
それ以外の場合は、Xcode が行うのと同じ方法で、新しいファイルをそのプロジェクトの project.pbxproj に追加します:PBXFileReference、PBXBuildFile、隣接するファイルと同じグループ(Carousels または Animations)内の子エントリー、およびアプリターゲットの Sources ビルドフェーズ内のエントリーを追加します。書式は同じグループ内の既存ファイルに合わせ、未使用の24文字の16進数 ID を使用します。その project.pbxproj だけを編集します。次を出力します:
📦 Registered in Xcode project: <xcodeproj name>
└─ PBXFileReference ✓
└─ PBXBuildFile ✓
└─ Group child ✓
└─ Sources phase ✓
.xcodeproj が見つからない場合 → 次を出力します:
⚠️ No Xcode project found. Add the file manually:
File path: <full path>
In Xcode: File → Add Files to "<ProjectName>"
or drag into the Navigator → check "Add to target"
ステップ 3 — ContentView 登録用スニペット(```swift ブロック、手動で貼り付けます):
date: フィールドには、今日の実際の日付を使用します。過去の日付をハードコードしないでください。
DemoItem(
row: RowView(icon: "💧", title: "Title Here", desc: "Feature · feature · feature"),
destination: wrappedDestination { YourView() },
date: "<today's date e.g. June 11, 2026>",
hasDateHeader: true
)
ステップ 4 — アセットに関する注意事項(プレースホルダーを使用した場合のみ):
ASSET NOTES:
- Add portrait images to Assets.xcassets/Images/ named: photo1, photo2…
→ Recommended size: 400×600pt, portrait ratio
- Remove placeholder RoundedRectangle once real assets are added
出力(編集モード)
編集前に出力します:
📂 Reading <FileName>.swift…
✏️ Applying: <one-line summary of change>
✍️ Writing updated file…
既存のパスにあるファイルを上書きしてから、次を出力します:
✅ Updated <full relative path>
続けて、変更内容とその理由を箇条書きにした 変更点 リストを出力します。 (編集の場合はファイルがすでに登録されているため、pbxproj の更新は不要です。)