Skip to content

Book 06 Controls Buttons Toggles Pickers

Michael Fluharty edited this page May 1, 2026 · 8 revisions

Book 06: Controls Buttons Toggles Pickers

Part III — The User Interface · Claude's Xcode 26 Swift Bible

Book-05-Menus-And-Navigation · Chapters and Appendices · Book-07-Toolbars-And-Tab-Views


Live Reference: Take A Chance On Me + CryoTunes Player

Take A Chance On Me is the smallest-scale shipping example of the patterns this chapter teaches — a multi-die roller built almost entirely from Button, Picker, and Stepper. Each die is a button; each die-size selector is a picker; the multi-die count is a stepper. Reading its source is reading this chapter applied to one concrete app. Source: github.com/fluhartyml/TakeAChanceOnMeV1.0.2. See Source Tour 26. CryoTunes Player uses every control in this chapter at production scale — buttons across the transport row (play, pause, skip, stop, shuffle, repeat, share, thumbs-up, thumbs-down), toggles in Settings, pickers for radio station and sleep timer. Source: github.com/fluhartyml/CryoTunesPlayer. See Source Tour 18.


Liquid Glass Updates to Controls (X26)

Every control in X26 looks a little different and feels noticeably more reactive. Apple's framing for what changed: "For controls like sliders and toggles, the knob transforms into Liquid Glass during interaction, and buttons fluidly morph into menus and popovers."[[c1]] Round corners get more aggressive too, deliberately, so a control's curve matches the curve of the device's display bezel.[[c1]]

Stock SwiftUI controls inherit the new sizes and shapes automatically as long as you haven't hand-tuned their dimensions. Four things to walk through after the rebuild:[[c1]]

  • Pull color back. Heavy custom tinting fights the new material; reach for system colors first, and if you need a custom color, declare light, dark, and increased-contrast versions of it.
  • Watch for crowding. Stick with the system spacing values rather than overriding them, and never stack one Liquid Glass surface directly on top of another.
  • Handle scroll-under content. Toolbars and other system bars already obscure the content behind them. A bar you built yourself opts in by registering with safeAreaBar(edge:alignment:spacing:content:); without that, scrolling text bleeds through and controls become hard to read.
  • Concentric corners. Use ConcentricRectangle or rect(corners:isUniform:) so a control's corner radius is derived from its container's. The cascade flows from the device's screen corner inward through window, group, and control.

New Button Styles — .glass and .glassProminent

Two new button styles adopt Liquid Glass with minimal code:

Button("Continue") {
    advance()
}
.buttonStyle(.glass)

Button("Buy Now") {
    purchase()
}
.buttonStyle(.glassProminent)

.glass drops the standard material onto a button. .glassProminent is the same material at higher visual emphasis, reserved for the primary action on a screen. Use it on a single button per screen so the emphasis stays meaningful.

Extra-Large Control Size

X26 also unlocks a new size step above large — an extra-large variant for controls. Two situations earn it: a packed form where bigger tap targets keep the screen usable, or a single hero control that's meant to anchor the page.[[c1]]

[c1] 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.


Button

Basic Button

Button("Save") {
    saveDocument()
}

Button(action: saveDocument) {
    Label("Save", systemImage: "square.and.arrow.down")
}

Button Roles

Button("Delete", role: .destructive) {
    deleteItem()
}

Button("Cancel", role: .cancel) {
    dismiss()
}

.destructive renders in red. .cancel tells the system this dismisses without action (used in dialogs and confirmations).

Button with Custom Label

Button {
    startDownload()
} label: {
    HStack {
        Image(systemName: "arrow.down.circle.fill")
            .font(.title2)
        VStack(alignment: .leading) {
            Text("Download").font(.headline)
            Text("42 MB").font(.caption).foregroundStyle(.secondary)
        }
    }
    .padding()
    .background(.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 12))
}

Built-in Button Styles

Button("Bordered") { }
    .buttonStyle(.bordered)

Button("Bordered Prominent") { }
    .buttonStyle(.borderedProminent)

Button("Borderless") { }
    .buttonStyle(.borderless)

Button("Plain") { }
    .buttonStyle(.plain)

| Style | Look | |-------|------| | .automatic | Platform default | | .bordered | Tinted background, rounded shape | | .borderedProminent | Filled with tint color, white text | | .borderless | Text only, no background | | .plain | No styling at all — just the label |

Tint Control

Button("Accept") { }
    .buttonStyle(.borderedProminent)
    .tint(.green)

Custom ButtonStyle

Implement the ButtonStyle protocol for a reusable button appearance.

struct ScaleButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .scaleEffect(configuration.isPressed ? 0.95 : 1.0)
            .opacity(configuration.isPressed ? 0.8 : 1.0)
            .animation(.easeInOut(duration: 0.15), value: configuration.isPressed)
    }
}

// Usage
Button("Tap Me") { }
    .buttonStyle(ScaleButtonStyle())

PrimitiveButtonStyle

For full control over when the action fires (e.g., requiring a long press).

struct LongPressButtonStyle: PrimitiveButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .onLongPressGesture {
                configuration.trigger()
            }
    }
}

Watch Out

  • ButtonStyle gives you isPressed state but the system handles the tap. PrimitiveButtonStyle gives you full control but you must call configuration.trigger() yourself.
  • buttonStyle applies to all buttons in the subtree. Set it on a container to style every button inside it at once.

Toggle

Basic Toggle

@State private var isEnabled = false

Toggle("Notifications", isOn: $isEnabled)

Toggle(isOn: $isEnabled) {
    Label("Notifications", systemImage: "bell")
}

Toggle Styles

Toggle("Option", isOn: $value)
    .toggleStyle(.switch)      // the default iOS switch

Toggle("Option", isOn: $value)
    .toggleStyle(.button)      // renders as a pressable button

Toggle("Option", isOn: $value)
    .toggleStyle(.checkbox)    // macOS only

Tint

Toggle("Dark Mode", isOn: $darkMode)
    .tint(.purple)

Watch Out

  • .toggleStyle(.checkbox) is macOS only. On iOS it falls back to a switch.
  • Toggles in a List automatically get trailing alignment. Outside a List, they default to leading label with trailing switch.

Picker

Menu Picker (Default on iOS)

@State private var selectedFlavor = "Chocolate"
let flavors = ["Chocolate", "Vanilla", "Strawberry"]

Picker("Flavor", selection: $selectedFlavor) {
    ForEach(flavors, id: \.self) { flavor in
        Text(flavor)
    }
}

Segmented

Picker("View", selection: $viewMode) {
    Text("Grid").tag(ViewMode.grid)
    Text("List").tag(ViewMode.list)
    Text("Gallery").tag(ViewMode.gallery)
}
.pickerStyle(.segmented)

Wheel

Picker("Weight", selection: $weight) {
    ForEach(100...300, id: \.self) { w in
        Text("\(w) lbs").tag(w)
    }
}
.pickerStyle(.wheel)

Inline (Expands in List)

Picker("Category", selection: $selectedCategory) {
    ForEach(categories) { cat in
        Text(cat.name).tag(cat)
    }
}
.pickerStyle(.inline)

Navigation Link Picker

Inside a NavigationStack, this pushes a full selection list.

Picker("Country", selection: $selectedCountry) {
    ForEach(countries) { country in
        Text(country.name).tag(country)
    }
}
.pickerStyle(.navigationLink)

Tag Type Matching

Every picker option must have a .tag() that matches the exact type of the selection binding. If your selection is Optional<Item>, your tags must be Optional<Item> too — not plain Item.

// selection is Item?, so tags are Item?
@State private var selected: Item?
Picker("Pick", selection: $selected) {
    Text("None").tag(nil as Item?)
    ForEach(items) { item in
        Text(item.name).tag(item as Item?)
    }
}

If the picker does not respond to taps, check that selection-binding type and tag type match exactly.


Stepper

@State private var quantity = 1

Stepper("Quantity: \(quantity)", value: $quantity, in: 1...99)

Stepper(value: $zoomLevel, in: 0.5...3.0, step: 0.25) {
    Text("Zoom: \(zoomLevel, specifier: "%.2f")x")
}

Manual Stepper

Stepper("Custom") {
    increment()
} onDecrement: {
    decrement()
}

Slider

@State private var volume: Double = 0.5

Slider(value: $volume)

Slider(value: $volume, in: 0...100, step: 5) {
    Text("Volume")
} minimumValueLabel: {
    Image(systemName: "speaker")
} maximumValueLabel: {
    Image(systemName: "speaker.wave.3")
}

Watch Out

  • Slider labels are hidden by default in many contexts. Use an explicit Text above the slider if you need the user to see the label.
  • step only snaps during dragging. Setting step: 5 with range 0...100 means the user drags in increments of 5.

DatePicker

@State private var date = Date()

DatePicker("Birthday", selection: $date, displayedComponents: .date)

DatePicker("Alarm", selection: $date, displayedComponents: .hourAndMinute)

DatePicker("Event", selection: $date,
           in: Date()...,   // only future dates
           displayedComponents: [.date, .hourAndMinute])

Styles

.datePickerStyle(.graphical)    // calendar grid
.datePickerStyle(.wheel)        // spinning wheels
.datePickerStyle(.compact)      // tappable pill that expands
.datePickerStyle(.automatic)    // platform decides

Watch Out

  • .graphical takes up a lot of space. Works well in a Form or sheet, but can break layouts in tight spaces.
  • The in: parameter sets the selectable range. Date()... means today onward. ...Date() means up to today.

ColorPicker

@State private var color: Color = .blue

ColorPicker("Theme Color", selection: $color)

ColorPicker("Background", selection: $color, supportsOpacity: false)

supportsOpacity: false hides the alpha slider.


Disabled States

Button("Submit") { submit() }
    .disabled(formIsInvalid)

Toggle("Feature", isOn: $feature)
    .disabled(!isPremiumUser)

.disabled(true) grays out the control and blocks interaction. It propagates down the view tree — disable a VStack and every control inside is disabled.

Reading Disabled State in Custom Views

struct MyControl: View {
    @Environment(\.isEnabled) var isEnabled

    var body: some View {
        Text("Status: \(isEnabled ? "Active" : "Disabled")")
            .foregroundStyle(isEnabled ? .primary : .secondary)
    }
}

Labels and Accessibility

Label

Label pairs an icon with text. Many controls accept a Label as content.

Label("Downloads", systemImage: "arrow.down.circle")
Label("Warning", systemImage: "exclamationmark.triangle")
    .foregroundStyle(.orange)

Label Styles

Label("Item", systemImage: "star")
    .labelStyle(.titleAndIcon)  // both text and icon
    .labelStyle(.titleOnly)     // text only
    .labelStyle(.iconOnly)      // icon only

Accessibility Labels

Button(action: deleteItem) {
    Image(systemName: "trash")
}
.accessibilityLabel("Delete item")

Slider(value: $brightness)
    .accessibilityLabel("Screen brightness")
    .accessibilityValue("\(Int(brightness))%")

Watch Out

  • Icon-only buttons must have an .accessibilityLabel. VoiceOver users hear nothing otherwise.
  • Label does not always show both icon and text. In toolbars and menus, the system may choose to show only one. Use .labelStyle(.titleAndIcon) to force both.

Practical Tips

  1. Use .borderedProminent sparingly. One prominent button per screen for the primary action. Everything else gets .bordered or .borderless.

  2. Picker tag type matching is the most common SwiftUI bug. When your picker ignores taps, check tag types first.

  3. Stepper fits small ranges (1-20). For large ranges, use a Slider or a text field with validation.

  4. Group related controls in a Form. Controls inside a Form automatically get platform-appropriate styling — grouped rows on iOS, aligned labels on macOS.

  5. Test with Dynamic Type. At the largest accessibility sizes, labels wrap and controls reflow. Make sure nothing gets clipped.

  6. 18pt minimum font height. Per project standards, all text in controls should meet this minimum for iPad readability.


Book-05-Menus-And-Navigation · Chapters and Appendices · Book-07-Toolbars-And-Tab-Views

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

Clone this wiki locally