Skip to content

Book 19 Building Custom Views And Modifiers

Michael Fluharty edited this page Apr 29, 2026 · 7 revisions

Book 19: Building Custom Views And Modifiers

Part V — Advanced Techniques · Claude's Xcode 26 Swift Bible

Book-18-Error-Handling-And-Result-Type · Chapters and Appendices · Book-20-Performance-Instruments-And-Best-Practices


Claude's Swift Reference 26 -- Part V: Advanced Techniques


Live Reference: Lexicon Sheet + CryoTransport Controls + Tally Matrix Squares

Three concrete custom-view examples you can read in the production source while working through this chapter:

LexiconSheet + IdentifierTaggedSourceView (in Inkwell, github.com/fluhartyml/Claudes-X26-Swift6-Bible) — the popup that appears when you tap a tinted Swift identifier in any source-mirror code block in the Under the Hood tab. A custom view that takes an identifier, looks it up in the Lexicon corpus, and renders a definition popup with code example, related links, and book references. Built from raw SwiftUI primitives.

CryoTransportControls (in CryoTunes Player, github.com/fluhartyml/CryoTunesPlayer) — a three-row composed view: transport row (play/pause/skip/stop), shuffle/repeat row, like/dislike/share row. A textbook example of decomposing a complex multi-button cluster into one named custom view.

TallyMatrix1x3 + TallyMatrix3x3 + SquareView (in Tally Matrix Clock, github.com/fluhartyml/Tally-Matrix-Clock) — three custom views that compose: SquareView is a single lit/unlit square primitive; the two matrix views compose grids of squares for the tens and ones digits. Same primitive, two different layouts, both reusable. See Source Tour 19 for the architectural walkthrough.


What You'll Learn

By the end of this chapter you can:

  • Extract reusable SwiftUI views from copy-pasted snippets.
  • Write custom ViewModifier types and apply them with .modifier or a cleaner .myStyle() extension.
  • Use @Environment values to pass data deep into a view tree without threading it through every level.
  • Read child-view sizes and positions with GeometryReader.
  • Share data from child to parent with PreferenceKey.
  • Animate transitions between layouts with .matchedGeometryEffect.

Extracting a Custom View

The first step past "all code in one big ContentView" is pulling repeated shapes into their own View types.

struct AvatarRow: View {
    let name: String
    let imageName: String

    var body: some View {
        HStack {
            Image(systemName: imageName)
                .resizable()
                .scaledToFit()
                .frame(width: 40, height: 40)
                .foregroundStyle(.blue)
            Text(name).font(.headline)
            Spacer()
        }
        .padding()
    }
}

Used anywhere:

VStack {
    AvatarRow(name: "Ada",    imageName: "person.circle")
    AvatarRow(name: "Max",    imageName: "person.circle.fill")
    AvatarRow(name: "Claude", imageName: "sparkles")
}

The rule of three: the third time you write the same layout, turn it into a view.


ViewModifier -- Reusable Styling

When the thing you keep repeating is a stack of modifiers, not a layout, reach for ViewModifier instead of a new view.

struct CardStyle: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding()
            .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
            .shadow(color: .black.opacity(0.12), radius: 6, y: 2)
    }
}

Apply it with .modifier(CardStyle()), or -- for a cleaner call site -- add a View extension:

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

// Usage
Text("Hello")
    .card()

Custom modifiers are how you ship a house style. A dozen views across the app, one definition, any future tweak happens in one place.

Modifiers That Take Arguments

struct BadgedStyle: ViewModifier {
    let color: Color
    let count: Int

    func body(content: Content) -> some View {
        content.overlay(alignment: .topTrailing) {
            if count > 0 {
                Text("\(count)")
                    .font(.caption2.bold())
                    .padding(4)
                    .background(color, in: Circle())
                    .foregroundStyle(.white)
                    .offset(x: 8, y: -8)
            }
        }
    }
}

extension View {
    func badged(_ count: Int, color: Color = .red) -> some View {
        modifier(BadgedStyle(color: color, count: count))
    }
}

// Usage
Image(systemName: "envelope")
    .font(.title)
    .badged(3)

Environment Values -- Passing State Without Plumbing

When deeply nested views need the same value (theme, date formatter, API client), don't thread it through every initializer. Put it in the environment.

Reading a Built-In Environment Value

struct Banner: View {
    @Environment(\.colorScheme) private var scheme

    var body: some View {
        Text("Hello")
            .foregroundStyle(scheme == .dark ? .white : .black)
    }
}

Defining Your Own Environment Value

Define a key, extend EnvironmentValues, and set it with .environment:

private struct ThemeKey: EnvironmentKey {
    static let defaultValue: Color = .blue
}

extension EnvironmentValues {
    var theme: Color {
        get { self[ThemeKey.self] }
        set { self[ThemeKey.self] = newValue }
    }
}

// A view that reads it
struct ThemedButton: View {
    @Environment(\.theme) private var theme
    let title: String

    var body: some View {
        Text(title).padding().background(theme).foregroundStyle(.white)
    }
}

// A view tree that sets it
VStack {
    ThemedButton(title: "Save")
    ThemedButton(title: "Cancel")
}
.environment(\.theme, .orange)

Every ThemedButton under that .environment(...) sees orange. Change the value once and every button updates.


GeometryReader -- Reading the Size Around You

GeometryReader gives its child view a GeometryProxy describing the size and safe-area it has been allocated.

struct SplitHalf: View {
    var body: some View {
        GeometryReader { geo in
            HStack(spacing: 0) {
                Color.red.frame(width: geo.size.width / 2)
                Color.blue.frame(width: geo.size.width / 2)
            }
        }
    }
}

When Not to Use GeometryReader

GeometryReader expands to fill the space it is offered, which often surprises newcomers. If all you need is "half the width," reach for .frame(maxWidth: .infinity) and let SwiftUI's normal layout system do the work.

Use GeometryReader when you genuinely need to compute something from the runtime size -- a custom layout, a parallax effect, a shape that depends on width / height.

onGeometryChange (iOS 18+)

In iOS 18, .onGeometryChange observes a view's frame without wrapping it in GeometryReader:

Text("Hello")
    .onGeometryChange(for: CGSize.self) { proxy in
        proxy.size
    } action: { newSize in
        print("I am now", newSize)
    }

Cleaner than GeometryReader when you only need a measurement, not a layout container.


PreferenceKey -- Child to Parent Communication

The environment passes data down the view tree. Preference keys pass data up.

private struct HeightKey: PreferenceKey {
    static let defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = max(value, nextValue())
    }
}

struct RowLikeContent: View {
    var body: some View {
        Text("I report my height upward")
            .background(
                GeometryReader { geo in
                    Color.clear.preference(key: HeightKey.self, value: geo.size.height)
                }
            )
    }
}

struct Parent: View {
    @State private var rowHeight: CGFloat = 0

    var body: some View {
        VStack {
            RowLikeContent()
            Text("The row above is \(Int(rowHeight)) points tall")
        }
        .onPreferenceChange(HeightKey.self) { rowHeight = $0 }
    }
}

reduce is how SwiftUI combines values from multiple children; in this case we take the largest. Use preference keys when a parent needs to size itself based on its children, or when a child needs to say something specific to an ancestor.


matchedGeometryEffect -- Transitions Between Layouts

When the same "thing" appears in two different layouts, matchedGeometryEffect animates it between them.

struct PhotoDetail: View {
    @Namespace private var ns
    @State private var expanded = false

    var body: some View {
        VStack {
            if expanded {
                Image("kitten")
                    .resizable()
                    .scaledToFit()
                    .matchedGeometryEffect(id: "photo", in: ns)
                    .onTapGesture { withAnimation(.spring) { expanded.toggle() } }
            } else {
                HStack {
                    Image("kitten")
                        .resizable()
                        .scaledToFit()
                        .frame(width: 80, height: 80)
                        .matchedGeometryEffect(id: "photo", in: ns)
                        .onTapGesture { withAnimation(.spring) { expanded.toggle() } }
                    Text("Kitten").font(.headline)
                }
            }
        }
    }
}

Tap the image. SwiftUI notices the two views share the id: "photo" in the same namespace, and animates position + size from the small version to the full one (and back). You don't write any of the intermediate frames.

@Namespace gives you a private namespace; reuse its value for every view pair you want matched.


Chapter Mini-Example -- A Badge Stack

Putting custom views, a custom modifier, and an environment value together:

import SwiftUI

private struct AccentKey: EnvironmentKey { static let defaultValue: Color = .blue }
extension EnvironmentValues {
    var accent: Color {
        get { self[AccentKey.self] }
        set { self[AccentKey.self] = newValue }
    }
}

struct BadgedIcon: View {
    let systemName: String
    let count: Int

    @Environment(\.accent) private var accent

    var body: some View {
        Image(systemName: systemName)
            .font(.title)
            .foregroundStyle(accent)
            .overlay(alignment: .topTrailing) {
                if count > 0 {
                    Text("\(count)")
                        .font(.caption2.bold())
                        .padding(4)
                        .background(.red, in: Circle())
                        .foregroundStyle(.white)
                        .offset(x: 8, y: -8)
                }
            }
    }
}

struct CardStyle: ViewModifier {
    func body(content: Content) -> some View {
        content.padding()
            .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
            .shadow(color: .black.opacity(0.12), radius: 6, y: 2)
    }
}

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

struct NotificationBar: View {
    var body: some View {
        HStack(spacing: 24) {
            BadgedIcon(systemName: "envelope", count: 3)
            BadgedIcon(systemName: "bell",     count: 0)
            BadgedIcon(systemName: "person.2", count: 1)
        }
        .card()
        .environment(\.accent, .orange)
    }
}

One reusable icon view, one reusable card modifier, one environment value -- and the bar's entire accent color lives in one spot.


What Book 20 Does

We've added view-shaped tools to the toolkit. Book 20 pulls back and looks at the app as a running system: Instruments, profiling, and the habits that keep a SwiftUI app smooth as it grows.


Book-18-Error-Handling-And-Result-Type · Chapters and Appendices · Book-20-Performance-Instruments-And-Best-Practices

Feedback: Found something off? Open an issue · Discuss it · Email Michael

Clone this wiki locally