-
Notifications
You must be signed in to change notification settings - Fork 0
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 →
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, andStepper. 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.
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
ConcentricRectangleorrect(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.
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.
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("Save") {
saveDocument()
}
Button(action: saveDocument) {
Label("Save", systemImage: "square.and.arrow.down")
}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 {
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))
}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 |
Button("Accept") { }
.buttonStyle(.borderedProminent)
.tint(.green)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())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()
}
}
}-
ButtonStylegives youisPressedstate but the system handles the tap.PrimitiveButtonStylegives you full control but you must callconfiguration.trigger()yourself. -
buttonStyleapplies to all buttons in the subtree. Set it on a container to style every button inside it at once.
@State private var isEnabled = false
Toggle("Notifications", isOn: $isEnabled)
Toggle(isOn: $isEnabled) {
Label("Notifications", systemImage: "bell")
}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 onlyToggle("Dark Mode", isOn: $darkMode)
.tint(.purple)-
.toggleStyle(.checkbox)is macOS only. On iOS it falls back to a switch. - Toggles in a
Listautomatically get trailing alignment. Outside a List, they default to leading label with trailing switch.
@State private var selectedFlavor = "Chocolate"
let flavors = ["Chocolate", "Vanilla", "Strawberry"]
Picker("Flavor", selection: $selectedFlavor) {
ForEach(flavors, id: \.self) { flavor in
Text(flavor)
}
}Picker("View", selection: $viewMode) {
Text("Grid").tag(ViewMode.grid)
Text("List").tag(ViewMode.list)
Text("Gallery").tag(ViewMode.gallery)
}
.pickerStyle(.segmented)Picker("Weight", selection: $weight) {
ForEach(100...300, id: \.self) { w in
Text("\(w) lbs").tag(w)
}
}
.pickerStyle(.wheel)Picker("Category", selection: $selectedCategory) {
ForEach(categories) { cat in
Text(cat.name).tag(cat)
}
}
.pickerStyle(.inline)Inside a NavigationStack, this pushes a full selection list.
Picker("Country", selection: $selectedCountry) {
ForEach(countries) { country in
Text(country.name).tag(country)
}
}
.pickerStyle(.navigationLink)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.
@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")
}Stepper("Custom") {
increment()
} onDecrement: {
decrement()
}@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")
}- Slider labels are hidden by default in many contexts. Use an explicit
Textabove the slider if you need the user to see the label. -
steponly snaps during dragging. Settingstep: 5with range0...100means the user drags in increments of 5.
@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]).datePickerStyle(.graphical) // calendar grid
.datePickerStyle(.wheel) // spinning wheels
.datePickerStyle(.compact) // tappable pill that expands
.datePickerStyle(.automatic) // platform decides-
.graphicaltakes 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.
@State private var color: Color = .blue
ColorPicker("Theme Color", selection: $color)
ColorPicker("Background", selection: $color, supportsOpacity: false)supportsOpacity: false hides the alpha slider.
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.
struct MyControl: View {
@Environment(\.isEnabled) var isEnabled
var body: some View {
Text("Status: \(isEnabled ? "Active" : "Disabled")")
.foregroundStyle(isEnabled ? .primary : .secondary)
}
}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("Item", systemImage: "star")
.labelStyle(.titleAndIcon) // both text and icon
.labelStyle(.titleOnly) // text only
.labelStyle(.iconOnly) // icon onlyButton(action: deleteItem) {
Image(systemName: "trash")
}
.accessibilityLabel("Delete item")
Slider(value: $brightness)
.accessibilityLabel("Screen brightness")
.accessibilityValue("\(Int(brightness))%")- Icon-only buttons must have an
.accessibilityLabel. VoiceOver users hear nothing otherwise. -
Labeldoes 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.
-
Use
.borderedProminentsparingly. One prominent button per screen for the primary action. Everything else gets.borderedor.borderless. -
Picker tag type matching is the most common SwiftUI bug. When your picker ignores taps, check tag types first.
-
Stepper fits small ranges (1-20). For large ranges, use a Slider or a text field with validation.
-
Group related controls in a
Form. Controls inside a Form automatically get platform-appropriate styling — grouped rows on iOS, aligned labels on macOS. -
Test with Dynamic Type. At the largest accessibility sizes, labels wrap and controls reflow. Make sure nothing gets clipped.
-
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
Claude's X26 Swift6 Bible | GPL v3 | Built with Claude by Anthropic | Repo