Skip to content

Controls

Daniel S edited this page Jul 2, 2026 · 1 revision

Controls

This page is a deep reference for DesignFoundation's five interactive control components: DFToggle, DFSlider, DFCheckbox, DFPicker, and DFDatePicker. For a quick overview of all input components, see Text-Fields-and-Inputs.


1. Choosing the Right Control

Scenario Use
Turn a single feature on or off (immediate effect) DFToggle (.switch style)
Select one item from 2–4 options visible simultaneously DFPicker (.segmented)
Select one item from a long hidden list DFPicker (.menu)
Binary agreement in a form (collect, then submit) DFCheckbox
Select multiple items independently DFCheckbox multi-select pattern
Pick a continuous value along a range DFSlider
Pick a discrete stepped value DFSlider with step:
Choose a calendar date DFDatePicker
Choose a date and time DFDatePicker with displayedComponents

Toggle vs Checkbox: Use DFToggle when the action takes effect immediately (enabling push notifications). Use DFCheckbox when the user confirms a choice before submitting a form.


2. DFToggle

DFToggle("Enable notifications", isOn: $enabled)
DFToggle("Auto-save", isOn: $autoSave).dfToggleStyle(.switch)    // default
DFToggle("Remember device", isOn: $remember).dfToggleStyle(.checkbox)
DFToggle("Auto-save", isOn: $autoSave).dfToggleStyle(.glass)     // iOS 26+
DFToggle("Advanced mode", isOn: $advanced).disabled(!isPro)

Style Comparison

.switch .checkbox
Platform metaphor On/Off switch Selection mark
Best for System settings, instant-effect toggles Forms, agreements
Perceived timing "This happens now" "This is my answer"

Example 1 — Notification Settings Section

struct NotificationSettingsSection: View {
    @State private var pushEnabled = true
    @State private var emailDigest = false
    @State private var smsAlerts = false

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            DFText("Notifications", style: .headline)

            DFCard {
                VStack(spacing: 0) {
                    DFListRow("Push notifications",
                              subtitle: "Receive alerts on this device") {
                        DFToggle("", isOn: $pushEnabled)
                    }
                    DFDivider()
                    DFListRow("Email digest",
                              subtitle: "Daily summary at 8 AM") {
                        DFToggle("", isOn: $emailDigest)
                    }
                    DFDivider()
                    DFListRow("SMS alerts") {
                        DFToggle("", isOn: $smsAlerts)
                            .disabled(!pushEnabled)
                    }
                }
            }
        }
        .padding()
    }
}

Example 2 — Feature Flags Panel

struct FeatureFlagsView: View {
    @EnvironmentObject var flags: FeatureFlags

    var body: some View {
        DFCard(padding: 16) {
            VStack(alignment: .leading, spacing: 12) {
                DFText("Experimental Features", style: .headline)
                DFText("These features are in beta and may change.", style: .caption)

                ForEach(flags.all) { flag in
                    HStack {
                        VStack(alignment: .leading, spacing: 2) {
                            DFText(flag.displayName, style: .body)
                            DFText(flag.description, style: .caption)
                        }
                        Spacer()
                        DFToggle("", isOn: flags.binding(for: flag.id))
                            .disabled(flag.isForced)
                    }
                    if flag.id != flags.all.last?.id { DFDivider() }
                }
            }
        }
    }
}

Example 3 — Settings with Dependency

DFCard {
    VStack(spacing: 0) {
        DFListRow("Share analytics") {
            DFToggle("", isOn: $analyticsEnabled)
        }
        DFDivider()
        DFListRow("Crash reporting") {
            DFToggle("", isOn: $crashReporting)
                .disabled(!analyticsEnabled)
        }
        DFDivider()
        DFListRow("Performance monitoring") {
            DFToggle("", isOn: $perfMonitoring)
                .disabled(!analyticsEnabled)
        }
    }
}

Example 4 — Terms Checkbox

struct RegistrationFooter: View {
    @Binding var agreedToTerms: Bool
    @Binding var subscribeMarketing: Bool
    let onSubmit: () -> Void

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            DFToggle("I agree to the Terms of Service and Privacy Policy",
                     isOn: $agreedToTerms)
                .dfToggleStyle(.checkbox)

            DFToggle("Send me product updates and announcements",
                     isOn: $subscribeMarketing)
                .dfToggleStyle(.checkbox)

            DFButton("Create account") { onSubmit() }
                .disabled(!agreedToTerms)
        }
    }
}

3. DFSlider

DFSlider(value: $opacity)                                   // 0...1, no label
DFSlider("Volume", value: $volume, in: 0...100)
DFSlider("Quality", value: $quality, in: 1...5, step: 1)
DFSlider("Font size", value: $size, in: 10...36, step: 1)
    .dfSliderStyle(.labeled)   // shows label + current value + min/max captions

Example 1 — Volume and Opacity

struct AudioControlPanel: View {
    @State private var volume: Double = 0.7
    @State private var micGain: Double = 0.5

    var body: some View {
        DFCard {
            VStack(spacing: 20) {
                DFText("Audio Controls", style: .headline)
                DFSlider("Volume", value: $volume, in: 0...1)
                DFSlider("Microphone gain", value: $micGain, in: 0...1)
                    .dfSliderStyle(.labeled)
            }
            .padding()
        }
    }
}

Example 2 — Font Size with Labeled Style

struct AppearanceSettings: View {
    @AppStorage("fontSize") private var fontSize: Double = 16

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            DFSlider("Font size", value: $fontSize, in: 10...36, step: 1)
                .dfSliderStyle(.labeled)

            DFCard {
                DFText("The quick brown fox jumps over the lazy dog.")
                    .font(.system(size: fontSize))
                    .padding()
            }
        }
    }
}

Example 3 — Quality Level with Discrete Steps

struct ExportSettingsView: View {
    @State private var quality: Double = 3
    private let qualityNames = ["", "Draft", "Low", "Medium", "High", "Maximum"]

    var body: some View {
        DFCard {
            VStack(spacing: 24) {
                DFText("Export Quality", style: .headline)

                VStack(alignment: .leading, spacing: 4) {
                    HStack {
                        DFText("Quality", style: .body)
                        Spacer()
                        DFBadge(text: qualityNames[Int(quality)])
                    }
                    DFSlider(value: $quality, in: 1...5, step: 1)
                        .accessibilityLabel("Export quality")
                        .accessibilityValue(qualityNames[Int(quality)])
                }
            }
            .padding()
        }
    }
}

Example 4 — Price Range (Dual Bound)

struct PriceRangeFilter: View {
    @State private var minPrice: Double = 0
    @State private var maxPrice: Double = 500
    let absoluteMin: Double = 0, absoluteMax: Double = 1000

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                DFText("Price range", style: .body)
                Spacer()
                DFText("$\(Int(minPrice)) – $\(Int(maxPrice))", style: .caption)
            }

            DFSlider("Minimum", value: Binding(
                get: { minPrice },
                set: { minPrice = min($0, maxPrice - 10) }
            ), in: absoluteMin...absoluteMax, step: 10)
            .dfSliderStyle(.labeled)

            DFSlider("Maximum", value: Binding(
                get: { maxPrice },
                set: { maxPrice = max($0, minPrice + 10) }
            ), in: absoluteMin...absoluteMax, step: 10)
            .dfSliderStyle(.labeled)
        }
    }
}

4. DFCheckbox

DFCheckbox(isChecked: $agreed, label: "I agree to the Terms of Service")
DFCheckbox(isChecked: $selected)   // no label — add accessibilityLabel
DFCheckbox(isChecked: $locked, label: "Admin only").disabled(true)

Multi-Select Pattern

struct InterestPicker: View {
    let interests = ["Design", "Engineering", "Marketing", "Product"]
    @State private var selected: Set<String> = []

    private func binding(for interest: String) -> Binding<Bool> {
        Binding(
            get: { selected.contains(interest) },
            set: { isOn in
                if isOn { selected.insert(interest) }
                else { selected.remove(interest) }
            }
        )
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            ForEach(interests, id: \.self) { interest in
                DFCheckbox(isChecked: binding(for: interest), label: interest)
            }
        }
    }
}

Example 1 — Terms Agreement

struct TermsStep: View {
    @State private var agreedTerms = false
    @State private var agreedPrivacy = false
    @State private var agreedMarketing = false

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            DFText("Before you continue", style: .headline)

            DFCard {
                VStack(alignment: .leading, spacing: 14) {
                    DFCheckbox(isChecked: $agreedTerms,
                               label: "I accept the Terms of Service")
                    DFDivider()
                    DFCheckbox(isChecked: $agreedPrivacy,
                               label: "I acknowledge the Privacy Policy")
                    DFDivider()
                    DFCheckbox(isChecked: $agreedMarketing,
                               label: "Send me product news (optional)")
                }
                .padding()
            }

            DFButton("Continue") { proceed() }
                .disabled(!agreedTerms || !agreedPrivacy)
        }
    }

    func proceed() { }
}

Example 2 — Multi-Select Interests

struct OnboardingInterestsView: View {
    struct Interest: Identifiable {
        let id: String; let icon: String; let label: String
    }

    let interests: [Interest] = [
        .init(id: "design", icon: "paintbrush", label: "UI/UX Design"),
        .init(id: "ios", icon: "iphone", label: "iOS Development"),
        .init(id: "backend", icon: "server.rack", label: "Backend Engineering"),
        .init(id: "data", icon: "chart.bar", label: "Data & Analytics"),
    ]

    @State private var selected: Set<String> = []

    private func binding(for id: String) -> Binding<Bool> {
        Binding(
            get: { selected.contains(id) },
            set: { if $0 { selected.insert(id) } else { selected.remove(id) } }
        )
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            DFText("What are you interested in?", style: .headline)
            DFText("Choose all that apply.", style: .caption)

            LazyVGrid(columns: [.init(.flexible()), .init(.flexible())], spacing: 10) {
                ForEach(interests) { interest in
                    DFCard(padding: 12) {
                        HStack(spacing: 8) {
                            DFIcon(interest.icon)
                            DFText(interest.label, style: .body)
                            Spacer()
                            DFCheckbox(isChecked: binding(for: interest.id))
                        }
                    }
                    .onTapGesture {
                        binding(for: interest.id).wrappedValue.toggle()
                    }
                }
            }
        }
    }
}

Example 3 — Permissions Checklist

struct PermissionsEditor: View {
    enum Permission: String, CaseIterable, Identifiable {
        case read = "Read", write = "Write", delete = "Delete", admin = "Administer"
        var id: Self { self }
    }

    @Binding var granted: Set<Permission>
    let isEditable: Bool

    var body: some View {
        DFCard {
            VStack(spacing: 0) {
                ForEach(Permission.allCases) { perm in
                    HStack {
                        DFText(perm.rawValue, style: .body)
                        Spacer()
                        DFCheckbox(isChecked: Binding(
                            get: { granted.contains(perm) },
                            set: { isOn in
                                if isOn { granted.insert(perm) }
                                else { granted.remove(perm) }
                            }
                        ))
                        .disabled(!isEditable)
                    }
                    .padding(.vertical, 10)
                    if perm != Permission.allCases.last { DFDivider() }
                }
            }
            .padding(.horizontal)
        }
    }
}

5. DFPicker

DFPicker("Role", selection: $role) {
    ForEach(Role.allCases, id: \.self) { r in Text(r.rawValue).tag(r) }
}
.dfPickerStyle(.menu)       // default — dropdown
.dfPickerStyle(.segmented)  // horizontal segmented control
.dfPickerStyle(.wheel)      // spinning drum (iOS/iPadOS)
.dfPickerStyle(.glass)      // iOS 26+

Make your enum CaseIterable, Hashable, and Sendable:

enum ExportFormat: String, CaseIterable, Hashable, Sendable, Identifiable {
    case pdf = "PDF", docx = "Word (.docx)", markdown = "Markdown"
    var id: Self { self }
}

Example 1 — Role Picker in a Form

struct InviteUserForm: View {
    @State private var email = ""
    @State private var role: Role = .viewer
    @State private var team = "Engineering"
    let teams = ["Design", "Engineering", "Marketing", "Product"]

    var body: some View {
        DFCard {
            VStack(spacing: 16) {
                DFText("Invite team member", style: .headline)
                DFTextField("Email address", text: $email, leadingIcon: "envelope")
                DFPicker("Role", selection: $role) {
                    ForEach(Role.allCases, id: \.self) { r in Text(r.rawValue).tag(r) }
                }
                DFPicker("Team", selection: $team) {
                    ForEach(teams, id: \.self) { Text($0).tag($0) }
                }
                DFButton("Send invite") { sendInvite() }
            }
            .padding()
        }
    }

    func sendInvite() { }
}

Example 2 — Sort Order Segmented Control

enum SortField: String, CaseIterable, Hashable, Sendable {
    case updated = "Updated", name = "Name", created = "Created"
}

DFPicker("Sort", selection: $sortField) {
    ForEach(SortField.allCases, id: \.self) { f in Text(f.rawValue).tag(f) }
}
.dfPickerStyle(.segmented)

Example 3 — Country Wheel Picker (in Sheet)

struct CountryPickerRow: View {
    @State private var selectedCountry = "United States"
    @State private var showingPicker = false

    let countries = Locale.isoRegionCodes
        .compactMap { Locale.current.localizedString(forRegionCode: $0) }
        .sorted()

    var body: some View {
        DFListRow("Country", subtitle: selectedCountry, accessory: .navigation)
            .onTapGesture { showingPicker = true }
            .overlay {
                DFSheet(isPresented: $showingPicker) {
                    VStack(spacing: 0) {
                        DFText("Select Country", style: .headline).padding()
                        DFPicker("Country", selection: $selectedCountry) {
                            ForEach(countries, id: \.self) { Text($0).tag($0) }
                        }
                        .dfPickerStyle(.wheel)
                        DFButton("Done") { showingPicker = false }.padding()
                    }
                }
            }
    }
}

Example 4 — Color / Theme Picker with Preview

struct ThemePickerRow: View {
    @Binding var appTheme: AppTheme

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            DFPicker("Theme", selection: $appTheme) {
                ForEach(AppTheme.allCases, id: \.self) { theme in
                    HStack {
                        Circle().fill(Color(hex: theme.accentHex)).frame(width: 12, height: 12)
                        Text(theme.rawValue)
                    }
                    .tag(theme)
                }
            }

            // Live preview swatch
            DFCard {
                HStack(spacing: 12) {
                    DFAvatar(name: "Alex Kim")
                    VStack(alignment: .leading) {
                        DFText("Alex Kim", style: .headline)
                        DFText("Product Designer", style: .caption)
                    }
                    Spacer()
                    DFButton("Follow") { }.dfButtonStyle(.secondary)
                }
                .padding()
            }
            .environment(\.dfTheme, appTheme.preset.theme)
        }
    }
}

6. DFDatePicker

DFDatePicker("Birthday", selection: $birthdate)                            // date only
DFDatePicker("Meeting", selection: $date, displayedComponents: [.date, .hourAndMinute])
DFDatePicker("Reminder at", selection: $time, displayedComponents: .hourAndMinute)

// Date range clamping
DFDatePicker("Appointment", selection: $date, in: Date()...)               // future only
DFDatePicker("Birthday", selection: $birthdate, in: ...Date())             // past only
DFDatePicker("Expiry", selection: $expiry, in: Date()...oneYearFromNow)    // bounded

// Styles
.dfDatePickerStyle(.compact)    // default — tappable label, overlay picker
.dfDatePickerStyle(.graphical)  // full calendar grid inline
.dfDatePickerStyle(.wheel)      // spinning drums (use in sheet/popover)
.dfDatePickerStyle(.glass)      // iOS 26+

Example 1 — Date of Birth Picker

struct ProfileBirthdateRow: View {
    @State private var birthdate = Calendar.current.date(
        from: DateComponents(year: 1990, month: 1, day: 1))!

    private var maximumDate: Date {
        Calendar.current.date(byAdding: .year, value: -13, to: Date())!
    }

    private var formattedAge: String {
        let comps = Calendar.current.dateComponents([.year], from: birthdate, to: Date())
        return comps.year.map { "\($0) years old" } ?? ""
    }

    var body: some View {
        DFCard {
            VStack(alignment: .leading, spacing: 12) {
                DFText("Date of birth", style: .headline)
                DFDatePicker("Birthday", selection: $birthdate, in: ...maximumDate)
                    .dfDatePickerStyle(.compact)
                DFText(formattedAge, style: .caption)
            }
            .padding()
        }
    }
}

Example 2 — Event Scheduling with Time

struct EventSchedulerView: View {
    @State private var startDate = Date()
    @State private var endDate = Date().addingTimeInterval(3600)

    private var duration: String {
        let interval = endDate.timeIntervalSince(startDate)
        let h = Int(interval) / 3600; let m = (Int(interval) % 3600) / 60
        return h > 0 ? "\(h)h \(m)m" : "\(m) minutes"
    }

    var body: some View {
        DFCard {
            VStack(alignment: .leading, spacing: 16) {
                DFText("Schedule Event", style: .headline)

                DFDatePicker("Starts", selection: $startDate, in: Date()...,
                             displayedComponents: [.date, .hourAndMinute])
                    .dfDatePickerStyle(.compact)
                    .onChange(of: startDate) { _, newStart in
                        if endDate <= newStart {
                            endDate = newStart.addingTimeInterval(3600)
                        }
                    }

                DFDatePicker("Ends", selection: $endDate, in: startDate...,
                             displayedComponents: [.date, .hourAndMinute])
                    .dfDatePickerStyle(.compact)

                HStack {
                    DFIcon("clock")
                    DFText("Duration: \(duration)", style: .caption)
                }

                DFButton("Add to calendar", icon: "calendar.badge.plus") { addToCalendar() }
                    .dfButtonStyle(.secondary)
            }
            .padding()
        }
    }

    func addToCalendar() { }
}

Example 3 — Expiry Date with Graphical Calendar

struct SubscriptionExpiryEditor: View {
    @State private var expiryDate: Date
    private let maxDate = Calendar.current.date(byAdding: .year, value: 5, to: Date())!

    init(currentExpiry: Date) { _expiryDate = State(initialValue: currentExpiry) }

    private var isExpiringSoon: Bool {
        expiryDate.timeIntervalSince(Date()) < 30 * 24 * 3600
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                DFText("Expiry Date", style: .headline)
                Spacer()
                if isExpiringSoon { DFBadge(text: "Expiring soon", color: .orange) }
            }

            DFCard {
                DFDatePicker("Expiry", selection: $expiryDate, in: Date()...maxDate)
                    .dfDatePickerStyle(.graphical)
                    .padding(.vertical, 8)
            }

            DFText(
                "Active until \(expiryDate.formatted(date: .long, time: .omitted))",
                style: .caption
            )
        }
    }
}

7. Complete Settings Form — All Five Controls

import DesignFoundation

struct AccountSettingsForm: View {
    @Environment(\.dfTheme) private var theme

    @State private var displayName = "Alex Kim"
    @State private var role: AccountRole = .manager
    @State private var birthdate = Date()

    @State private var language: Language = .english
    @State private var fontSize: Double = 16
    @State private var reducedMotion = false

    @State private var pushEnabled = true
    @State private var emailEnabled = false
    @State private var notifyOnMention = true
    @State private var notifyOnComment = true

    @State private var agreedMarketing = false
    @State private var isSaving = false

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 24) {

                // Profile
                sectionHeader("Profile", icon: "person.crop.circle")
                DFCard {
                    VStack(spacing: 16) {
                        DFTextField("Display name", text: $displayName, leadingIcon: "person")
                        DFPicker("Role", selection: $role) {
                            ForEach(AccountRole.allCases, id: \.self) { r in
                                Text(r.rawValue).tag(r)
                            }
                        }
                        DFDatePicker("Date of birth", selection: $birthdate, in: ...Date())
                    }
                    .padding()
                }

                // Preferences
                sectionHeader("Preferences", icon: "slider.horizontal.3")
                DFCard {
                    VStack(spacing: 16) {
                        DFPicker("Language", selection: $language) {
                            ForEach(Language.allCases) { lang in Text(lang.rawValue).tag(lang) }
                        }
                        DFDivider()
                        DFSlider("Text size", value: $fontSize, in: 12...24, step: 1)
                            .dfSliderStyle(.labeled)
                        DFDivider()
                        DFListRow("Reduce motion") {
                            DFToggle("", isOn: $reducedMotion)
                        }
                    }
                    .padding()
                }

                // Notifications
                sectionHeader("Notifications", icon: "bell")
                DFCard {
                    VStack(spacing: 0) {
                        VStack(spacing: 16) {
                            DFListRow("Push notifications") { DFToggle("", isOn: $pushEnabled) }
                            DFListRow("Email") { DFToggle("", isOn: $emailEnabled) }
                        }
                        DFDivider().padding(.vertical, 12)
                        VStack(alignment: .leading, spacing: 12) {
                            DFText("Notify me when", style: .caption)
                            DFCheckbox(isChecked: $notifyOnMention, label: "Someone mentions me")
                            DFCheckbox(isChecked: $notifyOnComment, label: "Someone comments")
                        }
                    }
                    .padding()
                }

                // Consent
                DFCard {
                    DFCheckbox(isChecked: $agreedMarketing,
                               label: "Send me product updates and offers")
                        .padding()
                }

                DFButton("Save changes", isLoading: isSaving) { saveSettings() }
                    .padding(.top, 8)
            }
            .padding()
        }
    }

    @ViewBuilder
    private func sectionHeader(_ title: String, icon: String) -> some View {
        HStack(spacing: 6) {
            DFIcon(icon, size: .sm, color: theme.colors.primary)
            DFText(title.uppercased(), style: .caption)
        }
    }

    private func saveSettings() {
        isSaving = true
        Task {
            try? await Task.sleep(for: .seconds(1))
            isSaving = false
            DFToastQueue.shared.show("Settings saved", style: .success)
        }
    }
}

8. Accessibility

DFToggle

  • label string becomes accessibilityLabel. Always provide one.
  • Inside DFListRow, the row title doesn't reach the toggle — still set the label.

DFSlider

  • Always provide accessibilityLabel and accessibilityValue for stepped sliders.
DFSlider("Quality", value: $quality, in: 1...5, step: 1)
    .accessibilityLabel("Export quality")
    .accessibilityValue(qualityNames[Int(quality)])

DFCheckbox

  • The label is announced as label + role ("checkbox, unchecked").
  • For checkboxes without visible labels, supply accessibilityLabel explicitly.

DFPicker

  • The picker label announces as "Role, popup button, currently Viewer."
  • Never leave the label empty.

DFDatePicker

  • The .graphical style exposes individual days as focusable accessibility elements.
  • Use accessibilityHint when range clamping may surprise the user.
DFDatePicker("Return date", selection: $returnDate, in: departureDate...)
    .accessibilityHint("Must be on or after your departure date")

See also: Text-Fields-and-Inputs · Buttons · Lists-Tables-and-Data · Alerts-and-Toasts · Theming

Clone this wiki locally