# SwiftUI Custom ViewModifiers : patterns réutilisables pour design system > Créer des ViewModifiers SwiftUI personnalisés pour un design system cohérent. Patterns, bonnes pratiques et exemples concrets pour styliser vos vues iOS efficacement. - Published: 2026-03-01 - Updated: 2026-03-28 - Author: SharpSkill - Tags: swiftui, ios, viewmodifier, design-system, swift - Reading time: 12 min --- Les ViewModifiers constituent le fondement d'un design system SwiftUI robuste et maintenable. En encapsulant les styles et comportements dans des composants réutilisables, ils permettent de garantir une cohérence visuelle tout en réduisant drastiquement la duplication de code. Cette approche modulaire transforme la façon de construire des interfaces iOS modernes. > **Ce que couvre cet article** > > Cet article présente les patterns essentiels pour créer des ViewModifiers personnalisés efficaces, de la structure de base aux compositions avancées pour un design system complet. ## Anatomie d'un ViewModifier Un ViewModifier est un protocole qui définit une transformation applicable à n'importe quelle vue. Contrairement aux extensions de View, les ViewModifiers peuvent maintenir un état interne et accepter des paramètres configurables. ```swift // CardModifier.swift import SwiftUI // Structure de base d'un ViewModifier personnalisé struct CardModifier: ViewModifier { // Paramètres configurables var cornerRadius: CGFloat = 12 var shadowRadius: CGFloat = 4 // La méthode body applique les transformations func body(content: Content) -> some View { content .padding() .background(Color(.systemBackground)) .cornerRadius(cornerRadius) .shadow( color: .black.opacity(0.1), radius: shadowRadius, x: 0, y: 2 ) } } // Extension pour une syntaxe fluide extension View { func cardStyle( cornerRadius: CGFloat = 12, shadowRadius: CGFloat = 4 ) -> some View { modifier(CardModifier( cornerRadius: cornerRadius, shadowRadius: shadowRadius )) } } ``` Le pattern `Content` représente la vue sur laquelle le modifier s'applique. Cette abstraction permet au même modifier de fonctionner sur n'importe quel type de vue SwiftUI. ### Utilisation pratique du CardModifier Une fois le modifier créé, son application devient intuitive et se lit naturellement dans le code : ```swift // CardUsageExample.swift struct ProductCard: View { let product: Product var body: some View { VStack(alignment: .leading, spacing: 8) { // Image du produit AsyncImage(url: product.imageURL) { image in image .resizable() .aspectRatio(contentMode: .fill) } placeholder: { Rectangle() .fill(Color.gray.opacity(0.2)) } .frame(height: 150) .clipped() // Informations produit Text(product.name) .font(.headline) Text(product.price, format: .currency(code: "EUR")) .foregroundStyle(.secondary) } .cardStyle() // Application du modifier } } struct Product: Identifiable { let id: UUID let name: String let price: Decimal let imageURL: URL? } ``` La clarté du code améliore significativement la maintenabilité. Modifier le style de toutes les cartes de l'application ne nécessite qu'un changement dans le ViewModifier. ## Modifiers conditionnels avec @ViewBuilder Les ViewModifiers peuvent inclure une logique conditionnelle pour adapter leur comportement selon le contexte. L'attribut `@ViewBuilder` permet de construire des vues complexes avec des branchements. ```swift // ConditionalBadgeModifier.swift struct BadgeModifier: ViewModifier { let count: Int let color: Color let showZero: Bool init(count: Int, color: Color = .red, showZero: Bool = false) { self.count = count self.color = color self.showZero = showZero } // @ViewBuilder permet les conditions dans le body @ViewBuilder func body(content: Content) -> some View { if count > 0 || showZero { content.overlay(alignment: .topTrailing) { badgeView } } else { // Retourne la vue sans modification content } } // Vue du badge extraite pour lisibilité private var badgeView: some View { Text(count > 99 ? "99+" : "\(count)") .font(.caption2.bold()) .foregroundStyle(.white) .padding(.horizontal, 6) .padding(.vertical, 2) .background(color) .clipShape(Capsule()) .offset(x: 8, y: -8) } } extension View { func badge( count: Int, color: Color = .red, showZero: Bool = false ) -> some View { modifier(BadgeModifier( count: count, color: color, showZero: showZero )) } } ``` Ce pattern permet d'ajouter des badges de notification sur n'importe quel élément de l'interface de manière déclarative et configurable. > **Performance des conditionnels** > > SwiftUI optimise automatiquement les branches `@ViewBuilder`. La vue non affichée n'est pas rendue, ce qui préserve les performances même avec des conditions complexes. ## Design System avec tokens de style Un design system mature repose sur des tokens : des constantes sémantiques qui définissent l'identité visuelle de l'application. Les ViewModifiers encapsulent parfaitement ces tokens. ```swift // DesignTokens.swift import SwiftUI // Tokens de spacing enum Spacing { static let xs: CGFloat = 4 static let sm: CGFloat = 8 static let md: CGFloat = 16 static let lg: CGFloat = 24 static let xl: CGFloat = 32 } // Tokens de radius enum Radius { static let sm: CGFloat = 4 static let md: CGFloat = 8 static let lg: CGFloat = 16 static let full: CGFloat = 9999 } // Tokens de typographie enum Typography { static let displayLarge = Font.system(size: 34, weight: .bold) static let displayMedium = Font.system(size: 28, weight: .bold) static let titleLarge = Font.system(size: 22, weight: .semibold) static let titleMedium = Font.system(size: 17, weight: .semibold) static let bodyLarge = Font.system(size: 17, weight: .regular) static let bodyMedium = Font.system(size: 15, weight: .regular) static let caption = Font.system(size: 12, weight: .regular) } // Tokens de couleurs sémantiques enum SemanticColor { static let primary = Color("PrimaryColor") static let secondary = Color("SecondaryColor") static let success = Color.green static let warning = Color.orange static let error = Color.red static let surface = Color(.systemBackground) static let surfaceVariant = Color(.secondarySystemBackground) } ``` ### ViewModifiers basés sur les tokens Ces tokens s'intègrent naturellement dans des ViewModifiers qui définissent les styles standards de l'application : ```swift // TextStyleModifiers.swift // Modifier pour les titres de page struct PageTitleModifier: ViewModifier { func body(content: Content) -> some View { content .font(Typography.displayMedium) .foregroundStyle(Color.primary) } } // Modifier pour les titres de section struct SectionTitleModifier: ViewModifier { func body(content: Content) -> some View { content .font(Typography.titleLarge) .foregroundStyle(Color.primary) } } // Modifier pour le texte secondaire struct SecondaryTextModifier: ViewModifier { func body(content: Content) -> some View { content .font(Typography.bodyMedium) .foregroundStyle(.secondary) } } // Extensions fluides pour tous les styles texte extension View { func pageTitle() -> some View { modifier(PageTitleModifier()) } func sectionTitle() -> some View { modifier(SectionTitleModifier()) } func secondaryText() -> some View { modifier(SecondaryTextModifier()) } } ``` L'utilisation devient expressive et auto-documentée, rendant le code lisible même pour les nouveaux membres de l'équipe. ## Modifiers interactifs avec état Les ViewModifiers peuvent gérer leur propre état pour créer des comportements interactifs complexes. Ce pattern s'avère puissant pour les animations et feedbacks utilisateur. ```swift // PressableModifier.swift struct PressableModifier: ViewModifier { // État interne du modifier @State private var isPressed = false // Configuration let scale: CGFloat let opacity: CGFloat let animation: Animation init( scale: CGFloat = 0.95, opacity: CGFloat = 0.8, animation: Animation = .easeInOut(duration: 0.1) ) { self.scale = scale self.opacity = opacity self.animation = animation } func body(content: Content) -> some View { content .scaleEffect(isPressed ? scale : 1.0) .opacity(isPressed ? opacity : 1.0) .animation(animation, value: isPressed) .simultaneousGesture( DragGesture(minimumDistance: 0) .onChanged { _ in if !isPressed { isPressed = true } } .onEnded { _ in isPressed = false } ) } } extension View { func pressable( scale: CGFloat = 0.95, opacity: CGFloat = 0.8 ) -> some View { modifier(PressableModifier(scale: scale, opacity: opacity)) } } ``` ### Animation de chargement avec shimmer Un autre exemple d'état interne : l'effet shimmer pour les placeholders de chargement : ```swift // ShimmerModifier.swift struct ShimmerModifier: ViewModifier { // Animation continue @State private var phase: CGFloat = 0 let duration: Double let bounce: Bool init(duration: Double = 1.5, bounce: Bool = false) { self.duration = duration self.bounce = bounce } func body(content: Content) -> some View { content .overlay( shimmerOverlay .mask(content) ) .onAppear { withAnimation( .linear(duration: duration) .repeatForever(autoreverses: bounce) ) { phase = 1 } } } private var shimmerOverlay: some View { GeometryReader { geometry in LinearGradient( colors: [ .clear, .white.opacity(0.5), .clear ], startPoint: .leading, endPoint: .trailing ) .frame(width: geometry.size.width * 2) .offset(x: -geometry.size.width + (geometry.size.width * 2 * phase)) } } } extension View { func shimmer(duration: Double = 1.5) -> some View { modifier(ShimmerModifier(duration: duration)) } } // Utilisation pour les squelettes de chargement struct SkeletonCard: View { var body: some View { VStack(alignment: .leading, spacing: Spacing.sm) { RoundedRectangle(cornerRadius: Radius.md) .fill(Color.gray.opacity(0.3)) .frame(height: 120) RoundedRectangle(cornerRadius: Radius.sm) .fill(Color.gray.opacity(0.3)) .frame(height: 20) RoundedRectangle(cornerRadius: Radius.sm) .fill(Color.gray.opacity(0.3)) .frame(width: 150, height: 16) } .shimmer() } } ``` ## Composition de ViewModifiers La vraie puissance des ViewModifiers réside dans leur composition. Plusieurs modifiers peuvent se combiner pour créer des styles complexes à partir de briques simples. ```swift // ComposedModifiers.swift // Modifier de base pour les boutons primaires struct PrimaryButtonStyleModifier: ViewModifier { let isEnabled: Bool func body(content: Content) -> some View { content .font(Typography.bodyLarge.weight(.semibold)) .foregroundStyle(.white) .frame(maxWidth: .infinity) .padding(.vertical, Spacing.md) .background(isEnabled ? SemanticColor.primary : Color.gray) .cornerRadius(Radius.md) } } // Modifier pour boutons secondaires (outline) struct SecondaryButtonStyleModifier: ViewModifier { let isEnabled: Bool func body(content: Content) -> some View { content .font(Typography.bodyLarge.weight(.semibold)) .foregroundStyle(isEnabled ? SemanticColor.primary : .gray) .frame(maxWidth: .infinity) .padding(.vertical, Spacing.md) .background(Color.clear) .overlay( RoundedRectangle(cornerRadius: Radius.md) .stroke( isEnabled ? SemanticColor.primary : Color.gray, lineWidth: 2 ) ) } } // Extension combinant style et interaction extension View { func primaryButton(isEnabled: Bool = true) -> some View { self .modifier(PrimaryButtonStyleModifier(isEnabled: isEnabled)) .pressable(scale: isEnabled ? 0.98 : 1.0) .allowsHitTesting(isEnabled) } func secondaryButton(isEnabled: Bool = true) -> some View { self .modifier(SecondaryButtonStyleModifier(isEnabled: isEnabled)) .pressable(scale: isEnabled ? 0.98 : 1.0) .allowsHitTesting(isEnabled) } } ``` Cette composition permet de créer des variations sans duplication de code. Chaque modifier reste testable et modifiable indépendamment. > **Ordre des modifiers** > > L'ordre d'application des modifiers impacte le résultat visuel. `padding` avant `background` crée un espace entre le contenu et le fond, tandis que l'inverse ajoute le padding autour du fond. ## Modifiers adaptatifs avec Environment Les ViewModifiers peuvent lire les valeurs d'environnement SwiftUI pour s'adapter au contexte : thème sombre, taille de texte accessible, orientation, etc. ```swift // AdaptiveCardModifier.swift struct AdaptiveCardModifier: ViewModifier { // Lecture des valeurs d'environnement @Environment(\.colorScheme) private var colorScheme @Environment(\.dynamicTypeSize) private var dynamicTypeSize @Environment(\.horizontalSizeClass) private var horizontalSizeClass func body(content: Content) -> some View { content .padding(adaptivePadding) .background(backgroundColor) .cornerRadius(Radius.lg) .shadow( color: shadowColor, radius: shadowRadius, x: 0, y: shadowOffset ) } // Padding adapté à la taille de texte private var adaptivePadding: CGFloat { switch dynamicTypeSize { case .xSmall, .small, .medium: return Spacing.md case .large, .xLarge: return Spacing.lg default: return Spacing.xl } } // Couleur de fond selon le thème private var backgroundColor: Color { colorScheme == .dark ? Color(.secondarySystemBackground) : Color(.systemBackground) } // Ombre plus subtile en mode sombre private var shadowColor: Color { colorScheme == .dark ? .clear : .black.opacity(0.1) } private var shadowRadius: CGFloat { colorScheme == .dark ? 0 : 8 } private var shadowOffset: CGFloat { colorScheme == .dark ? 0 : 4 } } extension View { func adaptiveCard() -> some View { modifier(AdaptiveCardModifier()) } } ``` Ce modifier s'adapte automatiquement aux préférences système sans nécessiter de code supplémentaire dans les vues qui l'utilisent. ## Organisation d'un design system complet Un design system structuré regroupe les ViewModifiers par catégorie fonctionnelle pour faciliter la découverte et la maintenance : ```swift // DesignSystem.swift // MARK: - Namespace pour le Design System enum DS { // MARK: Text Styles enum Text { static func title(_ content: some View) -> some View { content.modifier(PageTitleModifier()) } static func section(_ content: some View) -> some View { content.modifier(SectionTitleModifier()) } static func body(_ content: some View) -> some View { content.font(Typography.bodyLarge) } static func caption(_ content: some View) -> some View { content.modifier(SecondaryTextModifier()) } } // MARK: Container Styles enum Container { static func card(_ content: some View) -> some View { content.cardStyle() } static func adaptiveCard(_ content: some View) -> some View { content.adaptiveCard() } } // MARK: Button Styles enum Button { static func primary(_ content: some View, enabled: Bool = true) -> some View { content.primaryButton(isEnabled: enabled) } static func secondary(_ content: some View, enabled: Bool = true) -> some View { content.secondaryButton(isEnabled: enabled) } } // MARK: Effects enum Effect { static func shimmer(_ content: some View) -> some View { content.shimmer() } static func pressable(_ content: some View) -> some View { content.pressable() } } } ``` ### Utilisation du Design System Cette organisation produit un code expressif qui documente son intention : ```swift // DesignSystemUsage.swift struct ProfileScreen: View { let user: User @State private var isLoading = false var body: some View { ScrollView { VStack(spacing: Spacing.lg) { // Header avec style de titre Text("Profil") .pageTitle() // Carte utilisateur UserCard(user: user) .adaptiveCard() // Section paramètres VStack(alignment: .leading, spacing: Spacing.md) { Text("Paramètres") .sectionTitle() SettingsRow(title: "Notifications") SettingsRow(title: "Confidentialité") SettingsRow(title: "Apparence") } .adaptiveCard() // Bouton de déconnexion Button("Déconnexion") { // Action } .secondaryButton() } .padding(Spacing.md) } } } struct UserCard: View { let user: User var body: some View { HStack(spacing: Spacing.md) { AsyncImage(url: user.avatarURL) { image in image .resizable() .aspectRatio(contentMode: .fill) } placeholder: { Circle() .fill(Color.gray.opacity(0.3)) .shimmer() } .frame(width: 60, height: 60) .clipShape(Circle()) VStack(alignment: .leading, spacing: Spacing.xs) { Text(user.name) .font(Typography.titleMedium) Text(user.email) .secondaryText() } Spacer() } } } ``` ## Tests des ViewModifiers Les ViewModifiers peuvent être testés indépendamment pour garantir leur bon fonctionnement : ```swift // ViewModifierTests.swift import XCTest import SwiftUI final class ViewModifierTests: XCTestCase { func testCardModifierAppliesCorrectCornerRadius() { // Arrange let modifier = CardModifier(cornerRadius: 20, shadowRadius: 4) // Assert XCTAssertEqual(modifier.cornerRadius, 20) } func testBadgeModifierHidesWhenCountIsZero() { // Le badge ne s'affiche pas si count = 0 et showZero = false let modifier = BadgeModifier(count: 0, showZero: false) // Vérifier comportement via snapshot tests ou UI tests } func testPressableModifierInitialState() { // L'état initial doit être non pressé // Testable via ViewInspector ou UI tests } } ``` > **ViewInspector pour les tests** > > La bibliothèque ViewInspector permet d'inspecter le contenu des vues SwiftUI dans les tests unitaires, facilitant la validation des ViewModifiers. ## Conclusion Les ViewModifiers constituent l'outil privilégié pour construire un design system SwiftUI cohérent et maintenable. Leur capacité à encapsuler styles, animations et comportements dans des composants réutilisables transforme l'architecture des applications iOS modernes. ### Checklist design system SwiftUI - ✅ Définir des tokens de design (spacing, radius, typography, colors) - ✅ Créer des ViewModifiers atomiques pour chaque style - ✅ Utiliser `@ViewBuilder` pour les modifiers conditionnels - ✅ Gérer l'état interne avec `@State` pour les interactions - ✅ Composer les modifiers pour créer des styles complexes - ✅ Lire l'environnement pour l'adaptation automatique - ✅ Organiser les modifiers dans un namespace clair - ✅ Fournir des extensions View pour une syntaxe fluide - ✅ Tester les modifiers indépendamment - ✅ Documenter l'usage avec des exemples concrets --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/fr/blog/ios/swiftui-viewmodifiers-patterns-design-system