Skip to content

Book 19 Building Custom Views And Modifiers

Michael Fluharty edited this page May 1, 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 Xcode 26 Swift Bible -- 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. 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. See Source Tour 19 for the architectural walkthrough.


Liquid Glass for Custom Views — glassEffect and GlassEffectContainer

When a custom view needs to sit alongside the system's own Liquid Glass surfaces and feel like part of the same family, X26 gives you two SwiftUI tools[[cv1]]:

  • glassEffect(_:in:) — paints the Liquid Glass material onto a single custom view
  • GlassEffectContainer — wraps a cluster of Liquid Glass views so they share one render pass and morph cleanly into each other during animation

The headline rule on glassEffect(_:in:) is restraint — Apple warns that "overusing this material in multiple custom controls can provide a subpar user experience by distracting from that content."[[cv1]] Pick the elements where Liquid Glass earns its weight: a primary call-to-action button, a focus-state highlight on a custom slider, the lone floating affordance on a canvas. Don't drop it into every card, row, or chrome surface; the material's job is to point the eye at content, and that job breaks the moment everything on screen is wearing it.

Basic glassEffect

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

    var body: some View {
        Button(action: action) {
            Text(title)
                .font(.headline)
                .padding(.horizontal, 20)
                .padding(.vertical, 12)
        }
        .glassEffect(.regular, in: .capsule)
    }
}

First argument: which flavor of the material you want. Second argument: the shape the material is poured into — a capsule for a pill button, a rounded rectangle for a card, and so on. From there the button gets the full optical treatment — refraction at the edges, a knob-style morph when focus lands on it, the scroll edge handoff — without you writing any of those animations yourself.

GlassEffectContainer for Performance and Morphing

When you have multiple custom Liquid Glass elements that need to morph between each other (think: a row of custom toggles, or a custom toolbar where elements expand and collapse), wrap them in a GlassEffectContainer:

GlassEffectContainer {
    Button("Edit") { startEditing() }
        .glassEffect(.regular, in: .capsule)
    Button("Done") { finishEditing() }
        .glassEffect(.regular, in: .capsule)
    Button("Cancel") { cancel() }
        .glassEffect(.regular, in: .capsule)
}

The container is doing two things at once. It collapses what would otherwise be N independent render passes into a single pass — cheaper for the GPU, especially on older hardware. And it owns the morph between siblings, so when one button expands and another collapses, the glass shape stretches between them instead of cross-fading. Skip the container and each modifier is on its own; the animation between buttons looks staccato instead of fluid.

tvOS Pairs Liquid Glass with Focus

If your app reaches Apple TV, add this to your checklist. On tvOS, a stock button or control switches to its Liquid Glass look when the remote's focus lands on it — the focus state is the trigger. To get the same behavior on your custom controls, opt into the focus engine through focusable(_:) and isFocused; the Liquid Glass appearance is keyed to those APIs and shows up on its own. Hardware floor: Apple TV 4K (2nd generation) and later render the effects. Apple TV HD keeps its pre-X26 styling.

[cv1] Apple Developer Documentation, *Adopting Liquid Glass*. [developer.apple.com/documentation/TechnologyOverviews/adopting-liquid-glass](https://developer.apple.com/documentation/TechnologyOverviews/adopting-liquid-glass) — verified 2026-04-29.


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")
}

Rule: 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 centralize a styling decision. 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

Book 20 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