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.
Comments