How to Build a Segmented Control in SwiftUI
Use SwiftUI's Picker view with the .pickerStyle(.segmented) modifier — it renders the native iOS segmented control automatically. Bind it to a @State property and tag each option with .tag().
import SwiftUI
struct TLDRView: View {
@State private var selected = "Day"
let options = ["Day", "Week", "Month"]
var body: some View {
Picker("Period", selection: $selected) {
ForEach(options, id: \.self) { option in
Text(option).tag(option)
}
}
.pickerStyle(.segmented)
.padding()
}
}
Full implementation
The example below models a calendar view filter — a common real-world use case. We back the picker with an enum for type safety, respond to selection changes using .onChange(of:) (iOS 17 two-closure variant), and show how the selected value drives downstream content. The #Preview macro at the bottom renders the finished component in Xcode's canvas.
import SwiftUI
// MARK: - Model
enum CalendarPeriod: String, CaseIterable, Identifiable {
case day = "Day"
case week = "Week"
case month = "Month"
case year = "Year"
var id: Self { self }
var systemImage: String {
switch self {
case .day: return "sun.max"
case .week: return "calendar.badge.clock"
case .month: return "calendar"
case .year: return "calendar.badge.plus"
}
}
}
// MARK: - View
struct CalendarFilterView: View {
@State private var selectedPeriod: CalendarPeriod = .week
var body: some View {
VStack(alignment: .leading, spacing: 20) {
// Segmented control using Picker + .segmented style
Picker("Calendar Period", selection: $selectedPeriod) {
ForEach(CalendarPeriod.allCases) { period in
Text(period.rawValue)
.tag(period)
}
}
.pickerStyle(.segmented)
.accessibilityLabel("Calendar view period")
// React to selection changes (iOS 17+ two-closure onChange)
.onChange(of: selectedPeriod) { oldValue, newValue in
print("Changed from \(oldValue.rawValue) to \(newValue.rawValue)")
}
// Content driven by selection
ContentPlaceholder(period: selectedPeriod)
}
.padding()
}
}
// MARK: - Downstream content
struct ContentPlaceholder: View {
let period: CalendarPeriod
var body: some View {
HStack(spacing: 12) {
Image(systemName: period.systemImage)
.font(.title2)
.foregroundStyle(.tint)
VStack(alignment: .leading) {
Text("Showing: \(period.rawValue) view")
.font(.headline)
Text("Your events for this \(period.rawValue.lowercased()) appear here.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
.animation(.easeInOut(duration: 0.2), value: period)
}
}
// MARK: - Preview
#Preview {
CalendarFilterView()
}
How it works
-
Enum-backed state. Declaring
CalendarPeriodasCaseIterable & IdentifiableletsForEach(CalendarPeriod.allCases)drive the picker automatically — add or remove a case and the segments update for free. No index math required. -
.pickerStyle(.segmented). This single modifier transforms a standardPickerinto the nativeUISegmentedControlunder the hood, giving you the system-standard look, haptic feedback, and Dynamic Type support at zero cost. -
.tag(period). Each child view inside the picker must be tagged with the exact value it represents. SwiftUI matches the tag against the$selectedPeriodbinding to know which segment is active. Mismatched tag types are a compile-time error, not a silent bug. -
.onChange(of:)iOS 17 form. The two-closure variantonChange(of:) { old, new in }replaces the deprecated single-closure form. This is how you trigger analytics events, persist the selection, or coordinate with a view model on every change. -
Downstream animation.
ContentPlaceholderattaches.animation(.easeInOut, value: period)so the content smoothly cross-fades whenever the user taps a new segment — no explicitwithAnimation {}call needed.
Variants
Icon-only segments
Swap Text for Image inside the picker to show SF Symbols instead of labels. Always provide an .accessibilityLabel on each image so VoiceOver users hear a meaningful description.
enum ViewMode: String, CaseIterable, Identifiable {
case list = "List"
case grid = "Grid"
case map = "Map"
var id: Self { self }
var icon: String {
switch self {
case .list: return "list.bullet"
case .grid: return "square.grid.2x2"
case .map: return "map"
}
}
}
struct IconSegmentView: View {
@State private var mode: ViewMode = .list
var body: some View {
Picker("View mode", selection: $mode) {
ForEach(ViewMode.allCases) { m in
Image(systemName: m.icon)
.accessibilityLabel(m.rawValue)
.tag(m)
}
}
.pickerStyle(.segmented)
.padding(.horizontal)
}
}
#Preview { IconSegmentView() }
Tinted segmented control
Apply .tint(.purple) (or any ShapeStyle) directly on the Picker to recolor the selected-segment indicator. This works on iOS 17+ without touching UIAppearance or any UIKit bridge — just .tint(.purple).pickerStyle(.segmented) and you're done. For a fully custom thumb background or font, drop down to UISegmentedControl.appearance() in your @main App init — SwiftUI will pick it up.
Common pitfalls
-
⚠️ iOS version — single-closure
.onChangeis deprecated. If you target iOS 16 as a minimum and iOS 17+ as preferred, the compiler will warn about the oldonChange(of:perform:)form. Use#if swift(>=5.9)availability guards or bump your deployment target to iOS 17 to use the two-closure API cleanly. -
⚠️ Tag type must match the binding exactly. A
Picker<_, String, _>whose children are tagged with anenumvalue will silently show no selection on older Xcode versions. The binding's generic type, the.tag()argument, and the@Stateproperty must all be the same concrete type — enums are safest because they are exhaustive and Codable-friendly. -
⚠️ Too many segments hurt usability. Apple's Human Interface Guidelines recommend no more than five segments in a segmented control. Beyond that, labels clip and tap targets shrink below the 44×44 pt minimum. For more options, switch to a
Menuor a full-screenPickersheet instead. -
⚠️ VoiceOver needs explicit labels on icon-only pickers. When you use
Image(systemName:)without accompanying text, VoiceOver reads the raw SF Symbol name (e.g., "square.grid.2x2"). Add.accessibilityLabel("Grid")to each image child so the control is usable without sight.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a segmented control in SwiftUI for iOS 17+. Use Picker with SegmentedPickerStyle. Back the selection with a CaseIterable enum for type safety. Make it accessible (VoiceOver labels on all segments). Add a #Preview with realistic sample data showing the control filtering a list of content below it.
In Soarias's Build phase, paste this prompt into the active task to scaffold the segmented control component, wire it to your existing view model, and get a SwiftData-backed preview in one shot — no manual boilerplate required.
Related
FAQ
Does this work on iOS 16?
Yes — Picker with .pickerStyle(.segmented) has been available since iOS 13. The code in this guide uses the iOS 17 two-closure .onChange(of:) API; if you need iOS 16 support, replace it with the single-closure form .onChange(of: selectedPeriod) { newValue in … } and suppress the deprecation warning with a // TODO: remove when min target is iOS 17 comment.
Can I use custom views (not just Text/Image) inside a segmented Picker?
Only Text and Image are officially supported as segment content for SegmentedPickerStyle. You can use Label (an icon + text combination) and SwiftUI will render the text portion only inside the segment on iOS — the icon appears when the same label is used in other contexts like toolbars. For fully custom segment layouts (e.g., a badge count on each tab), you'll need to build a custom control from HStack + Button with manual selection tracking.
What's the UIKit equivalent?
UISegmentedControl. In UIKit you'd call addTarget(_:action:for: .valueChanged) and read selectedSegmentIndex. SwiftUI's Picker(.segmented) wraps this control internally, so you get the same native rendering with far less code and a clean declarative binding. If you need UIKit-level customization (custom fonts, images for every state), use UISegmentedControl.appearance() in your app's init() — SwiftUI picks it up automatically.
Last reviewed: 2026-05-11 by the Soarias team.