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.
| Tool | Use for |
|---|---|
Custom View | Reusable UI components (cards, headers, badges) |
ViewModifier | Reusable groups of modifiers (card styling, headers) |
ButtonStyle / PrimitiveButtonStyle | Customizing every Button in a subtree |
LabelStyle, MenuStyle, etc. | Customizing other system controls |
TextFieldStyle | Custom text-input styling |
EnvironmentKey | Custom environment values for theming |
#Preview macro | Preview 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.isPressedfor 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()
ViewModifieris astructwith abody(content:)returningsome View- Provide an
extension Viewhelper 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
ButtonStyleextensively 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
ComponentLibrarySPM 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
- “Custom
ViewandViewModifierare interchangeable.” Not quite. A customViewis its own entity (you compose with it). AViewModifieris applied to existing content. UseViewwhen the thing is something; useViewModifierwhen it adds something. - “
ButtonStyleis just styling.” It’s also interaction state (configuration.isPressed) and accessibility. Recreating buttons withonTapGestureloses both. - “You can’t share styles across apps.” Swift Packages make it trivial. Most teams ship a design-system package.
- “Theming requires a giant
EnvironmentObject.” A simplestructwith anEnvironmentKeyis enough. Avoid making theme a class unless you need to mutate it at runtime (dark mode swap is handled by the system). - “
#Previewis just for new code.” Migrating oldPreviewProviderto#Previewis mostly mechanical and removes boilerplate; do it as you touch files.
Seasoned engineer’s take
The hierarchy I use:
ButtonStyle/LabelStyle/TextFieldStylefor every input and control. Never style controls inline.ViewModifierfor reusable visual treatments (cards, headers, badges) that aren’t controls.- Custom
Viewfor genuinely reusable composite components (empty state, error view, loading state). Group+Viewextension 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, theconfiguration.labelis the original button’s label — preserve it. Don’t replace it withText(...); you’d lose the call-site flexibility.
WARNING: Don’t put
@Statein aViewModifierunless 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/—AppThemestruct 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— staticAppThemeinstances.
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