CommunityImagegithub.com

ParthJadhav/ios-marketing-capture

Utilisez cette compétence lorsque l'utilisateur souhaite automatiser la capture de captures d'écran marketing pour une application iOS SwiftUI, avec prise en charge de multiples locales, appareils et apparences. Elle gère les captures plein écran, les rendus d'éléments isolés (cartes de carrousel, widgets) et la dénomination reproductible des fichiers de sortie.

Qu'est-ce que ios-marketing-capture ?

Cette compétence permet d'automatiser la capture de captures d'écran marketing pour une application iOS SwiftUI, en produisant deux flux de sortie parallèles : des captures plein écran de chaque écran pertinent, avec des données déterministes et une barre d'état réelle, et des rendus isolés de composants spécifiques (cartes, widgets, graphiques) avec des fonds transparents ou naturels. Contrairement à l'approche traditionnelle basée sur XCUITest, elle utilise un mode de capture intégré à l'application, ce qui accélère les itérations, réduit la complexité de la configuration du projet et permet un accès direct à l'état réel de l'application, aux ViewModels et à ImageRenderer. Le processus comprend une exploration du code source pour comprendre le modèle de navigation et les mécanismes d'alimentation en données de démonstration, puis une implémentation guidée par un coordinateur qui parcourt une liste d'étapes de capture. La compétence fonctionne sur toutes les plateformes prenant en charge les applications SwiftUI, comme Claude Code, Cursor et d'autres agents, et est conçue pour être utilisée comme une alternative légère à Fastlane snapshot. Elle inclut la création de fichiers de capture, l'intégration d'un script shell pour automatiser les builds et les tests sur simulateur, et la vérification visuelle des sorties PNG.

Compatible avec~Claude Code~Codex CLI~Cursor
npx skills add ParthJadhav/ios-marketing-capture

Installed? Explore more Image skills: steipete/songsee, affaan-m/frontend-design, mattpocock/improve-codebase-architecture · View all 6 →

Demander à votre IA préférée

Ouvre une nouvelle conversation avec cette compétence d'agent déjà préchargée.

Documentation

iOS Marketing Capture

Aperçu

Automatisez la capture reproductible de captures d'écran marketing pour une application iOS SwiftUI sur plusieurs locales, avec deux flux de sortie parallèles :

  1. Captures plein écran — chaque écran pertinent pour le marketing, avec des données déterministes préparées, une barre d'état réelle et le chrome de la zone de sécurité
  2. Captures d'éléments — rendus isolés de composants spécifiques (cartes, widgets, graphiques) à n'importe quelle échelle, avec un arrière-plan naturel à l'intérieur des coins arrondis et une transparence à l'extérieur

Cette compétence est l'étape de capture. Si l'utilisateur souhaite également des pages marketing de style Apple composées autour des captures (maquettes d'appareils, titres, dégradés), combinez-la avec la compétence app-store-screenshots en tant qu'étape de post-traitement.

Approche principale

Mode de capture intégré à l'application, pas XCUITest. C'est une décision difficile qui fait un compromis avec les conventions Fastlane snapshot / XCUITest, et elle gagne pour presque tous les projets réels.

Pourquoi intégré à l'application plutôt que XCUITest :

  • Pas de nouvelle cible de test. Ajouter une cible de test UI à un projet Xcode existant est une manipulation fragile du pbxproj. De nombreux projets n'ont aucune cible de test et pas de xcodegen — en ajouter une à la main est sujet aux erreurs.
  • Itération plus rapide. Un test UI prend 30s+ pour se lancer par exécution. La capture intégrée à l'application n'est qu'un relancement du binaire installé.
  • Pas de xcodebuild test. Tout le flux est xcodebuild build une fois, puis simctl launch par locale. Pas de surcharge liée au bundle de test.
  • Accès à l'état réel de l'application. Vous pouvez appeler des ViewModels, SwiftData, ImageRenderer et UIWindow.drawHierarchy directement. XCUITest ne peut que taper et lire les éléments d'accessibilité.
  • Les rendus d'éléments ont besoin du processus interne de toute façon. ImageRenderer sur les vues de widget ou les composants isolés doit s'exécuter dans le processus de l'application — il n'y a pas d'équivalent XCUITest.

Comment ça marche :

  1. Un fichier MarketingCapture.swift réservé au mode DEBUG se trouve dans la cible principale de l'application
  2. Lorsqu'il est lancé avec -MarketingCapture 1, l'application prépare les données, puis un coordinateur parcourt une liste de CaptureSteps — chaque étape navigue, attend que l'interface se stabilise, prend une capture et nettoie
  3. Les PNG sont écrits dans le répertoire sandbox de l'application Documents/marketing/<locale>/
  4. Un script shell construit une fois, installe, puis boucle sur les locales en relançant avec -AppleLanguages (xx) -AppleLocale xx, en extrayant les fichiers via simctl get_app_container

Processus

Suivez ces étapes dans l'ordre. Ne sautez pas à l'avance.

Étape 1 : Recueillir les exigences

Posez ces questions à l'utilisateur une par une (ne les regroupez pas — chaque réponse peut invalider les questions suivantes) :

  1. Écrans à capturer — « Quels écrans souhaitez-vous ? Donnez-moi le chemin de navigation ou le nom de l'onglet pour chacun. » Obtenez une liste concrète, pas « les flux principaux ».
  2. Éléments isolés — « Des composants que vous souhaitez rendre indépendamment avec des fonds transparents ? (cartes de carrousel, widgets, tuiles hero, graphiques, etc.) »
  3. Locales — « Quelles locales ? (a) toutes les locales de votre Localizable.xcstrings, (b) un sous-ensemble App Store que je spécifierai, ou (c) laissez-moi vous donner une liste explicite. » Si (a), utiliser grep sur le fichier .xcstrings pour les codes de locale :
    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))"
    
  4. Appareil — « Quel simulateur ? (iPhone 17 6,1" recommandé pour les fonctionnalités de conception iOS 26) » — vérifiez que l'appareil est disponible via xcrun simctl list devices available.
  5. Apparence — « Uniquement le mode clair, uniquement le mode sombre, ou les deux ? »
  6. Données de démonstration — « Comment les données de démonstration sont-elles peuplées aujourd'hui ? (a) une nouvelle installation les intègre automatiquement, (b) il y a un bouton de débogage 'Charger les données de démonstration', (c) vous les ajoutez manuellement, (d) aucune donnée de démonstration n'existe encore. » Ensuite : « Les données existantes sont-elles suffisamment exhaustives pour que chaque écran que vous avez listé paraisse peuplé pour le marketing ? Auditez-les avec l'utilisateur. »

Étape 2 : Exploration

Avant d'écrire du code, explorez suffisamment le code source pour répondre à :

  • Le projet utilise-t-il les groupes de dossiers synchronisés Xcode (Xcode 16+, PBXFileSystemSynchronizedRootGroup) ? Si oui, les nouveaux fichiers sont automatiquement inclus dans leur cible — pas besoin de modifications du pbxproj. Vérifiez avec grep -c PBXFileSystemSynchronized <proj>.xcodeproj/project.pbxproj.
  • Quel est le modèle de navigation racine ?
    • TabView(selection:) — le plus courant. Vous avez besoin : de la liaison @State selectedTab, des indices des onglets et des onglets qui ont des NavigationStack imbriqués.
    • NavigationStack (pile unique avec un routeur) — vous avez besoin : de la liaison du chemin ou de l'objet routeur, ainsi que de l'ensemble des types NavigationLink(value:) / .navigationDestination.
    • NavigationSplitView — vous avez besoin : de la liaison de sélection de la barre latérale, de l'état de navigation de la colonne de détail.
    • Coordinateur personnalisé / hôte UIKit — vous avez besoin : de la méthode navigate(to:) du coordinateur ou d'un équivalent.
  • Comment les liens profonds sont-ils routés ? Trouvez le gestionnaire onOpenURL et l'enum/switch qui mappe les URL à l'état de navigation.
  • Où sont définis les injecteurs de données de démonstration ? Tracez le chemin du code depuis le bouton de débogage (le cas échéant) jusqu'à la fonction qui écrit réellement dans ModelContext. Si aucun injecteur n'existe, voir « Création d'un injecteur de données de démonstration » ci-dessous.
  • Les widgets vivent-ils dans une cible séparée ? Les fichiers de vue des widgets et les types d'entrée se trouvent-ils également dans la cible principale de l'application ? (Presque certainement non — ils doivent être ajoutés si vous souhaitez les rendre via ImageRenderer.)
  • L'application utilise-t-elle les Live Activities / ActivityKit ? Si oui, signalez-le comme un piège connu (voir ci-dessous).
  • L'application utilise-t-elle SwiftData + CloudKit sync (cloudKitDatabase: .automatic) ? Si oui, signalez-le comme un piège connu.
  • Une vue doit-elle être capturée dans un état non par défaut ? (par exemple un minuteur en cours de décompte, un formulaire partiellement rempli, un graphique avec des valeurs spécifiques). Si oui, chacun a besoin d'un mécanisme d'amorçage via static var (voir « Amorçage de l'état de la vue » ci-dessous).

Étape 3 : Présenter la conception à l'utilisateur

Avant d'écrire du code, résumez votre plan dans cette structure. Obtenez une approbation explicite avant de continuer :

  1. Architecture (mode de capture intégré à l'application, fichier unique, protégé par DEBUG)
  2. Liste des fichiers (chemins exacts que vous allez créer / modifier)
  3. Plan de capture écran par écran (comment chaque écran est atteint — index d'onglet, chemin de navigation, déclencheur de feuille)
  4. Justification de l'ordre de capture (quels écrans doivent venir avant d'autres — voir piège n°5)
  5. Approche de rendu des éléments (quels composants, comment ils seront encapsulés)
  6. Disposition de sortie (structure de dossiers, convention de nommage)
  7. Pièges connus pertinents pour ce projet (signalés à l'étape 2)
  8. États amorcés nécessaires (quelles vues, quelles variables statiques)

Étape 4 : Implémenter

Utilisez les modèles dans templates/ comme points de départ. Ce sont des schémas de référence, pas un échafaudage copier-coller — chaque projet a une navigation, des modèles et des vues différents. Les modèles montrent les blocs de construction ; vous les composez pour l'application cible.

Fichiers clés à produire :

  • <AppName>/Debug/MarketingCapture.swift — tout le système de capture, réservé au mode DEBUG. Contient :
    • MarketingCapture enum (analyse des arguments de lancement, assistants de sortie, capture d'écran de la fenêtre, variables d'amorçage)
    • MarketingCaptureCoordinator class (parcourt [CaptureStep] et capture chacun)
    • MarketingElementHarness enum (rendus ImageRenderer de cartes, widgets, graphiques)
  • <AppName>/ContentView.swift (ou l'endroit où se trouve la vue racine) — hook DEBUG qui intègre les données et exécute le coordinateur.
  • Toutes les vues qui nécessitent des états amorcés — hooks .onAppear protégés par DEBUG et écouteurs .onReceive de rejet.
  • scripts/capture-marketing.sh — construction + installation + boucle par locale.
  • .gitignore — ajoutez marketing/.

Étape 5 : Vérifier itérativement

Ne remettez pas le script à l'utilisateur en attendant. Exécutez-le vous-même contre un simulateur et vérifiez au moins une locale avant de déclarer la tâche terminée. Lisez les PNG de sortie avec l'outil Read pour vérifier visuellement que chaque écran montre ce que vous attendez. Les problèmes d'exécution courants sont listés dans « Pièges connus » ci-dessous.

Lorsque vous trouvez un problème, corrigez-le, réexécutez tout le script (pas seulement la locale qui échoue — les correctifs peuvent régresser les locales précédentes) et revérifiez visuellement.

Architecture : Capture par étapes

Le coordinateur pilote la capture en parcourant une liste de valeurs CaptureStep. Chaque étape est autonome : elle sait comment naviguer vers son écran, combien de temps attendre et comment nettoyer après elle.

struct CaptureStep {
    let name: String                        // nom du fichier de sortie, par exemple "01-home"
    let navigate: @MainActor () -> Void     // mettre l'application dans le bon état
    let settle: Duration                    // attendre les animations/chargements
    let cleanup: (@MainActor () -> Void)?   // nettoyer avant l'étape suivante
}

Le coordinateur est une simple boucle :

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))  // animation de nettoyage
}

Construction d'étapes pour différents modèles de navigation

Application TabView (la plus courante) :

// Changement d'onglet simple — définissez simplement l'index
CaptureStep(name: "01-home", navigate: { setTab(0) }, settle: .milliseconds(1800), cleanup: nil)

// Onglet + feuille présentée
CaptureStep(
    name: "05-timer-setup",
    navigate: {
        setTab(3)
        pendingBrewRecipe = someRecipe
    },
    settle: .milliseconds(2000),
    cleanup: {
        NotificationCenter.default.post(name: MarketingCapture.dismissSheetNotification, object: nil)
        pendingBrewRecipe = nil
    }
)

Application NavigationStack + routeur :

// Pousser une route sur la pile
CaptureStep(
    name: "02-detail",
    navigate: { router.push(.itemDetail(item)) },
    settle: .milliseconds(1800),
    cleanup: { router.popToRoot() }
)

Application NavigationSplitView :

// Sélectionner un élément de la barre latérale, puis le détail
CaptureStep(
    name: "03-detail",
    navigate: {
        sidebarSelection = .recipes
        detailSelection = recipes.first
    },
    settle: .milliseconds(1800),
    cleanup: { detailSelection = nil }
)

Ordre : la règle d'empilement

Capturez tout écran qui a besoin d'un état de navigation « propre » AVANT les écrans qui poussent sur la même pile. Les NavigationPath / @State imbriqués à l'intérieur des vues enfants ne peuvent pas être dépilés depuis le coordinateur. Ainsi :

Bon :  Étagère (liste propre) → Détail du café (pousse sur la pile de l'étagère)
Mauvais : Détail du café → Étagère (la pile a toujours le détail poussé)

Si deux écrans partagent un NavigationStack, capturez d'abord la vue de niveau racine.

Amorçage de l'état de la vue

Certains écrans doivent être capturés dans un état non par défaut spécifique — un minuteur en cours de décompte, un graphique avec des valeurs particulières, un formulaire à moitié rempli. Le modèle :

  1. Ajoutez une static var à MarketingCapture pour chaque valeur d'amorçage :

    /// Défini par le coordinateur avant de présenter la vue du minuteur.
    /// La vue lit ceci dans .onAppear pour sauter à un temps écoulé spécifique.
    static var pendingElapsedSeconds: Int?
    
    /// Défini sur true pour afficher la superposition d'évaluation sur le minuteur.
    static var pendingShowAssessment: Bool = false
    
  2. Dans la vue cible, ajoutez un .onAppear protégé par DEBUG qui lit la valeur d'amorçage :

    .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
    }
    
  3. Dans le coordinateur, définissez la variable avant de naviguer :

    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)
        }
    )
    

Création d'un injecteur de données de démonstration

Si l'application n'a pas de mécanisme de données de démonstration existant, créez-en un. Placez-le dans <AppName>/Debug/DemoDataSeeder.swift, encapsulé dans #if DEBUG.

Directives :

  • Injectez suffisamment de données pour que chaque écran capturé paraisse peuplé. Auditez la liste des écrans par rapport à l'injection.
  • Utilisez un contenu réaliste : de vrais noms de lieux, des nombres plausibles, des états variés (certains articles « en rupture de stock », d'autres « frais », certains avec des images, d'autres sans).
  • Si l'application utilise SwiftData, écrivez directement dans le ModelContext. Si Core Data, utilisez le contexte d'objet géré. Si un backend REST, injectez via la couche de cache/magasin local.
  • Rendez l'injection idempotente — vérifiez si les données existent déjà avant d'insérer. Le magasin persiste entre les relancements du simulateur, et la réinjection par locale provoque des à-coups de synchronisation CloudKit et des crashs.
  • Incluez suffisamment de variété pour remplir différents états d'interface : les états vides ne doivent PAS apparaître à moins qu'ils ne soient un écran marketing.

Forme minimale :

#if DEBUG
enum DemoDataSeeder {
    static func seedIfEmpty(in context: ModelContext) {
        let existing = (try? context.fetchCount(FetchDescriptor<Item>())) ?? 0
        guard existing == 0 else { return }

        // Articles avec des états variés
        let items = [
            Item(name: "...", status: .active, ...),
            Item(name: "...", status: .lowStock, ...),
            // ...assez pour remplir chaque écran
        ]
        items.forEach { context.insert($0) }
        try? context.save()
    }
}
#endif

Rendu d'éléments

Les éléments sont rendus via ImageRenderer à l'échelle 3x avec transparence à l'extérieur des coins arrondis.

Cartes / lignes de liste

@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: cardHeightFromContext)

        let cgImage = renderer.cgImage
        // ... découper les coins arrondis si nécessaire
    }
}

Widgets

Pour rendre les widgets, vous aurez besoin des fichiers de vue du widget et des types d'entrée dans la cible principale de l'application. Si ce n'est pas le cas, ajoutez-les là. Le rendu des widgets piloté par SwiftUI peut être effectué en créant une instance de la vue widget avec une entrée factice et en la rendant via ImageRenderer.

Graphiques

Les graphiques peuvent être rendus avec des données factices, en s'assurant que la vue du graphique est configurée avec des valeurs spécifiques. Utilisez le mécanisme d'amorçage (pendingChartData) pour injecter des données factices avant de rendre la vue du graphique.

Script de capture

Créez scripts/capture-marketing.sh :

#!/bin/bash
set -euo pipefail

DEVICE="iPhone 17"
LOCALES=("en" "fr" "de" "ja")
APP_BUNDLE_ID="com.example.app"

# Construire une fois
xcodebuild build -scheme "YourApp" -destination "platform=iOS Simulator,name=$DEVICE" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO

# Lancer le simulateur
xcrun simctl boot "$DEVICE" || true
xcrun simctl install booted "build/Debug-iphonesimulator/YourApp.app"

for locale in "${LOCALES[@]}"; do
    echo "Capturing locale: $locale"
    xcrun simctl launch booted "$APP_BUNDLE_ID" -MarketingCapture 1 -AppleLanguages "($locale)" -AppleLocale "$locale"
    sleep 10  # attendre la fin de la capture
    xcrun simctl get_app_container booted "$APP_BUNDLE_ID" data | xargs -I{} cp -R "{}/Documents/marketing/$locale" "output/$locale"
done

Pièges connus

  1. SwiftData + CloudKit Sync : Les appels répétés à context.save() lors de la préparation des données peuvent déclencher des cycles de synchronisation CloudKit, provoquant des délais d'attente et des crashs. Utilisez context.save() uniquement après avoir inséré toutes les données, ou injectez de manière conditionnelle uniquement lorsque les données sont vides. Dans le pire des cas, envisagez de désactiver CloudKit pour la construction de capture de débogage.
  2. Live Activities : Les Live Activities en cours d'exécution ne peuvent pas être facilement rejetées à partir du code de débogage. Si l'application a des activités actives, elles peuvent apparaître dans l'îlot dynamique ou sur l'écran de verrouillage sur les captures d'écran. Définissez un indicateur de débogage pour les désactiver.
  3. Pile de navigation instable : Si vous utilisez NavigationStack avec .navigationDestination, assurez-vous que la vue de détail est dépilée correctement. Sinon, la pile peut se corrompre.
  4. Animations : Les animations SwiftUI peuvent interférer avec les captures d'écran. Utilisez .animation(.none) ou désactivez les animations globalement pendant la capture.
  5. Contenu scrollé : Pour les listes ou les vues qui ne sont pas entièrement visibles, vous devrez peut-être faire défiler avant de capturer. Envisagez d'utiliser UIScrollView ou ScrollViewReader pour défiler vers le bas.
  6. Capture de feuille : L'ouverture d'une feuille peut ne pas être instantanée ; le coordinateur doit attendre que la feuille soit présentée avant de capturer.
  7. Changement de locale : Le changement de locale via les arguments de lancement fonctionne de manière fiable, mais assurez-vous que l'application gère la mise à jour de la locale sans crash. Testez chaque locale individuellement.

Skills associés