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.

SwiftUI ViewModifiers pour créer un design system réutilisable en iOS

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.

CardModifier.swiftswift
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 :

CardUsageExample.swiftswift
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.

ConditionalBadgeModifier.swiftswift
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.

DesignTokens.swiftswift
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 :

TextStyleModifiers.swiftswift
// 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.

Prêt à réussir tes entretiens iOS ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

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.

PressableModifier.swiftswift
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 :

ShimmerModifier.swiftswift
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.

ComposedModifiers.swiftswift
// 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.

AdaptiveCardModifier.swiftswift
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 :

DesignSystem.swiftswift
// 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 :

DesignSystemUsage.swiftswift
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 :

ViewModifierTests.swiftswift
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

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Tags

#swiftui
#ios
#viewmodifier
#design-system
#swift

Partager

Articles similaires