SwiftUI 커스텀 ViewModifier: 디자인 시스템을 위한 재사용 가능한 패턴

일관된 디자인 시스템을 위해 SwiftUI에서 커스텀 ViewModifier를 구축합니다. iOS 뷰를 효율적으로 스타일링하기 위한 패턴, 베스트 프랙티스, 실용적인 예시를 다룹니다.

iOS에서 재사용 가능한 디자인 시스템을 구축하기 위한 SwiftUI ViewModifier

ViewModifier는 견고하고 유지보수가 쉬운 SwiftUI 디자인 시스템의 토대를 형성합니다. 스타일과 동작을 재사용 가능한 컴포넌트에 캡슐화함으로써 시각적 일관성을 보장하고 코드 중복을 크게 줄여 줍니다. 이러한 모듈식 접근 방식은 현대적인 iOS 인터페이스를 구축하는 방식을 변화시킵니다.

이 글에서 다루는 내용

이 글에서는 효과적인 커스텀 ViewModifier를 만들기 위한 핵심 패턴을, 기본 구조부터 완전한 디자인 시스템을 위한 고급 합성까지 소개합니다.

ViewModifier의 구조

ViewModifier는 임의의 뷰에 적용 가능한 변환을 정의하는 프로토콜입니다. View 익스텐션과 달리 ViewModifier는 내부 상태를 유지하고 구성 가능한 파라미터를 받을 수 있습니다.

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

Content 패턴은 modifier가 적용되는 뷰를 나타냅니다. 이 추상화 덕분에 동일한 modifier가 모든 SwiftUI 뷰 타입에서 동작할 수 있습니다.

실제로 CardModifier 사용하기

한 번 생성하고 나면 modifier 적용은 직관적이 되며, 코드에서 자연스럽게 읽힙니다.

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

코드의 명확성은 유지보수성을 크게 향상시킵니다. 앱의 모든 카드 스타일을 변경하려면 ViewModifier에서 단 한 번의 수정만 필요합니다.

@ViewBuilder를 활용한 조건부 modifier

ViewModifier는 컨텍스트에 따라 동작을 조정하기 위해 조건부 로직을 포함할 수 있습니다. @ViewBuilder 속성을 사용하면 분기를 가진 복잡한 뷰를 만들 수 있습니다.

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

이 패턴을 사용하면 어떤 인터페이스 요소에든 선언적이고 구성 가능한 방식으로 알림 배지를 추가할 수 있습니다.

조건문의 성능

SwiftUI는 @ViewBuilder 분기를 자동으로 최적화합니다. 표시되지 않는 뷰는 렌더링되지 않으므로 복잡한 조건에서도 성능이 유지됩니다.

스타일 토큰 기반 디자인 시스템

성숙한 디자인 시스템은 토큰에 의존합니다. 토큰은 앱의 시각적 정체성을 정의하는 의미론적 상수입니다. ViewModifier는 이러한 토큰을 완벽하게 캡슐화합니다.

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

토큰 기반 ViewModifier

이러한 토큰은 앱의 표준 스타일을 정의하는 ViewModifier에 자연스럽게 통합됩니다.

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

사용법은 표현력 있고 자체 문서화되어 있어, 새로 합류한 팀원도 코드를 읽기 쉽습니다.

iOS 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

상태를 가진 인터랙티브 modifier

ViewModifier는 복잡한 인터랙티브 동작을 만들기 위해 자체 상태를 관리할 수 있습니다. 이 패턴은 애니메이션과 사용자 피드백에서 특히 강력합니다.

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

shimmer 효과를 활용한 로딩 애니메이션

내부 상태의 또 다른 예로, 로딩 플레이스홀더를 위한 shimmer 효과를 살펴보겠습니다.

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

ViewModifier 합성

ViewModifier의 진정한 힘은 합성에 있습니다. 여러 modifier를 조합하면 단순한 빌딩 블록으로부터 복잡한 스타일을 만들 수 있습니다.

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

이러한 합성을 통해 코드 중복 없이 다양한 변형을 만들 수 있습니다. 각 modifier는 독립적으로 테스트 가능하고 수정 가능한 상태로 유지됩니다.

modifier의 순서가 중요합니다

modifier의 적용 순서는 시각적 결과에 영향을 미칩니다. paddingbackground보다 먼저 오면 콘텐츠와 배경 사이에 공간이 생기지만, 반대 순서에서는 배경 주위에 padding이 추가됩니다.

Environment를 활용한 적응형 modifier

ViewModifier는 SwiftUI의 environment 값을 읽어 컨텍스트에 적응할 수 있습니다. 다크 모드, 접근 가능한 텍스트 크기, 방향 등이 그 예입니다.

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

이 modifier는 사용하는 뷰에 추가 코드를 요구하지 않고도 시스템 환경설정에 자동으로 적응합니다.

완전한 디자인 시스템 구성

구조화된 디자인 시스템에서는 발견 가능성과 유지보수성을 높이기 위해 ViewModifier를 기능별 카테고리로 묶습니다.

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

디자인 시스템 사용

이러한 구성은 자체 의도를 문서화하는 표현력 있는 코드를 만들어 냅니다.

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

ViewModifier 테스트

ViewModifier는 올바른 동작을 보장하기 위해 독립적으로 테스트할 수 있습니다.

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

ViewInspector 라이브러리를 사용하면 단위 테스트에서 SwiftUI 뷰의 내용을 검사할 수 있어, ViewModifier 검증이 한층 수월해집니다.

결론

ViewModifier는 일관되고 유지보수가 쉬운 SwiftUI 디자인 시스템을 구축하기 위한 가장 적합한 도구로 자리 잡습니다. 스타일, 애니메이션, 동작을 재사용 가능한 컴포넌트에 캡슐화하는 능력은 현대 iOS 애플리케이션의 아키텍처를 변화시킵니다.

SwiftUI 디자인 시스템 체크리스트

  • ✅ 디자인 토큰 정의(spacing, radius, typography, colors)
  • ✅ 각 스타일에 대한 원자적 ViewModifier 작성
  • ✅ 조건부 modifier에 @ViewBuilder 사용
  • ✅ 인터랙션을 위해 @State로 내부 상태 관리
  • ✅ 복잡한 스타일을 만들기 위해 modifier 합성
  • ✅ 자동 적응을 위해 environment 읽기
  • ✅ 명확한 namespace로 modifier 정리
  • ✅ 유려한 문법을 위해 View 익스텐션 제공
  • ✅ modifier 독립적으로 테스트
  • ✅ 구체적인 예시로 사용법 문서화

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

태그

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

공유

관련 기사