ios-marketing-capture
SwiftUI iOSアプリのマーケティング用スクリーンショットを複数のロケール・デバイス・外観にわたって自動で撮影したい場合に使用します。フルスクリーン撮影、カルーセルカードやウィジェットなどの個別要素のレンダリング、再現可能な出力ファイル名付けに対応しています。マーケティング用スクリーンショット、ロケール別スクリーンショット、ウィジェットレンダリング、App Storeアセット、fastlane代替、simctlスクリーンショットなどで起動します。
description の原文を見る
Use when the user wants to automate capture of marketing screenshots for a SwiftUI iOS app across multiple locales, devices, or appearances. Covers full-screen shots, isolated element renders (carousel cards, widgets), and reproducible output naming. Triggers on marketing screenshots, locale screenshots, widget renders, App Store assets, fastlane-alternative, simctl screenshots.
SKILL.md 本文
iOS マーケティングキャプチャ
概要
SwiftUI iOS アプリ向けにマーケティングスクリーンショットの取得を自動化します。複数のロケール対応で、2つの並列出力ストリームを提供します:
- フルスクリーンキャプチャ — すべてのマーケティング関連画面を、決定論的にシード化されたデータ、実際のステータスバー/セーフエリアクロムで取得
- 要素キャプチャ — 特定のコンポーネント(カード、ウィジェット、チャート)を任意のスケールで独立して描画。角丸内側は自然な背景、外側は透明
このスキルはキャプチャステップです。ユーザーが Apple スタイルのマーケティングページ(デバイスモックアップ、ヘッドライン、グラデーション)をスクリーンショットの周囲に合成したい場合は、app-store-screenshots スキルを後処理ステップとして組み合わせてください。
コアアプローチ
アプリ内キャプチャモードを採用。XCUITest 慣例との判断の交換トレードオフですが、ほぼすべての実プロジェクトで優れています。
XCUITest より アプリ内の理由:
- 新しいテストターゲット不要。 UI テストターゲットを既存 Xcode プロジェクトに追加すると pbxproj が壊れやすくなります。多くのプロジェクトはテストターゲットがなく xcodegen もない — 手動追加はエラーが起きやすい。
- 反復が高速。 UI テストは実行ごとに 30 秒以上起動に要します。アプリ内キャプチャはインストール済みバイナリの再起動だけです。
xcodebuild test不要。 ワークフロー全体がxcodebuild buildを 1 回、ロケールごとにsimctl launchを実行するだけです。テストバンドルのオーバーヘッドなし。- 実アプリの状態にアクセス可能。 ViewModel、SwiftData、ImageRenderer、
UIWindow.drawHierarchyを直接呼び出せます。XCUITest はタップとアクセシビリティ要素の読み取りのみです。 - 要素描画はプロセス内が必須。 ウィジェットビューまたは独立したコンポーネント上の
ImageRendererはアプリプロセス内で実行する必要があります — XCUITest に相当するものはありません。
動作方法:
- DEBUG のみの
MarketingCapture.swiftファイルがメインアプリターゲットに存在 -MarketingCapture 1で起動されたとき、アプリはデータをシード化し、コーディネーターがCaptureStepリストを走査します — 各ステップはナビゲート、整定待機、スナップショット、クリーンアップを実行- PNG はアプリのサンドボックス
Documents/marketing/<locale>/ディレクトリに書き込まれます - シェルスクリプトが 1 回ビルドしてインストール。
-AppleLanguages (xx) -AppleLocale xxで再起動してロケールをループし、simctl get_app_containerでファイルを取得します
プロセス
以下のステップを順番に実行してください。先へ進まないでください。
ステップ 1: 要件の確認
ユーザーにこれらの質問を一度に 1 つ(バッチ処理しない — 各回答が後の質問を無効化する可能性)してください:
- キャプチャするスクリーン — "どのスクリーンをキャプチャしたいですか?各画面のナビゲーションパスまたはタブ名を具体的なリストで教えてください。"
- 独立した要素 — "透明背景で独立して描画したいコンポーネントはありますか?(カルーセルカード、ウィジェット、ヒロータイル、チャートなど)"
- ロケール — "どのロケール?(a)
Localizable.xcstringsのすべてのロケール、(b) 指定する App Store サブセット、または (c) 明示的なリストを教えてください。" (a) の場合、.xcstringsファイルをグリップしてロケールコードを抽出:python3 -c "import json; d=json.load(open('<path>/Localizable.xcstrings')); langs=set(); [langs.update(v.get('localizations',{}).keys()) for v in d['strings'].values()]; print(sorted(langs))" - デバイス — "どのシミュレーター?(iOS 26 デザイン機能向けに 6.1" iPhone 17 推奨)" — デバイスが
xcrun simctl list devices availableで利用可能か確認してください。 - 外観 — "ライトのみ、ダークのみ、または両方?"
- シードデータ — "デモデータはどう入力されますか?(a) 新規インストール時に自動シード、(b) デバッグの『デモデータを読み込む』ボタン、(c) 手動追加、(d) デモデータはまだない。" 次に: "既存データはすべてのリストアップ画面がマーケティング用に入力されたような十分な量ですか?ユーザーと一緒に監査してください。"
ステップ 2: 調査
コードを書く前に、以下に答えられるほどコードベースを調査してください:
- プロジェクトは Xcode 同期フォルダグループ(Xcode 16+、
PBXFileSystemSynchronizedRootGroup)を使用していますか?はいの場合、新規ファイルは自動的にターゲットに含まれます — pbxproj 編集は不要。grep -c PBXFileSystemSynchronized <proj>.xcodeproj/project.pbxprojで確認。 - ルートナビゲーションパターンは何か?
TabView(selection:)— 最も一般的。必要なもの:@State selectedTabバインディング、タブインデックス、ネストされたNavigationStackを持つタブ。NavigationStack(単一スタックとルーター) — 必要なもの: パスバインディングまたはルーターオブジェクト、NavigationLink(value:)/.navigationDestinationタイプのセット。NavigationSplitView— 必要なもの: サイドバーセレクションバインディング、詳細列のナビゲーション状態。- カスタムコーディネーター / UIKit ホスト — 必要なもの: コーディネーターの
navigate(to:)メソッドまたは相当。
- ディープリンクはどう経路分析されますか?
onOpenURLハンドラと URL をナビゲーション状態にマップする列挙型/スイッチを見つけてください。 - デモデータシーダーはどこで定義されていますか?デバッグボタン(ある場合)から実際に
ModelContextに書き込む関数までのコードパスをトレース。シーダーが存在しない場合は、以下の「デモデータシーダーの作成」を参照。 - ウィジェットは別のターゲットに存在していますか?ウィジェットビューファイルとエントリタイプはメインアプリターゲットにもありますか?(ほぼ確実にいいえ — ImageRenderer 経由で描画する場合は追加する必要があります。)
- アプリは Live Activities / ActivityKit を使用していますか?はいの場合、これを既知の落とし穴としてフラグ(以下を参照)。
- アプリは SwiftData + CloudKit 同期(
cloudKitDatabase: .automatic)を使用していますか?はいの場合、既知の落とし穴としてフラグ。 - 非デフォルト状態でキャプチャする必要のあるビューはありますか?(例: タイマーがカウントダウン中、フォームが部分的に入力、特定の値のチャート)。はいの場合、各々に
static varプライミング機構が必要(下記「ビュー状態のプライミング」参照)。
ステップ 3: 設計をユーザーに提示
コードを書く前に、この構造で計画をまとめてください。コード作成前に明示的な承認を得てください:
- アーキテクチャ(アプリ内キャプチャモード、単一ファイル、DEBUG ゲート)
- ファイルリスト(作成/変更する正確なパス)
- スクリーン別キャプチャ計画(各画面への到達方法 — タブインデックス、ナビゲーションパス、シートトリガー)
- キャプチャ順序の根拠(どのスクリーンが他の前に来る必要があるか — 落とし穴 #5 参照)
- 要素描画アプローチ(どのコンポーネント、どう包装するか)
- 出力レイアウト(フォルダ構造、命名規則)
- このプロジェクトに関連する既知の落とし穴(ステップ 2 でフラグ)
- 必要なプライム状態(どのビュー、どの static vars)
ステップ 4: 実装
templates/ のテンプレートを開始点として使用してください。これらは参照パターンであり、コピペ足場ではありません — すべてのプロジェクトのナビゲーション、モデル、ビューは異なります。テンプレートは構成要素を示します。ターゲットアプリ用に組み立ててください。
生成する主要ファイル:
<AppName>/Debug/MarketingCapture.swift— キャプチャシステム全体、DEBUG のみ。含む:MarketingCaptureenum(起動引数パース、出力ヘルパー、ウィンドウスナップショット、プライミング vars)MarketingCaptureCoordinatorクラス([CaptureStep]を走査してスナップショット)MarketingElementHarnessenum(カード、ウィジェット、チャートの ImageRenderer 描画)
<AppName>/ContentView.swift(またはルートビューがあるところ) — DEBUG フック。データシード実行とコーディネーター実行。- プライム状態が必要なビュー — DEBUG ゲート
.onAppearフックと.onReceive閉じるリスナー。 scripts/capture-marketing.sh— ビルド + インストール + ロケール別ループ。.gitignore—marketing/を追加。
ステップ 5: 反復的に検証
スクリプトをユーザーに渡して待たないでください。シミュレーターに対して自分で実行し、完了宣言前に少なくとも 1 つのロケールを検証してください。Read ツールで出力 PNG を読み、各スクリーンが期待を示しているか視覚的に検証してください。一般的なランタイム問題は下記「既知の落とし穴」に記載。
問題が見つかったら、修正して、スクリプト全体を再実行し(失敗ロケールだけでなく — 修正は以前のロケールで回帰可能)、視覚的に再検証。
アーキテクチャ: ステップベースのキャプチャ
コーディネーターは CaptureStep 値のリストを走査してキャプチャを駆動します。各ステップは自己完結: 画面へのナビゲート方法、待機時間、クリーンアップ方法を知っています。
struct CaptureStep {
let name: String // 出力ファイル名、例 "01-home"
let navigate: @MainActor () -> Void // アプリを正しい状態に
let settle: Duration // アニメーション/読み込み待ち
let cleanup: (@MainActor () -> Void)? // 次ステップ前にティアダウン
}
コーディネーターはシンプルなループ:
for step in steps {
step.navigate()
try? await Task.sleep(for: step.settle)
if let image = MarketingCapture.snapshotKeyWindow() {
MarketingCapture.writePNG(image, name: step.name)
}
step.cleanup?()
try? await Task.sleep(for: .milliseconds(400)) // クリーンアップアニメーション
}
ナビゲーションパターン別のステップ構築
TabView アプリ(最も一般的):
// シンプルなタブ切り替え — インデックスを設定するだけ
CaptureStep(name: "01-home", navigate: { setTab(0) }, settle: .milliseconds(1800), cleanup: nil)
// タブ + 表示シート
CaptureStep(
name: "05-timer-setup",
navigate: {
setTab(3)
pendingBrewRecipe = someRecipe
},
settle: .milliseconds(2000),
cleanup: {
NotificationCenter.default.post(name: MarketingCapture.dismissSheetNotification, object: nil)
pendingBrewRecipe = nil
}
)
NavigationStack + ルーターアプリ:
// ルートをスタックにプッシュ
CaptureStep(
name: "02-detail",
navigate: { router.push(.itemDetail(item)) },
settle: .milliseconds(1800),
cleanup: { router.popToRoot() }
)
NavigationSplitView アプリ:
// サイドバー項目を選択し、詳細を選択
CaptureStep(
name: "03-detail",
navigate: {
sidebarSelection = .recipes
detailSelection = recipes.first
},
settle: .milliseconds(1800),
cleanup: { detailSelection = nil }
)
順序付け: スタッキングルール
同じスタックにプッシュするスクリーンより前に、「クリーン」なナビゲーション状態が必要なスクリーンをキャプチャしてください。 子ビュー内にネストされた NavigationPath / @State はコーディネーターからはポップできません。だから:
良い: 棚(クリーンリスト) → コーヒー詳細(棚のスタックにプッシュ)
悪い: コーヒー詳細 → 棚(スタックにまだ詳細がプッシュされたまま)
2 つのスクリーンが NavigationStack を共有する場合、ルートレベルビューを最初にキャプチャしてください。
ビュー状態のプライミング
特定の非デフォルト状態でキャプチャする必要のあるスクリーン — タイマーが途中でカウントダウン、特定の値のチャート、部分的に入力されたフォーム。パターン:
-
プライミング値ごとに
MarketingCaptureにstatic varを追加:/// コーディネーターがタイマービュー表示前に設定。 /// ビューは .onAppear で読み取り、特定の経過時間にジャンプします。 static var pendingElapsedSeconds: Int? /// 真に設定して、タイマーに評価オーバーレイを表示。 static var pendingShowAssessment: Bool = false -
ターゲットビューに DEBUG ゲート
.onAppearを追加。プライミング値を読み取り:.onAppear { #if DEBUG if MarketingCapture.isActive, let elapsed = MarketingCapture.pendingElapsedSeconds { phase = .active timerVM.elapsedTime = TimeInterval(elapsed) timerVM.start() DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { timerVM.pause() } } #endif } -
コーディネーターでナビゲート前に var を設定:
CaptureStep( name: "06-timer-midway", navigate: { MarketingCapture.pendingElapsedSeconds = 75 openTimerSheet(someRecipe) }, settle: .milliseconds(2400), cleanup: { MarketingCapture.pendingElapsedSeconds = nil NotificationCenter.default.post(name: MarketingCapture.dismissSheetNotification, object: nil) } )
デモデータシーダーの作成
アプリにデモデータ機構がない場合は作成してください。<AppName>/Debug/DemoDataSeeder.swift に配置し、#if DEBUG でラップ。
ガイドライン:
- キャプチャするすべてのスクリーンが入力されるだけ十分なデータをシード。 スクリーンリストに対してシードを監査。
- リアルな内容を使用: 実際の地名、妥当な数字、多様な状態(一部が「ストック少」、一部が「新鮮」、画像あり/なし)。
- アプリが SwiftData を使用する場合、
ModelContextに直接書き込み。Core Data なら managed object context。REST バックエンドなら local キャッシュ/ストア層経由でシード。 - シードをべき等に — 挿入前にデータが既に存在するか確認。ストアはシミュレータ再起動を超えて永続化。ロケール別にシードし直すと CloudKit 同期チャーンとクラッシュを引き起こします。
- 異なる UI 状態を埋めるのに十分な多様性を含める: 空の状態はマーケティングスクリーンでない限り表示されてはいけません。
最小形:
#if DEBUG
enum DemoDataSeeder {
static func seedIfEmpty(in context: ModelContext) {
let existing = (try? context.fetchCount(FetchDescriptor<Item>())) ?? 0
guard existing == 0 else { return }
// 異なる状態を持つアイテム
let items = [
Item(name: "...", status: .active, ...),
Item(name: "...", status: .lowStock, ...),
// ...すべてのスクリーンを埋めるのに十分
]
items.forEach { context.insert($0) }
try? context.save()
}
}
#endif
要素描画
要素は ImageRenderer で 3x スケール、角丸外側は透明で描画。
カード / リスト行
@MainActor
static func renderCards(items: [Item], theme: AppTheme) {
let cardWidth: CGFloat = 380
for item in items {
let card = ItemCard(item: item, theme: theme)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(width: cardWidth)
.background(theme.background)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
let renderer = ImageRenderer(content: card)
renderer.scale = 3
renderer.isOpaque = false
renderer.proposedSize = .init(width: cardWidth, height: nil)
guard let image = renderer.uiImage else { continue }
MarketingCapture.writePNG(image, name: "card-\(slugify(item.name))", subfolder: "elements")
}
}
ウィジェット
ウィジェットビューは特殊な処理が必要です。通常 WidgetKit のプロセス内で実行され、システム提供のパディングと背景に依存。
@MainActor
static func renderWidget(
name: String,
size: CGSize,
cornerRadius: CGFloat? = nil,
@ViewBuilder content: () -> some View
) {
let isAccessory = size.height <= 80
let radius = cornerRadius ?? (isAccessory ? 8 : 22)
let contentPadding: CGFloat = isAccessory ? 0 : 16
let view = content()
.padding(contentPadding)
.frame(width: size.width, height: size.height)
.background(theme.background)
.clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
.environment(\.colorScheme, .light)
let renderer = ImageRenderer(content: view)
renderer.scale = 3
renderer.isOpaque = false
renderer.proposedSize = .init(width: size.width, height: size.height)
guard let image = renderer.uiImage else { return }
MarketingCapture.writePNG(image, name: name, subfolder: "elements")
}
// 標準 iPhone ウィジェットサイズ(ポイント、iPhone 14-17 サイズクラス)
enum WidgetSize {
static let small = CGSize(width: 170, height: 170)
static let medium = CGSize(width: 364, height: 170)
static let large = CGSize(width: 364, height: 382)
static let accessoryCircular = CGSize(width: 76, height: 76)
static let accessoryRectangular = CGSize(width: 172, height: 76)
static let accessoryInline = CGSize(width: 257, height: 26)
}
// 使用例:
renderWidget(name: "widget-pulse-small", size: WidgetSize.small) {
PulseSmallView(entry: PulseEntry(
date: Date(),
count: 2,
streak: 5,
lastItemName: "Morning Routine"
))
}
チャート / スタンドアロンビュー
任意の SwiftUI ビューを要素として描画可能。同じ方法でラップ — 明示的なサイズ、背景、コーナークリップ:
@MainActor
static func renderChart() {
let chart = MyChartView(values: ChartData.sample)
.frame(width: 420, height: 420)
.background(theme.background)
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
let renderer = ImageRenderer(content: chart)
renderer.scale = 3
renderer.isOpaque = false
renderer.proposedSize = .init(width: 420, height: 420)
guard let image = renderer.uiImage else { return }
MarketingCapture.writePNG(image, name: "chart-overview", subfolder: "elements")
}
既知の落とし穴
これらはすべて実プロジェクトを悩ませた実際のバグです。このリストを負荷を支えるものとして扱ってください。
1. Live Activities はアプリ起動をまたいで永続化
ActivityKit Live Activities はプロセス終了を超えて生存。アプリがキャプチャ中に Live Activity を開始したら(例: タイマーの start())、次のロケール再起動がそれを継承。fresh シードがアクティビティが参照するモデルを削除と組み合わせ、SwiftData 永続プロパティアサーションが発生。
修正: マーケティングキャプチャブロックの最初の最初に <ActivityManager>.shared.endImmediately() を呼び出し、データに触る前に。ビュー onDisappear でキャプチャモード時に timerVM.stop()(または LA を適切に終わらせる何か)も呼び出し。
2. ロケール別に再シードしない
SwiftData + CloudKit をロケール別にシードすると同期チャーンとクラッシュ。SwiftData ストアは再起動をまたいで永続化 — データはロケール非依存のデモコンテンツ。1 回目の実行時にシード 1 回、以降はスキップ:
contentVM.fetchItems()
if contentVM.allItems.isEmpty {
DemoDataSeeder.seedIfEmpty(in: modelContext)
contentVM.fetchItems()
}
3. シード前にセットアップする ViewModel は古いスナップショットを保持
ルートビューの onAppear がマーケティングシード前に someVM.setup(modelContext:) を呼び出す場合、VM は空のストアのスナップショットを保持。シード後、データが必要なすべての VM に対して someVM.refresh()(またはその同等のフェッチメソッド)を呼び出し。
4. トリガーバインディングを nil に設定しても、シートを閉じない
親ビューが .fullScreenCover(item: $request) を表示し、request が内部 @State で駆動される場合、トリガーバインディングを設定(例: pendingItem = nil)しても、カバーに何もしません。カバーは up のまま、次のスクリーンショットはナビゲートした画面の代わりにそれをキャプチャ。
修正: NotificationCenter 経由で消去シグナルをブロードキャスト。表示されたビューがリッスン:
// MarketingCapture.swift
static let dismissSheetNotification = Notification.Name("MarketingCapture.dismissSheet")
// 表示されたビュー body 内
.onReceive(NotificationCenter.default.publisher(for: MarketingCapture.dismissSheetNotification)) { _ in
dismiss()
}
次にステップの cleanup で通知をポスト。カバーアニメーションが完了するまで少なくとも 900ms、次のステップを開始する前に許可。
5. NavigationPath は外からはポップできない
子ビューが @State private var navigationPath = NavigationPath() を保持し、深いリンクがそれにプッシュする場合、コーディネーターはポップできません。解決法: キャプチャシーケンスを並べ替える。スタックにプッシュするスクリーンはクリーンスタックが必要なスクリーンの後に来る。例: 棚を最初にキャプチャ、次にコーヒー詳細にプッシュ — その逆はしない。
6. ウィジェットビューは通常、拡張ターゲットのみ
ユーザーのウィジェットビューがウィジェット拡張ターゲットのみの場合、メインアプリターゲットの MarketingCapture.swift から参照できません。以下のいずれか:
- (a) ウィジェットビューファイル(およびエントリタイプと共有ヘルパー)をメインアプリターゲットの membership に追加。プロジェクトが同期フォルダグループを使用する場合、
PBXFileSystemSynchronizedBuildFileExceptionSet.membershipExceptionsを編集する意味。**重要な落とし穴:
ライセンス: MIT(寛容ライセンスのため全文を引用しています) · 原本リポジトリ
詳細情報
- 作者
- ParthJadhav
- ライセンス
- MIT
- 最終更新
- 2026/4/10
Source: https://github.com/ParthJadhav/ios-marketing-capture / ライセンス: MIT