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.

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.
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.
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 :
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.
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.
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.
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 :
// 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.
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 :
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.
// 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.
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.
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 :
// 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 :
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 :
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
}
}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
@ViewBuilderpour les modifiers conditionnels - ✅ Gérer l'état interne avec
@Statepour 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
Partager
Articles similaires

SwiftUI Performance : optimiser LazyVStack et listes complexes
Techniques d'optimisation pour LazyVStack et listes SwiftUI. Réduire la consommation mémoire, améliorer le scrolling et éviter les pièges de performance courants.

SwiftUI @Observable vs @State : quand utiliser quoi en 2026
Comprendre les différences entre @Observable et @State en SwiftUI pour choisir le bon outil de gestion d'état selon le contexte de votre application iOS.

SwiftUI : Construire des interfaces modernes pour iOS
Apprenez à créer des interfaces utilisateur modernes avec SwiftUI : syntaxe déclarative, composants, animations et bonnes pratiques pour iOS 18.