ViewModifier แบบกำหนดเองใน SwiftUI: รูปแบบที่นำกลับมาใช้ใหม่ได้สำหรับ Design System

สร้าง ViewModifier แบบกำหนดเองใน SwiftUI สำหรับ design system ที่สอดคล้องกัน รูปแบบ แนวทางปฏิบัติที่ดีที่สุด และตัวอย่างที่ใช้งานได้จริงสำหรับการจัดสไตล์ view ของ iOS อย่างมีประสิทธิภาพ

ViewModifier ของ SwiftUI สำหรับสร้าง design system ที่นำกลับมาใช้ใหม่ได้ใน iOS

ViewModifier เป็นรากฐานของ design system SwiftUI ที่แข็งแกร่งและบำรุงรักษาได้ง่าย ด้วยการห่อหุ้มสไตล์และพฤติกรรมไว้ในคอมโพเนนต์ที่นำกลับมาใช้ใหม่ได้ ViewModifier จึงรับประกันความสอดคล้องเชิงทัศนะและลดการซ้ำซ้อนของโค้ดได้อย่างมาก แนวทางแบบโมดูลาร์นี้เปลี่ยนแปลงวิธีการสร้างอินเทอร์เฟซ iOS สมัยใหม่

สิ่งที่บทความนี้ครอบคลุม

บทความนี้นำเสนอรูปแบบที่จำเป็นสำหรับการสร้าง ViewModifier แบบกำหนดเองที่มีประสิทธิภาพ ตั้งแต่โครงสร้างพื้นฐานไปจนถึงการประกอบขั้นสูงสำหรับ design system ที่สมบูรณ์

กายวิภาคของ ViewModifier

ViewModifier คือ protocol ที่นิยามการแปลงที่สามารถนำไปใช้กับ view ใดก็ได้ ซึ่งแตกต่างจาก extension ของ 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 แสดงถึง view ที่ modifier ถูกนำไปใช้ การทำให้เป็นนามธรรมนี้ทำให้ modifier เดียวกันสามารถทำงานกับ view 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

Modifier แบบมีเงื่อนไขด้วย @ViewBuilder

ViewModifier สามารถรวมตรรกะแบบมีเงื่อนไขเพื่อปรับพฤติกรรมตามบริบท แอตทริบิวต์ @ViewBuilder ช่วยให้สร้าง view ที่ซับซ้อนพร้อมการแยกสาขาได้

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 ให้เหมาะสมโดยอัตโนมัติ View ที่ไม่ได้แสดงผลจะไม่ถูกเรนเดอร์ จึงรักษาประสิทธิภาพไว้ได้แม้ในเงื่อนไขที่ซับซ้อน

Design System ด้วยโทเคนสไตล์

Design system ที่สมบูรณ์อาศัยโทเคน: ค่าคงที่เชิงความหมายที่นิยามอัตลักษณ์ทางทัศนะของแอปพลิเคชัน 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 สำหรับ placeholder ในการโหลด:

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 ไปใช้ส่งผลต่อผลลัพธ์เชิงทัศนะ padding ก่อน background จะสร้างพื้นที่ระหว่างเนื้อหาและพื้นหลัง ในขณะที่ลำดับตรงกันข้ามจะเพิ่ม padding รอบพื้นหลัง

Modifier ปรับตัวด้วย Environment

ViewModifier สามารถอ่านค่า environment ของ SwiftUI เพื่อปรับให้เข้ากับบริบท: โหมดมืด ขนาดข้อความที่เข้าถึงได้ การวางแนว และอื่นๆ

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 นี้ปรับตัวเองให้เข้ากับการตั้งค่าระบบโดยอัตโนมัติโดยไม่ต้องเพิ่มโค้ดใน view ที่ใช้งาน

การจัดระเบียบ Design System ที่สมบูรณ์

Design system ที่มีโครงสร้างจะจัดกลุ่ม 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()
        }
    }
}

การใช้ Design System

การจัดระเบียบนี้สร้างโค้ดที่แสดงออกได้ดีและบันทึกเจตนาของตนเอง:

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 ช่วยให้สามารถตรวจสอบเนื้อหาของ view SwiftUI ในการทดสอบหน่วย ทำให้การตรวจสอบ ViewModifier ง่ายขึ้น

บทสรุป

ViewModifier ก้าวขึ้นมาเป็นเครื่องมือที่ได้รับความนิยมในการสร้าง design system SwiftUI ที่สอดคล้องและบำรุงรักษาได้ ความสามารถในการห่อหุ้มสไตล์ แอนิเมชัน และพฤติกรรมไว้ในคอมโพเนนต์ที่นำกลับมาใช้ใหม่ได้นั้นเปลี่ยนแปลงสถาปัตยกรรมของแอป iOS สมัยใหม่

รายการตรวจสอบ Design System SwiftUI

  • ✅ กำหนดโทเคนการออกแบบ (spacing, radius, typography, colors)
  • ✅ สร้าง ViewModifier แบบอะตอมิกสำหรับแต่ละสไตล์
  • ✅ ใช้ @ViewBuilder สำหรับ modifier แบบมีเงื่อนไข
  • ✅ จัดการสถานะภายในด้วย @State สำหรับการโต้ตอบ
  • ✅ ประกอบ modifier เพื่อสร้างสไตล์ที่ซับซ้อน
  • ✅ อ่าน environment เพื่อการปรับตัวอัตโนมัติ
  • ✅ จัดระเบียบ modifier ใน namespace ที่ชัดเจน
  • ✅ จัดเตรียม extension ของ View สำหรับไวยากรณ์ที่ลื่นไหล
  • ✅ ทดสอบ modifier อย่างอิสระ
  • ✅ บันทึกการใช้งานด้วยตัวอย่างที่เป็นรูปธรรม

เริ่มฝึกซ้อมเลย!

ทดสอบความรู้ของคุณด้วยตัวจำลองสัมภาษณ์และแบบทดสอบเทคนิคครับ

แท็ก

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

แชร์

บทความที่เกี่ยวข้อง