Niestandardowe ViewModifiers w SwiftUI: wzorce wielokrotnego użytku dla design systemów

Buduj niestandardowe ViewModifiers w SwiftUI dla spójnego design systemu. Wzorce, najlepsze praktyki i praktyczne przykłady efektywnego stylowania widoków iOS.

ViewModifiers SwiftUI do budowy design systemów wielokrotnego użytku w iOS

ViewModifiers stanowią fundament solidnego i łatwego w utrzymaniu design systemu w SwiftUI. Poprzez enkapsulację stylów i zachowań w komponentach wielokrotnego użytku zapewniają spójność wizualną i drastycznie redukują duplikację kodu. To modułowe podejście zmienia sposób, w jaki budowane są nowoczesne interfejsy iOS.

Co obejmuje ten artykuł

Artykuł prezentuje podstawowe wzorce tworzenia skutecznych niestandardowych ViewModifiers, od podstawowej struktury po zaawansowane kompozycje pełnego design systemu.

Anatomia ViewModifiera

ViewModifier to protokół definiujący transformację, którą można zastosować do dowolnego widoku. W przeciwieństwie do rozszerzeń View, ViewModifiers mogą utrzymywać wewnętrzny stan i przyjmować konfigurowalne parametry.

CardModifier.swiftswift
import SwiftUI

// Basic structure of a custom ViewModifier
struct CardModifier: ViewModifier {
    // Configurable parameters
    var cornerRadius: CGFloat = 12
    var shadowRadius: CGFloat = 4

    // The body method applies 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 for fluent syntax
extension View {
    func cardStyle(
        cornerRadius: CGFloat = 12,
        shadowRadius: CGFloat = 4
    ) -> some View {
        modifier(CardModifier(
            cornerRadius: cornerRadius,
            shadowRadius: shadowRadius
        ))
    }
}

Wzorzec Content reprezentuje widok, do którego stosowany jest modifier. Ta abstrakcja pozwala temu samemu modifierowi działać na dowolnym typie widoku SwiftUI.

Użycie CardModifiera w praktyce

Po utworzeniu modifiera jego stosowanie staje się intuicyjne i naturalnie czytelne w kodzie:

CardUsageExample.swiftswift
struct ProductCard: View {
    let product: Product

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            // Product image
            AsyncImage(url: product.imageURL) { image in
                image
                    .resizable()
                    .aspectRatio(contentMode: .fill)
            } placeholder: {
                Rectangle()
                    .fill(Color.gray.opacity(0.2))
            }
            .frame(height: 150)
            .clipped()

            // Product information
            Text(product.name)
                .font(.headline)

            Text(product.price, format: .currency(code: "USD"))
                .foregroundStyle(.secondary)
        }
        .cardStyle() // Applying the modifier
    }
}

struct Product: Identifiable {
    let id: UUID
    let name: String
    let price: Decimal
    let imageURL: URL?
}

Klarowność kodu znacznie poprawia łatwość utrzymania. Zmiana stylu wszystkich kart w aplikacji wymaga tylko jednej modyfikacji w ViewModifierze.

Modifiers warunkowe z @ViewBuilder

ViewModifiers mogą zawierać logikę warunkową, aby dostosować swoje zachowanie do kontekstu. Atrybut @ViewBuilder umożliwia budowanie złożonych widoków z rozgałęzieniami.

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 enables conditions in the body
    @ViewBuilder
    func body(content: Content) -> some View {
        if count > 0 || showZero {
            content.overlay(alignment: .topTrailing) {
                badgeView
            }
        } else {
            // Returns the view without modification
            content
        }
    }

    // Badge view extracted for readability
    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
        ))
    }
}

Ten wzorzec pozwala dodawać badge'e powiadomień do dowolnego elementu interfejsu w sposób deklaratywny i konfigurowalny.

Wydajność warunków

SwiftUI automatycznie optymalizuje gałęzie @ViewBuilder. Niewyświetlany widok nie jest renderowany, co zachowuje wydajność nawet przy złożonych warunkach.

Design system z tokenami stylu

Dojrzały design system opiera się na tokenach: semantycznych stałych, które definiują tożsamość wizualną aplikacji. ViewModifiers idealnie enkapsulują te tokeny.

DesignTokens.swiftswift
import SwiftUI

// Spacing tokens
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
}

// Radius tokens
enum Radius {
    static let sm: CGFloat = 4
    static let md: CGFloat = 8
    static let lg: CGFloat = 16
    static let full: CGFloat = 9999
}

// Typography tokens
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)
}

// Semantic color tokens
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 oparte na tokenach

Te tokeny naturalnie integrują się z ViewModifiers definiującymi standardowe style aplikacji:

TextStyleModifiers.swiftswift
// Modifier for page titles
struct PageTitleModifier: ViewModifier {
    func body(content: Content) -> some View {
        content
            .font(Typography.displayMedium)
            .foregroundStyle(Color.primary)
    }
}

// Modifier for section titles
struct SectionTitleModifier: ViewModifier {
    func body(content: Content) -> some View {
        content
            .font(Typography.titleLarge)
            .foregroundStyle(Color.primary)
    }
}

// Modifier for secondary text
struct SecondaryTextModifier: ViewModifier {
    func body(content: Content) -> some View {
        content
            .font(Typography.bodyMedium)
            .foregroundStyle(.secondary)
    }
}

// Fluent extensions for all text styles
extension View {
    func pageTitle() -> some View {
        modifier(PageTitleModifier())
    }

    func sectionTitle() -> some View {
        modifier(SectionTitleModifier())
    }

    func secondaryText() -> some View {
        modifier(SecondaryTextModifier())
    }
}

Użycie staje się ekspresyjne i samodokumentujące, co czyni kod czytelnym nawet dla nowych członków zespołu.

Gotowy na rozmowy o iOS?

Ćwicz z naszymi interaktywnymi symulatorami, flashcards i testami technicznymi.

Modifiers interaktywne ze stanem

ViewModifiers mogą zarządzać własnym stanem, aby tworzyć złożone zachowania interaktywne. Ten wzorzec okazuje się potężny w przypadku animacji i informacji zwrotnej dla użytkownika.

PressableModifier.swiftswift
struct PressableModifier: ViewModifier {
    // Internal modifier state
    @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))
    }
}

Animacja ładowania z efektem shimmer

Kolejny przykład wewnętrznego stanu: efekt shimmer dla placeholderów ładowania:

ShimmerModifier.swiftswift
struct ShimmerModifier: ViewModifier {
    // Continuous animation
    @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))
    }
}

// Usage for loading skeletons
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()
    }
}

Kompozycja ViewModifiers

Prawdziwa moc ViewModifiers tkwi w ich kompozycji. Wiele modifiers można łączyć, by tworzyć złożone style z prostych bloków.

ComposedModifiers.swiftswift
// Base modifier for primary buttons
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 for secondary buttons (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 combining style and 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)
    }
}

Taka kompozycja umożliwia tworzenie wariantów bez duplikacji kodu. Każdy modifier pozostaje niezależnie testowalny i modyfikowalny.

Kolejność modifiers ma znaczenie

Kolejność stosowania modifiers wpływa na rezultat wizualny. padding przed background tworzy odstęp między zawartością a tłem, podczas gdy odwrotna kolejność dodaje padding wokół tła.

Modifiers adaptacyjne z Environment

ViewModifiers mogą odczytywać wartości środowiska SwiftUI, aby dostosować się do kontekstu: tryb ciemny, dostępny rozmiar tekstu, orientacja i więcej.

AdaptiveCardModifier.swiftswift
struct AdaptiveCardModifier: ViewModifier {
    // Reading environment values
    @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 adapted to text size
    private var adaptivePadding: CGFloat {
        switch dynamicTypeSize {
        case .xSmall, .small, .medium:
            return Spacing.md
        case .large, .xLarge:
            return Spacing.lg
        default:
            return Spacing.xl
        }
    }

    // Background color based on theme
    private var backgroundColor: Color {
        colorScheme == .dark
            ? Color(.secondarySystemBackground)
            : Color(.systemBackground)
    }

    // More subtle shadow in dark mode
    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())
    }
}

Ten modifier automatycznie dostosowuje się do preferencji systemowych bez konieczności dodawania kodu w widokach, które go używają.

Organizacja kompletnego design systemu

Ustrukturyzowany design system grupuje ViewModifiers według kategorii funkcjonalnych, aby ułatwić wyszukiwanie i utrzymanie:

DesignSystem.swiftswift
// MARK: - Namespace for the 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()
        }
    }
}

Korzystanie z design systemu

Taka organizacja produkuje ekspresyjny kod, który dokumentuje własną intencję:

DesignSystemUsage.swiftswift
struct ProfileScreen: View {
    let user: User
    @State private var isLoading = false

    var body: some View {
        ScrollView {
            VStack(spacing: Spacing.lg) {
                // Header with title style
                Text("Profile")
                    .pageTitle()

                // User card
                UserCard(user: user)
                    .adaptiveCard()

                // Settings section
                VStack(alignment: .leading, spacing: Spacing.md) {
                    Text("Settings")
                        .sectionTitle()

                    SettingsRow(title: "Notifications")
                    SettingsRow(title: "Privacy")
                    SettingsRow(title: "Appearance")
                }
                .adaptiveCard()

                // Logout button
                Button("Log Out") {
                    // 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()
        }
    }
}

Testowanie ViewModifiers

ViewModifiers można testować niezależnie, aby zapewnić poprawne zachowanie:

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() {
        // Badge should not display if count = 0 and showZero = false
        let modifier = BadgeModifier(count: 0, showZero: false)

        // Verify behavior via snapshot tests or UI tests
    }

    func testPressableModifierInitialState() {
        // Initial state should be not pressed
        // Testable via ViewInspector or UI tests
    }
}
ViewInspector do testów

Biblioteka ViewInspector pozwala inspekcjonować zawartość widoków SwiftUI w testach jednostkowych, ułatwiając walidację ViewModifiers.

Podsumowanie

ViewModifiers narzucają się jako preferowane narzędzie do budowania spójnego i łatwego w utrzymaniu design systemu w SwiftUI. Ich zdolność do enkapsulacji stylów, animacji i zachowań w komponentach wielokrotnego użytku zmienia architekturę nowoczesnych aplikacji iOS.

Lista kontrolna design systemu SwiftUI

  • ✅ Zdefiniować tokeny designu (spacing, radius, typography, colors)
  • ✅ Stworzyć atomowe ViewModifiers dla każdego stylu
  • ✅ Używać @ViewBuilder dla modifiers warunkowych
  • ✅ Zarządzać wewnętrznym stanem z @State dla interakcji
  • ✅ Komponować modifiers, by tworzyć złożone style
  • ✅ Czytać environment dla automatycznej adaptacji
  • ✅ Organizować modifiers w przejrzystym namespace
  • ✅ Dostarczać rozszerzenia View dla płynnej składni
  • ✅ Testować modifiers niezależnie
  • ✅ Dokumentować użycie konkretnymi przykładami

Zacznij ćwiczyć!

Sprawdź swoją wiedzę z naszymi symulatorami rozmów i testami technicznymi.

Tagi

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

Udostępnij

Powiązane artykuły