top of page

Complete Tutorial How to Make Action in Picker SwiftUI

  • backlinksindiit
  • Oct 9
  • 6 min read

SwiftUI Picker actions confuse developers more than they should. You create a Picker, bind it to a state variable, and... nothing happens when users select values. The documentation says Pickers update state automatically. What they skip over? Triggering custom actions when that state changes.

Spent three days debugging this last month. Picker selection worked fine. My navigation logic? Silent. Turns out .onChange placement matters way more than Apple admits.

The onChange Mystery Everyone Encounters

How to make action in Picker SwiftUI starts with understanding where SwiftUI looks for modifiers. Stick .onChange directly on the Picker. Not on the parent view. Not on a child element. On the Picker itself.

struct ContentView: View { @State private var selectedOption = 0 let options = ["Add", "Edit", "Delete"] var body: some View { Picker("Actions", selection: $selectedOption) { ForEach(0..<options.count, id: \.self) { index in Text(options

) } } .onChange(of: selectedOption) { oldValue, newValue in handleSelection(newValue) } } func handleSelection(_ value: Int) { switch value { case 0: print("Adding item") case 1: print("Editing item") case 2: print("Deleting item") default: break } } }

That oldValue, newValue syntax? New in iOS 17. Before that, onChange only gave you the new value. Breaking change. Your iOS 16 code throws errors on iOS 17+.

iOS Version Differences That Break Things

The onChange signature changed between iOS versions. iOS 14-16 used this:

.onChange(of: selectedOption) { value in handleSelection(value) }

iOS 17+ requires both old and new values:

.onChange(of: selectedOption) { oldValue, newValue in handleSelection(newValue) }

Supporting both? Conditional compilation helps:

#if swift(>=5.9) .onChange(of: selectedOption) { oldValue, newValue in handleSelection(newValue) } #else .onChange(of: selectedOption) { value in handleSelection(value) } #endif

Messy. Works. Keeps your app running across iOS versions without crashes.

Tag Values and Identifiable Protocol

Picker selection binding depends on tag values matching your state type. String state? String tags. Int state? Int tags. Enum state? Enum tags.

enum ActionType: String, CaseIterable, Identifiable { case add = "Add Item" case edit = "Edit Item" case delete = "Delete Item" var id: String { self.rawValue } } struct ActionPicker: View { @State private var selectedAction: ActionType = .add var body: some View { Picker("Select Action", selection: $selectedAction) { ForEach(ActionType.allCases) { action in Text(action.rawValue).tag(action) } } .onChange(of: selectedAction) { _, newValue in performAction(newValue) } } func performAction(_ action: ActionType) { switch action { case .add: addNewItem() case .edit: editCurrentItem() case .delete: removeItem() } } }

Conforming to Identifiable eliminates those id: \.self workarounds in ForEach loops. Cleaner code. Fewer bugs.

Navigation Triggers From Picker Selection

Moving to different screens based on Picker selection? NavigationLink does not play nice with Picker. The selection changes but navigation does not fire. Binding issues everywhere.

Use NavigationStack with a navigation path instead:

struct PickerNavigationView: View { @State private var selectedDestination: Destination? = nil @State private var pickerSelection = 0 enum Destination: Hashable { case addScreen case editScreen case deleteScreen } var body: some View { NavigationStack { VStack { Picker("Action", selection: $pickerSelection) { Text("Add").tag(0) Text("Edit").tag(1) Text("Delete").tag(2) } .pickerStyle(.segmented) .onChange(of: pickerSelection) { _, newValue in selectedDestination = mapToDestination(newValue) } } .navigationDestination(for: Destination.self) { destination in destinationView(for: destination) } } } func mapToDestination(_ value: Int) -> Destination? { switch value { case 0: return .addScreen case 1: return .editScreen case 2: return .deleteScreen default: return nil } } @ViewBuilder func destinationView(for destination: Destination) -> some View { switch destination { case .addScreen: Text("Add Screen") case .editScreen: Text("Edit Screen") case .deleteScreen: Text("Delete Screen") } } }

State-driven navigation. No hacky NavigationLink wrappers. Works reliably across iOS versions.

Picker Styles Affect Action Timing

Different picker styles fire onChange at different moments. Menu style triggers onChange immediately when users select an option. Wheel style? Only after the wheel stops spinning. Segmented style fires instantly on tap.

Picker("Style Test", selection: $selection) { ForEach(options, id: \.self) { option in Text(option) } } .pickerStyle(.menu) // Fires immediately // .pickerStyle(.wheel) // Fires after wheel stops // .pickerStyle(.segmented) // Fires on tap .onChange(of: selection) { _, newValue in print("Changed to: \(newValue)") }

Wheel style delays confuse users. They spin, release, and expect immediate feedback. Nothing happens for 0.5 seconds. Feels broken even though the code works correctly.

Observable Object Integration

Real apps need Picker actions to update ViewModels, not just local state. ObservableObject pattern keeps business logic separate from views.

class AppViewModel: ObservableObject { @Published var selectedFilter: FilterOption = .all @Published var items:

= [] enum FilterOption: String, CaseIterable { case all = "All Items" case active = "Active" case completed = "Completed" } func filterItems() { // Filter logic based on selectedFilter } } struct FilterPickerView: View { @StateObject private var viewModel = AppViewModel() var body: some View { Picker("Filter", selection: $viewModel.selectedFilter) { ForEach(AppViewModel.FilterOption.allCases, id: \.self) { filter in Text(filter.rawValue) } } .onChange(of: viewModel.selectedFilter) { _, _ in viewModel.filterItems() } } }

Published properties trigger view updates automatically. No manual refresh calls needed.

Working With Mobile Development Teams

Production apps built by experienced mobile app developer houston teams handle Picker actions differently than tutorials show. Error handling. Loading states. Network requests triggered by selection changes. These complexities require architecture beyond basic onChange calls.

class DataManager: ObservableObject { @Published var selectedCategory: Category? @Published var isLoading = false @Published var errorMessage: String? func loadData(for category: Category) async { isLoading = true errorMessage = nil do { // Simulated network request try await Task.sleep(nanoseconds: 1_000_000_000) // Load data logic isLoading = false } catch { errorMessage = error.localizedDescription isLoading = false } } }

Async operations from Picker selections need proper error boundaries. Users change selections rapidly. Cancel previous requests before starting new ones.

Common Mistakes That Kill User Experience

Triggering heavy operations directly in onChange blocks the main thread. Users see frozen UI when selecting Picker options. Spinning wheels. Unresponsive taps.

Wrong approach:

.onChange(of: selection) { _, newValue in processLargeDataset(newValue) // Blocks UI }

Better approach:

.onChange(of: selection) { _, newValue in Task { await processLargeDataset(newValue) } }

Task wrapping moves work off the main thread. UI stays responsive. Users keep interacting while processing happens in background.

Debugging Picker Actions

Print statements show you what fires when. Picker not triggering onChange? Check these:

Picker("Debug", selection: $selection) { ForEach(options, id: \.self) { option in Text(option) .tag(option) .onAppear { print("Option appeared: \(option)") } } } .onChange(of: selection) { oldValue, newValue in print("Old: \(oldValue), New: \(newValue)") } .onAppear { print("Picker appeared") }

Missing tag modifiers? Selection changes but binding does not update. Tags must match your selection type exactly.

Forms and Picker Integration

Pickers inside Forms behave differently. The Form container adds padding, styling, and touch target expansion. onChange still works but layout shifts affect user perception of when changes occur.

Form { Section(header: Text("Settings")) { Picker("Theme", selection: $selectedTheme) { Text("Light").tag(Theme.light) Text("Dark").tag(Theme.dark) Text("Auto").tag(Theme.auto) } .onChange(of: selectedTheme) { _, newTheme in applyTheme(newTheme) } } }

Form sections visually group related Pickers. Users understand which actions relate to which settings.

Testing Picker Actions

UI tests need to simulate Picker interactions. XCTest provides tap and selection APIs but they're finicky with different Picker styles.

func testPickerAction() throws { let app = XCUIApplication() app.launch() let picker = app.pickers["ActionPicker"] picker.tap() // Menu style app.buttons["Edit"].tap() // Verify result XCTAssertTrue(app.staticTexts["Edit Mode Active"].exists) }

Segmented pickers need button taps. Menu pickers need menu item selection. Wheel pickers? Swipe gestures that rarely work reliably in tests.

Performance Optimization Tips

Multiple Pickers triggering onChange creates cascading updates. Each change triggers view recalculation. Batch updates when possible:

@State private var category = 0 @State private var subcategory = 0 @State private var needsUpdate = false var body: some View { VStack { Picker("Category", selection: $category) { } .onChange(of: category) { _, _ in needsUpdate = true } Picker("Subcategory", selection: $subcategory) { } .onChange(of: subcategory) { _, _ in needsUpdate = true } } .onChange(of: needsUpdate) { _, shouldUpdate in if shouldUpdate { performBatchUpdate() needsUpdate = false } } }

Debouncing Picker actions prevents excessive API calls when users rapidly change selections.

SwiftUI Preview Limitations

Picker actions might work in preview but fail on device. Previews use simulated environments that skip certain lifecycle events. Always test on real hardware.

struct ActionPicker_Previews: PreviewProvider { static var previews: some View { ActionPicker() .previewDevice("iPhone 15 Pro") } }

Device-specific previews catch layout issues but not all behavioral bugs.

Picker actions in SwiftUI require understanding onChange timing, proper tag usage, and iOS version differences. Start with simple implementations. Add complexity as requirements grow. Test on devices, not just simulators.

Recent Posts

See All

Comments


bottom of page