Skip to content

Picker and PickerStyles in SwiftUI | SwiftUI Bootcamp #38

Filter bars, category selectors, age wheels, dropdown menus — these all share the same SwiftUI component: Picker. This lesson covers how Picker binds to state, how .tag() connects each option to its value, and how swapping .pickerStyle() completely changes the appearance without touching the data logic.

What You'll Learn

  • How Picker binds a selection to @State using a binding, with options identified by .tag()
  • The difference between SegmentedPickerStyle, MenuPickerStyle, and WheelPickerStyle
  • How to use UISegmentedControl.appearance() to customize the segmented control's colors using UIKit

Mental Model

Picker is like a voting machine. You load it with options (the ForEach of candidates), each option gets an identity tag (the ballot ID), and the machine records which one is currently selected (the $selection binding). The visual form of the voting machine can change completely — from a row of physical buttons (SegmentedPickerStyle) to a wheel-spinning drum (WheelPickerStyle) to a dropdown menu (MenuPickerStyle) — but the underlying vote-counting mechanism is identical.

This separation between data logic and visual presentation is what makes Picker powerful. You wire up the data once, and then choosing a different .pickerStyle() is a one-line change.

Detailed Explanation

Picker(selection: $selection, label: Text("Picker")) { options } creates a picker that reads and writes to selection. Every time the user changes the selection, the binding writes the new value back.

Each option in the content closure must have a .tag() modifier that specifies what value to assign to selection when that option is chosen. The tag's type must match the selection's type. If selection is String, all tags must be String. Type mismatches are a common source of subtle bugs where the picker appears to work visually but selection never changes.

ForEach(filterOptions.indices) uses integer indices to iterate, then accesses filterOptions[index] for both the displayed text and the .tag(). This approach works well but ForEach(filterOptions, id: \.self) { option in Text(option).tag(option) } is more concise for simple string arrays.

Picker styles:

  • SegmentedPickerStyle() — renders as a segmented control (the tab-like buttons). Works best for 2–4 options. Can be customized via UISegmentedControl.appearance().
  • MenuPickerStyle() — renders as a tappable label that shows a dropdown menu. Good for 5+ options in a compact space.
  • WheelPickerStyle() — renders as a spinning wheel (like the classic UIDatePicker). Good for numeric ranges or many ordered options.

The UISegmentedControl.appearance() calls in init() use UIKit's appearance proxy to set the selected segment's background color to red and text color to white. This is a common UIKit bridge pattern for SwiftUI components that don't expose their UIKit equivalent's full styling API natively.

Code Structure

PickerBootcamp.swift demonstrates the SegmentedPickerStyle with a custom UIKit-themed selected state. The init() method contains the UIKit appearance customization. Three commented-out blocks show MenuPickerStyle with a custom button label, and WheelPickerStyle for numeric age selection — each sharing the same data binding pattern.

Complete Code

PickerBootcamp.swift

swift
import SwiftUI

struct PickerBootcamp: View {
    
    @State var selection: String = "Most Recent" // tracks which filter option is currently selected
    let filterOptions: [String] = [
        "Most Recent", "Most Popular", "Most Liked"
    ]
    
    init() {
        UISegmentedControl.appearance().selectedSegmentTintColor = UIColor.red // selected segment background color
        
        let attributes: [NSAttributedString.Key:Any] = [
            .foregroundColor : UIColor.white // white text on the selected (red) segment
        ]
        UISegmentedControl.appearance().setTitleTextAttributes(attributes, for: .selected)
    }
    
    var body: some View {
        
        Picker(
            selection: $selection,   // two-way binding: reads current selection, writes when user changes it
            label: Text("Picker"),   // label is required but not shown in SegmentedPickerStyle
            content: {
                ForEach(filterOptions.indices) { index in
                    Text(filterOptions[index])
                        .tag(filterOptions[index]) // tag must match selection type (String) — this is what gets written to $selection
                }
        })
            .pickerStyle(SegmentedPickerStyle()) // renders as the horizontal segmented control
            //.background(Color.red)
        
//        Picker(
//            selection: $selection,
//            label:
//                HStack {           // MenuPickerStyle uses the label as its trigger button
//                    Text("Filter:")
//                    Text(selection) // shows the current selection in the button label
//                }
//                .font(.headline)
//                .foregroundColor(.white)
//                .padding()
//                .padding(.horizontal)
//                .background(Color.blue)
//                .cornerRadius(10)
//                .shadow(color: Color.blue.opacity(0.3), radius: 10, x: 0, y: 10)
//            ,
//            content: {
//                ForEach(filterOptions, id: \.self) { option in
//                    HStack {
//                        Text(option)
//                        Image(systemName: "heart.fill")
//                    }
//                    .tag(option)   // tag value must match the selection binding type
//                }
//        })
//            .pickerStyle(MenuPickerStyle()) // dropdown menu — good for 5+ options
        
//        VStack {
//            HStack {
//                Text("Age:")
//                Text(selection)   // displays current wheel selection
//            }
//
//            Picker(
//                selection: $selection,
//                label: Text("Picker"),
//                content: {
//                    ForEach(18..<100) { number in
//                        Text("\(number)")
//                            .font(.headline)
//                            .foregroundColor(.red)
//                            .tag("\(number)") // converts Int to String to match selection type
//                    }
//            })
//                .pickerStyle(WheelPickerStyle()) // spinning drum — good for long numeric ranges
//                //.background(Color.gray.opacity(0.3))
//        }
        
    }
}

struct PickerBootcamp_Previews: PreviewProvider {
    static var previews: some View {
        PickerBootcamp()
    }
}

Code Walkthrough

  1. @State var selection: String = "Most Recent" — The current selection state. The Picker writes back to this whenever the user changes their choice. Any view reading selection updates reactively.

  2. init() with UISegmentedControl.appearance() — This is a UIKit appearance proxy. It applies globally to all UISegmentedControl instances in the app. Be aware of this: if you have other segmented controls in the app, they'll also be affected. Use with care in large apps.

  3. ForEach(filterOptions.indices) — Iterates using integer indices (0, 1, 2). Accessing the array with these indices gives both the display text and the tag value. The alternative ForEach(filterOptions, id: \.self) is equivalent and slightly cleaner.

  4. .tag(filterOptions[index]) — Each option must have a .tag() that matches the type of selection. If the types don't match (e.g., tagging with Int when selection is String), the picker renders correctly but selection never updates when the user taps. This silent failure is a common bug.

  5. .pickerStyle(SegmentedPickerStyle()) — The single line that changes the visual presentation. Uncommenting MenuPickerStyle() or WheelPickerStyle() and commenting this out will completely change the appearance while the data binding and tags remain unchanged.

  6. WheelPickerStyle with .tag("\(number)") — In the wheel example, ForEach(18..<100) produces Int values. Because selection is a String, the tag converts each Int to a String via "\(number)". If selection were Int, you'd use .tag(number) instead.

Common Mistakes

Mistake: Mismatching tag type with selection type
If selection is String and you write .tag(42) (an Int), the picker will show the options visually but will never update selection. The compiler may not warn you about this if the types are Hashable. Always verify that .tag(value) matches the type of selection.

Mistake: Not setting an initial selection value that matches one of the options
If selection = "Newest" but the options are ["Most Recent", "Most Popular"], none of them will appear selected initially. The picker renders without a highlighted option. The initial selection must exactly match one of the tag values.

Mistake: Using UISegmentedControl.appearance() changes that accidentally style all segment controls in the app
The appearance proxy is global. If this view is only one of many, the red selected color will appear everywhere. Scope the appearance changes carefully, or use .tint(.red) on the Picker in iOS 15+ for per-instance accent color control without UIKit.

Key Takeaways

  • Picker separates data logic (selection binding + tags) from visual presentation (pickerStyle) — you can change one without touching the other.
  • .tag(value) is the critical connector between each visual option and the value it writes to the selection binding — the types must match exactly.
  • Swap .pickerStyle() to change from segmented control to dropdown menu to spinning wheel without any changes to the data model.

Last updated: June 27, 2026

Released under the MIT License.