5.8 — Custom views & ViewModifiers

Opening scenario

Your app has 47 screens. The “primary action” button appears on 38 of them. Today it’s defined ad-hoc on each screen — some are Button { ... } .foregroundStyle(.white) .frame(maxWidth: .infinity) .padding() .background(.blue) .clipShape(...), others use slightly different paddings or corner radii. Design ships a new brand: rounded corners 8 → 12, padding 12 → 14, color blue → indigo. You have to find and update 38 places. Some you’ll miss. Some QA flags.

This is what ButtonStyle, ViewModifier, and reusable components fix. SwiftUI’s composition story is excellent: you can build a small palette of primitives once, and screens become declarative compositions of those primitives. When the brand changes, you change the primitive.

ToolUse for
Custom ViewReusable UI components (cards, headers, badges)
ViewModifierReusable groups of modifiers (card styling, headers)
ButtonStyle / PrimitiveButtonStyleCustomizing every Button in a subtree
LabelStyle, MenuStyle, etc.Customizing other system controls
TextFieldStyleCustom text-input styling
EnvironmentKeyCustom environment values for theming
#Preview macroPreview variants in Xcode

Concept → Why → How → Code

Custom View — your first abstraction

struct PrimaryButton: View {
    let title: String
    let action: () -> Void

    var body: some View {
        Button(action: action) {
            Text(title)
                .font(.headline)
                .foregroundStyle(.white)
                .frame(maxWidth: .infinity)
                .padding()
                .background(Color.accentColor, in: .rect(cornerRadius: 12))
        }
    }
}

// Usage
PrimaryButton(title: "Continue") { goNext() }

Pros: simple, type-safe, no surprises. Cons: every variation needs a new view or initializer parameters. Doesn’t compose well with other modifiers (you can’t say PrimaryButton(...).destructive).

Use for: composite components that are conceptually one thing (cards, headers, badges, empty states).

ButtonStyle — restyle every button

struct PrimaryButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .font(.headline)
            .foregroundStyle(.white)
            .frame(maxWidth: .infinity)
            .padding()
            .background(Color.accentColor, in: .rect(cornerRadius: 12))
            .opacity(configuration.isPressed ? 0.7 : 1.0)
            .scaleEffect(configuration.isPressed ? 0.97 : 1.0)
            .animation(.spring(duration: 0.2), value: configuration.isPressed)
    }
}

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

// Usage
Button("Continue") { goNext() }
    .buttonStyle(.primary)

ButtonStyle is the right choice for buttons because:

  • Preserves the semantics of Button (accessibility, action, focus)
  • Gives you configuration.isPressed for free
  • Composes with other view modifiers (.disabled, .tint, .controlSize)
  • Cascades — apply once at a container, and all child buttons restyle:
VStack {
    Button("Save") { ... }
    Button("Cancel") { ... }
}
.buttonStyle(.primary)   // applies to both

PrimitiveButtonStyle lets you change the trigger gesture (long-press, double-tap). Rarely needed.

LabelStyle, MenuStyle, ToggleStyle, etc.

Same pattern for other controls:

struct BadgeLabelStyle: LabelStyle {
    func makeBody(configuration: Configuration) -> some View {
        HStack(spacing: 4) {
            configuration.icon
                .foregroundStyle(.tint)
            configuration.title
                .font(.caption)
        }
        .padding(.horizontal, 8)
        .padding(.vertical, 4)
        .background(.tint.opacity(0.15), in: .capsule)
    }
}

// Usage
Label("3 unread", systemImage: "bell")
    .labelStyle(BadgeLabelStyle())
    .tint(.orange)

Similar protocols: ToggleStyle, PickerStyle, MenuStyle, ProgressViewStyle, GaugeStyle, DatePickerStyle, NavigationSplitViewStyle. All follow make(...) -> some View with a Configuration.

TextFieldStyle — custom text inputs

struct RoundedTextFieldStyle: TextFieldStyle {
    func _body(configuration: TextField<Self._Label>) -> some View {
        configuration
            .padding(12)
            .background(Color(uiColor: .secondarySystemBackground))
            .clipShape(.rect(cornerRadius: 8))
    }
}

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

// Usage
TextField("Email", text: $email)
    .textFieldStyle(.rounded)

(TextFieldStyle uses an underscored protocol member by historical accident — it works.)

ViewModifier — reusable modifier chains

When a sequence of modifiers should be reused but it’s not a button/control:

struct CardModifier: ViewModifier {
    var padding: CGFloat = 16
    var cornerRadius: CGFloat = 12

    func body(content: Content) -> some View {
        content
            .padding(padding)
            .background(.background)
            .clipShape(.rect(cornerRadius: cornerRadius))
            .shadow(color: .black.opacity(0.1), radius: 8, y: 2)
    }
}

extension View {
    func card(padding: CGFloat = 16, cornerRadius: CGFloat = 12) -> some View {
        modifier(CardModifier(padding: padding, cornerRadius: cornerRadius))
    }
}

// Usage
VStack {
    Text("Hello")
    Text("World")
}
.card()
  • ViewModifier is a struct with a body(content:) returning some View
  • Provide an extension View helper for ergonomic call sites
  • Same reusability win as a function, but participates in SwiftUI’s diffing

Environment-based theming

For values that propagate through the view tree (theme, currency, locale):

struct AppTheme {
    var primaryColor: Color = .indigo
    var cornerRadius: CGFloat = 12
    var titleFont: Font = .system(.title, design: .rounded, weight: .bold)
}

private struct AppThemeKey: EnvironmentKey {
    static let defaultValue = AppTheme()
}

extension EnvironmentValues {
    var appTheme: AppTheme {
        get { self[AppThemeKey.self] }
        set { self[AppThemeKey.self] = newValue }
    }
}

// Inject
ContentView()
    .environment(\.appTheme, AppTheme(primaryColor: .pink, cornerRadius: 16, titleFont: .largeTitle))

// Read
struct StyledTitle: View {
    @Environment(\.appTheme) var theme
    let text: String
    var body: some View {
        Text(text)
            .font(theme.titleFont)
            .foregroundStyle(theme.primaryColor)
    }
}

Combined with ViewModifiers, this gives you a full theming system: components read the environment theme; design system swaps the value at the top to rebrand.

Modern Swift (5.10+) has @Entry macro shortcut:

extension EnvironmentValues {
    @Entry var appTheme: AppTheme = AppTheme()
}

One line — no key struct, no extension scaffolding.

Composition pattern — slot-based components

Components that take child views:

struct Card<Header: View, Content: View>: View {
    @ViewBuilder let header: () -> Header
    @ViewBuilder let content: () -> Content

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            header()
                .font(.headline)
            content()
        }
        .card()
    }
}

// Usage
Card {
    Text("Today's stats")
} content: {
    Text("12 active users")
    Text("3 conversions")
}

@ViewBuilder enables the trailing-closure DSL (multiple statements, conditionals). Critical for ergonomic component APIs.

Group and EquatableView

Group lets you apply modifiers to multiple views without a layout container:

Group {
    Text("First")
    Text("Second")
    Text("Third")
}
.font(.headline)
.foregroundStyle(.blue)

EquatableView short-circuits re-renders when wrapped value’s == returns true:

struct ExpensiveChart: View, Equatable {
    let data: [Double]
    var body: some View { ... }
}

// Usage
EquatableView(content: ExpensiveChart(data: data))

If data == previousData, SwiftUI skips body. Use for expensive views that often receive equal data.

#Preview macro (iOS 17+)

#Preview("Default") {
    PrimaryButton(title: "Continue") {}
}

#Preview("Disabled", traits: .sizeThatFitsLayout) {
    PrimaryButton(title: "Continue") {}
        .disabled(true)
}

#Preview("Dark", traits: .sizeThatFitsLayout) {
    PrimaryButton(title: "Continue") {}
        .preferredColorScheme(.dark)
}

Replaces the old PreviewProvider boilerplate. Multiple previews per file. Named. Supports traits (size, color scheme, locale).

For interactive previews:

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

@Previewable (iOS 18+) lets you declare state directly in a preview block.

Component library — packaging for reuse

For a design system, ship as a Swift Package:

DesignSystem/
├── Package.swift
└── Sources/DesignSystem/
    ├── Buttons/
    │   ├── PrimaryButtonStyle.swift
    │   └── SecondaryButtonStyle.swift
    ├── TextFields/
    │   └── RoundedTextFieldStyle.swift
    ├── Modifiers/
    │   └── CardModifier.swift
    ├── Components/
    │   ├── Card.swift
    │   ├── Badge.swift
    │   └── EmptyState.swift
    └── Theme/
        └── AppTheme.swift

Apps import DesignSystem. Updates ship as version bumps. Multiple apps share. (Lab 5.4 builds exactly this.)

Accessibility in custom components

Custom components must explicitly forward or set accessibility:

struct Badge: View {
    let count: Int

    var body: some View {
        Text("\(count)")
            .font(.caption.weight(.bold))
            .padding(.horizontal, 6)
            .padding(.vertical, 2)
            .background(.red, in: .capsule)
            .foregroundStyle(.white)
            .accessibilityLabel("\(count) unread")
    }
}

For composite components, decide:

  • Should the children be discoverable separately?
  • Or should the component be one accessibility element?
.accessibilityElement(children: .combine)   // one element, combined labels
// or
.accessibilityElement(children: .ignore)    // one element, custom label

Covered in depth in chapter 5.13.

In the wild

  • Airbnb’s Epoxy (their iOS UI framework, partially open-sourced) is conceptually a design-system-as-code: components, styles, layouts as composable primitives.
  • Apple’s SwiftUI sample code uses ButtonStyle extensively for consistent app-wide buttons (see WWDC sample projects).
  • Stripe’s iOS SDK ships a design-system Swift Package; custom ButtonStyle, TextFieldStyle, and reusable card components are exported.
  • Mozilla Firefox iOS (open source) has a ComponentLibrary SPM module with their button/input/card styles.
  • Apollo’s RIP had a small private design system for the Reddit client — RedditButton, RedditTextField, RedditCard.

Common misconceptions

  1. “Custom View and ViewModifier are interchangeable.” Not quite. A custom View is its own entity (you compose with it). A ViewModifier is applied to existing content. Use View when the thing is something; use ViewModifier when it adds something.
  2. ButtonStyle is just styling.” It’s also interaction state (configuration.isPressed) and accessibility. Recreating buttons with onTapGesture loses both.
  3. “You can’t share styles across apps.” Swift Packages make it trivial. Most teams ship a design-system package.
  4. “Theming requires a giant EnvironmentObject.” A simple struct with an EnvironmentKey is enough. Avoid making theme a class unless you need to mutate it at runtime (dark mode swap is handled by the system).
  5. #Preview is just for new code.” Migrating old PreviewProvider to #Preview is mostly mechanical and removes boilerplate; do it as you touch files.

Seasoned engineer’s take

The hierarchy I use:

  1. ButtonStyle / LabelStyle / TextFieldStyle for every input and control. Never style controls inline.
  2. ViewModifier for reusable visual treatments (cards, headers, badges) that aren’t controls.
  3. Custom View for genuinely reusable composite components (empty state, error view, loading state).
  4. Group + View extension for one-off compositions inside a feature.

When I see a screen with 5+ modifiers applied to a button, I extract a ButtonStyle. When I see the same combination of (padding, background, corner radius) twice, I extract a ViewModifier. When I see a screen that’s 80% existing components and 20% new content, the architecture is healthy.

Avoid the “10-parameter init” trap. If a component grows past ~5 parameters, split it. Either decompose into smaller components, or pass @ViewBuilder closures for the variable parts.

TIP: Inside a ButtonStyle, the configuration.label is the original button’s label — preserve it. Don’t replace it with Text(...); you’d lose the call-site flexibility.

WARNING: Don’t put @State in a ViewModifier unless you really mean it. It’ll be re-instantiated per application. For stateful modifiers (e.g., shake-on-error), it works but is subtle.

Interview corner

Junior-level: “When would you create a ViewModifier vs a custom View?”

ViewModifier when you have a reusable set of modifiers to apply to existing content (e.g., “card” styling — padding, background, shadow). Custom View when you have a reusable component with its own identity and content (e.g., a Badge view with text inside). Rule of thumb: if it modifies content, it’s a modifier; if it is content, it’s a view.

Mid-level: “Walk through implementing a design-system primary button. Why use ButtonStyle over a custom View?”

struct PrimaryButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .font(.headline).foregroundStyle(.white)
            .frame(maxWidth: .infinity).padding()
            .background(Color.accentColor, in: .rect(cornerRadius: 12))
            .opacity(configuration.isPressed ? 0.7 : 1.0)
    }
}
extension ButtonStyle where Self == PrimaryButtonStyle {
    static var primary: PrimaryButtonStyle { .init() }
}

ButtonStyle preserves the semantics of Button (accessibility, focus, action), provides isPressed for free, composes with .disabled / .controlSize, and cascades via .buttonStyle(.primary) on a container to all child buttons. A custom View wrapper loses all of that — you’d reinvent press states with gestures and lose Button’s accessibility traits.

Senior-level: “Design a design-system package architecture for an app that supports 3 brand variants (dark/light/holiday).”

Package layout:

  • DesignSystem/Tokens/Colors.swift, Spacing.swift, Typography.swift — static design tokens per brand.
  • DesignSystem/Theme/AppTheme struct with the tokens, EnvironmentKey, View.theme(_:) modifier.
  • DesignSystem/Styles/ButtonStyles, TextFieldStyles, etc., that read tokens from @Environment(\.appTheme).
  • DesignSystem/Components/Card, EmptyState, LoadingView, etc., reading theme.
  • DesignSystem/Brands/DarkBrand.swift, LightBrand.swift, HolidayBrand.swift — static AppTheme instances.

App init:

RootView()
    .environment(\.appTheme, Brand.current)

Brand.current is determined at launch from settings/A-B test.

Everything below the root reads from environment. Switching brand requires no view changes. Holiday brand can swap colors, corner radii, even iconography by overriding the theme struct’s properties.

For runtime brand switching (e.g., user toggles a “holiday mode” preference), make brand a @State at the root and animate the change.

Red flag in candidates: Reaching for inheritance (“BaseButton subclass”) to handle button variants. Indicates an OOP-first mindset that doesn’t fit SwiftUI’s composition-first model.

Lab preview

Lab 5.4 builds a complete design-system Swift Package with PrimaryButtonStyle, RoundedTextFieldStyle, CardModifier, Badge, and EmptyState — each with #Preview blocks demonstrating variants.


Next: SwiftUI ↔ UIKit interop