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
- Xcode → New → Package…
- Name:
DesignKit - 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
- Dark mode polish: Verify each component in dark mode; tweak
Themeto define light/dark variants. - Dynamic Type sweep: Run at AX5; fix overflows in
EmptyStateandCard. - Snapshot tests: Add
SnapshotTestinglibrary; snapshot each#Previewto PNGs in CI. - More components:
Avatar,ListSection,LoadingButton(with spinner state),Toast,SegmentedPicker. - Animations: Add
withAnimationon state changes; document animation behavior. - Cross-platform: Add
macOS 14support; conditional colors forsystemBackgroundon Mac. - Documentation: Add DocC comments to every public API; build a DocC archive (
xcodebuild docbuild). - Versioning + release: Tag
1.0.0in git, set up CI to test on push.
Notes & troubleshooting
#Previewinside a package works in Xcode 16. Click “Resume” in the canvas if it doesn’t load.@Previewable(iOS 18 SDK) lets you put@Statedirectly in#Previewblocks instead of wrapping in a helper view.buttonStyle(.primary)static accessor: only works when you extendButtonStylewithwhere Self == PrimaryButtonStyle. Without that extension, you’d write.buttonStyle(PrimaryButtonStyle())— verbose.EnvironmentKey(the old way) vs@Entry(Swift 6):@Entryis much less ceremony. Both work;@Entryis 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.