Lab 5.4 — Component library

Goal

Build a small SwiftUI component library as a Swift Package: a PrimaryButtonStyle, RoundedTextFieldStyle, CardModifier, Badge, and EmptyState views. Each with #Preview blocks, accessibility built in, and a README with usage examples.

By the end you’ll have practiced the design-system pattern that every iOS team at scale uses.

Time

90–120 minutes.

Prereqs

  • Xcode 16+
  • Chapter 5.8 (custom views & view modifiers)

Setup

  1. Xcode → New → Package…
  2. Name: DesignKit
  3. iOS 17, macOS 14 deployment targets in Package.swift
// swift-tools-version: 5.10
import PackageDescription

let package = Package(
    name: "DesignKit",
    platforms: [.iOS(.v17), .macOS(.v14)],
    products: [
        .library(name: "DesignKit", targets: ["DesignKit"]),
    ],
    targets: [
        .target(name: "DesignKit"),
        .testTarget(name: "DesignKitTests", dependencies: ["DesignKit"]),
    ]
)

Build

1. Theme via EnvironmentValues

Sources/DesignKit/Theme.swift:

import SwiftUI

public struct Theme: Sendable {
    public var primary: Color
    public var background: Color
    public var cardBackground: Color
    public var cornerRadius: CGFloat
    public var spacingUnit: CGFloat

    public init(
        primary: Color = .accentColor,
        background: Color = Color(.systemBackground),
        cardBackground: Color = Color(.secondarySystemBackground),
        cornerRadius: CGFloat = 12,
        spacingUnit: CGFloat = 8
    ) {
        self.primary = primary
        self.background = background
        self.cardBackground = cardBackground
        self.cornerRadius = cornerRadius
        self.spacingUnit = spacingUnit
    }

    public static let `default` = Theme()
}

extension EnvironmentValues {
    @Entry public var theme: Theme = .default
}

extension View {
    public func theme(_ theme: Theme) -> some View {
        environment(\.theme, theme)
    }
}

Note: Color(.systemBackground) is iOS-only. For cross-platform you’d use Color(uiColor:) / Color(nsColor:) conditional. Kept simple here — assume iOS.

2. PrimaryButtonStyle

Sources/DesignKit/PrimaryButtonStyle.swift:

import SwiftUI

public struct PrimaryButtonStyle: ButtonStyle {
    @Environment(\.theme) private var theme
    @Environment(\.isEnabled) private var isEnabled

    public init() {}

    public func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .font(.body.weight(.semibold))
            .foregroundStyle(isEnabled ? Color.white : Color.white.opacity(0.6))
            .padding(.vertical, theme.spacingUnit * 1.5)
            .padding(.horizontal, theme.spacingUnit * 2)
            .frame(maxWidth: .infinity)
            .background(
                RoundedRectangle(cornerRadius: theme.cornerRadius)
                    .fill(isEnabled ? theme.primary : theme.primary.opacity(0.4))
            )
            .scaleEffect(configuration.isPressed ? 0.98 : 1)
            .animation(.spring(duration: 0.2), value: configuration.isPressed)
    }
}

extension ButtonStyle where Self == PrimaryButtonStyle {
    public static var primary: PrimaryButtonStyle { .init() }
}

#Preview {
    VStack(spacing: 16) {
        Button("Primary") { }.buttonStyle(.primary)
        Button("Disabled") { }.buttonStyle(.primary).disabled(true)
    }
    .padding()
}

3. RoundedTextFieldStyle

Sources/DesignKit/RoundedTextFieldStyle.swift:

import SwiftUI

public struct RoundedTextFieldStyle: TextFieldStyle {
    @Environment(\.theme) private var theme

    public init() {}

    public func _body(configuration: TextField<Self._Label>) -> some View {
        configuration
            .padding(.vertical, theme.spacingUnit * 1.25)
            .padding(.horizontal, theme.spacingUnit * 1.5)
            .background(
                RoundedRectangle(cornerRadius: theme.cornerRadius)
                    .fill(theme.cardBackground)
            )
            .overlay(
                RoundedRectangle(cornerRadius: theme.cornerRadius)
                    .stroke(Color.secondary.opacity(0.2), lineWidth: 1)
            )
    }
}

extension TextFieldStyle where Self == RoundedTextFieldStyle {
    public static var rounded: RoundedTextFieldStyle { .init() }
}

#Preview {
    @Previewable @State var text = ""
    return VStack {
        TextField("Email", text: $text).textFieldStyle(.rounded)
    }
    .padding()
}

4. CardModifier

Sources/DesignKit/CardModifier.swift:

import SwiftUI

public struct CardModifier: ViewModifier {
    @Environment(\.theme) private var theme

    public init() {}

    public func body(content: Content) -> some View {
        content
            .padding(theme.spacingUnit * 2)
            .background(
                RoundedRectangle(cornerRadius: theme.cornerRadius)
                    .fill(theme.cardBackground)
            )
            .shadow(color: .black.opacity(0.06), radius: 8, x: 0, y: 2)
    }
}

extension View {
    public func card() -> some View {
        modifier(CardModifier())
    }
}

#Preview {
    VStack(alignment: .leading) {
        Text("Card title").font(.headline)
        Text("Card body with some longer text").font(.body).foregroundStyle(.secondary)
    }
    .card()
    .padding()
}

5. Badge

Sources/DesignKit/Badge.swift:

import SwiftUI

public struct Badge: View {
    public enum Style {
        case info, success, warning, error
    }

    let text: String
    let style: Style

    public init(_ text: String, style: Style = .info) {
        self.text = text
        self.style = style
    }

    public var body: some View {
        Text(text)
            .font(.caption2.weight(.semibold))
            .foregroundStyle(foreground)
            .padding(.horizontal, 8)
            .padding(.vertical, 3)
            .background(background, in: .capsule)
            .accessibilityLabel("\(styleLabel): \(text)")
    }

    private var foreground: Color {
        switch style {
        case .info: .blue
        case .success: .green
        case .warning: .orange
        case .error: .red
        }
    }

    private var background: Color { foreground.opacity(0.15) }

    private var styleLabel: String {
        switch style {
        case .info: "Info"
        case .success: "Success"
        case .warning: "Warning"
        case .error: "Error"
        }
    }
}

#Preview {
    HStack {
        Badge("New", style: .info)
        Badge("Live", style: .success)
        Badge("Beta", style: .warning)
        Badge("Failed", style: .error)
    }
    .padding()
}

6. EmptyState

Sources/DesignKit/EmptyState.swift:

import SwiftUI

public struct EmptyState<Action: View>: View {
    let title: String
    let message: String?
    let systemImage: String
    let action: Action

    public init(
        _ title: String,
        message: String? = nil,
        systemImage: String,
        @ViewBuilder action: () -> Action = { EmptyView() }
    ) {
        self.title = title
        self.message = message
        self.systemImage = systemImage
        self.action = action()
    }

    public var body: some View {
        VStack(spacing: 16) {
            Image(systemName: systemImage)
                .font(.system(size: 56))
                .foregroundStyle(.secondary)
            Text(title)
                .font(.title3.weight(.semibold))
            if let message {
                Text(message)
                    .font(.body)
                    .foregroundStyle(.secondary)
                    .multilineTextAlignment(.center)
            }
            action
                .padding(.top, 8)
        }
        .padding(32)
        .frame(maxWidth: 320)
        .accessibilityElement(children: .combine)
    }
}

#Preview("With action") {
    EmptyState(
        "No notes yet",
        message: "Tap below to create your first note.",
        systemImage: "note.text"
    ) {
        Button("Create note") { }.buttonStyle(.primary)
    }
}

#Preview("Without action") {
    EmptyState(
        "All caught up!",
        systemImage: "checkmark.circle"
    )
}

7. Showcase view (for development)

Sources/DesignKit/Showcase.swift:

import SwiftUI

public struct Showcase: View {
    @State private var text = ""

    public init() {}

    public var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 32) {
                Group {
                    Text("Buttons").font(.title2.bold())
                    Button("Primary action") { }.buttonStyle(.primary)
                    Button("Disabled") { }.buttonStyle(.primary).disabled(true)
                }

                Group {
                    Text("Text fields").font(.title2.bold())
                    TextField("Email", text: $text).textFieldStyle(.rounded)
                    TextField("Password", text: $text).textFieldStyle(.rounded)
                }

                Group {
                    Text("Cards").font(.title2.bold())
                    VStack(alignment: .leading) {
                        Text("A card").font(.headline)
                        Text("With some content.").foregroundStyle(.secondary)
                    }
                    .card()
                }

                Group {
                    Text("Badges").font(.title2.bold())
                    HStack {
                        Badge("New", style: .info)
                        Badge("Live", style: .success)
                        Badge("Beta", style: .warning)
                        Badge("Failed", style: .error)
                    }
                }

                Group {
                    Text("Empty state").font(.title2.bold())
                    EmptyState(
                        "Nothing here",
                        message: "Add something to get started.",
                        systemImage: "tray"
                    ) {
                        Button("Add") { }.buttonStyle(.primary)
                    }
                    .frame(maxWidth: .infinity)
                }
            }
            .padding()
        }
    }
}

#Preview {
    Showcase()
}

8. README

README.md in the package root:

# DesignKit

A small SwiftUI component library demonstrating the style/modifier/view patterns.

## Install

In your `Package.swift`:

```swift
.package(url: "https://example.com/DesignKit.git", from: "1.0.0")
```

## Usage

```swift
import DesignKit

VStack {
    TextField("Email", text: $email).textFieldStyle(.rounded)
    Button("Sign in") { signIn() }.buttonStyle(.primary)

    HStack {
        Badge("New", style: .info)
        Badge("Beta", style: .warning)
    }

    VStack {
        Text("Welcome").font(.headline)
        Text("Get started by creating an account.")
    }
    .card()
}
.theme(.default)
```

## Customization

Inject a custom theme via `.theme(_:)`:

```swift
ContentView()
    .theme(Theme(primary: .purple, cornerRadius: 4))
```

## Showcase

Run `Showcase()` in a preview or test app to see all components.

## Components

- `PrimaryButtonStyle` — primary CTA button. `Button(...).buttonStyle(.primary)`
- `RoundedTextFieldStyle` — bordered rounded text field. `TextField(...).textFieldStyle(.rounded)`
- `CardModifier` — apply via `.card()`
- `Badge` — small pill-shaped label with semantic style
- `EmptyState` — illustrated empty-state with optional action

9. Test app

Create a quick iOS app DesignKitDemo, depend on DesignKit as a local package, set ContentView to:

import SwiftUI
import DesignKit

struct ContentView: View {
    var body: some View {
        Showcase()
    }
}

Run on Simulator. All components render.

Stretch goals

  1. Dark mode polish: Verify each component in dark mode; tweak Theme to define light/dark variants.
  2. Dynamic Type sweep: Run at AX5; fix overflows in EmptyState and Card.
  3. Snapshot tests: Add SnapshotTesting library; snapshot each #Preview to PNGs in CI.
  4. More components: Avatar, ListSection, LoadingButton (with spinner state), Toast, SegmentedPicker.
  5. Animations: Add withAnimation on state changes; document animation behavior.
  6. Cross-platform: Add macOS 14 support; conditional colors for systemBackground on Mac.
  7. Documentation: Add DocC comments to every public API; build a DocC archive (xcodebuild docbuild).
  8. Versioning + release: Tag 1.0.0 in git, set up CI to test on push.

Notes & troubleshooting

  • #Preview inside a package works in Xcode 16. Click “Resume” in the canvas if it doesn’t load.
  • @Previewable (iOS 18 SDK) lets you put @State directly in #Preview blocks instead of wrapping in a helper view.
  • buttonStyle(.primary) static accessor: only works when you extend ButtonStyle with where Self == PrimaryButtonStyle. Without that extension, you’d write .buttonStyle(PrimaryButtonStyle()) — verbose.
  • EnvironmentKey (the old way) vs @Entry (Swift 6): @Entry is much less ceremony. Both work; @Entry is the future.
  • Color(.systemBackground) requires UIKit imports under the hood; cross-platform packages use #if canImport(UIKit) / #if canImport(AppKit) conditionals.
  • Public API surface: every type, init, and method you want consumers to use must be public. Forget one and the package compiles but consumers get “not accessible” errors.

Where to next

Phase 5 done — you now have a working Todo app, animated dashboard, multiplatform notes, and design-system package. Phase 6 covers Concurrency & Swift 6 — the deeper story of async/await, structured concurrency, actors, sendable, Swift 6 strict concurrency.


Phase 5 complete. Return to Summary or continue to Phase 6 once it ships.