Build SwiftUI Segmented Customizable Control

Master the techniques for creating polished, production-ready segmented controls with smooth animations and custom styling in SwiftUI

Segmented controls are among the most versatile UI components in iOS and macOS development, allowing users to select from a small set of mutually exclusive options. While SwiftUI provides a built-in segmented picker style, developers often need greater customization than the native component offers. This comprehensive guide explores both the straightforward approach using SwiftUI's native Picker and advanced techniques for building fully customizable segmented controls with smooth animations, custom styling, and accessibility support.

Whether you're building a filter interface for a content-heavy app, a mode switcher for a document editor, or a preference selector for user settings, understanding how to implement and customize segmented controls is an essential skill for any SwiftUI developer.

Understanding the Native Segmented Picker

SwiftUI's Picker view with the .segmented style provides the quickest path to a functional segmented control. This approach leverages the platform's native styling and automatically handles many edge cases that would otherwise require additional development effort. The implementation requires minimal code and integrates seamlessly with SwiftUI's data binding system, making it an excellent starting point for projects that don't require extensive customization. For teams working on custom web development projects, understanding these foundational patterns is essential for building maintainable codebases.

The native picker automatically adapts to the current platform's design language, providing a consistent experience for users across iOS and macOS. It handles selection state management internally and provides basic accessibility support out of the box. For simple use cases where the default blue styling and basic behavior meet your requirements, the segmented picker style offers the fastest path to a working implementation.

Basic Segmented Picker Implementation
1import SwiftUI2 3struct ContentView: View {4 @State private var selectedItem = "Item 1"5 let items = ["Item 1", "Item 2", "Item 3"]6 7 var body: some View {8 VStack {9 Picker("Select an item", selection: $selectedItem) {10 ForEach(items, id: \.self) { item in11 Text(item).tag(item)12 }13 }14 .pickerStyle(.segmented)15 16 Text("Selected item: \(selectedItem)")17 .padding()18 }19 .padding()20 }21}

Why Build a Custom Segmented Control?

While the native segmented picker serves many use cases effectively, it comes with significant limitations that often prompt developers to seek alternatives. According to LogRocket's analysis of custom segmented control implementations, the built-in component offers restricted control over visual styling, preventing developers from customizing colors, typography, spacing, and interactive states to match their app's design system.

  • Limited Styling Control: The native picker uses fixed blue styling with no ability to customize background colors, text colors, or selection indicators beyond system defaults. This makes it difficult to align the component with custom design systems that use specific color palettes.

  • No Custom Animations: Selection changes in the native picker result in abrupt transitions without any animation. Modern iOS interfaces increasingly expect smooth sliding effects that indicate the relationship between selection states, which the native picker cannot provide.

  • Design System Mismatch: Apple HIG guidelines continue to evolve, and design systems often require specific corner radii, shadows, spacing, and typography that differ from platform defaults. The native picker cannot accommodate these variations.

  • Custom Behavior Restrictions: Adding features like icons alongside text, badges indicating notification counts, or dynamic content within options becomes challenging or impossible with the native implementation.

For applications where brand consistency, polished animations, or unique interactive behaviors are important, building a custom segmented control provides the flexibility needed to create a truly differentiated user experience. Our web development team specializes in creating custom UI components that align with your brand guidelines and design system requirements.

The Foundation: Building with HStack and Buttons

Creating a custom segmented control from scratch begins with understanding the fundamental building blocks. At its core, a segmented control is simply a horizontal arrangement of tappable areas, which we implement using an HStack containing Button views. This approach provides complete control over every aspect of the component's appearance and behavior, from individual button styling to the overall layout and spacing.

The foundation starts with defining a selection state using an enum that conforms to CaseIterable for automatic option generation. Each option becomes a case in the enum, and the current selection is stored in a @State property. The HStack arranges buttons horizontally, with each button updating the selection when tapped. The buttonStyle(.plain) modifier removes default button styling, allowing custom styling to take precedence.

By using an enum for state management, we gain type safety and compile-time checking. The enum also provides a natural place to associate display strings, icons, or other metadata with each option through computed properties or helper functions.

Basic HStack + Button Segmented Control
1enum SegmentedControlState: String, CaseIterable, Identifiable {2 var id: Self { self }3 4 case option1 = "Option 1"5 case option2 = "Option 2"6 case option3 = "Option 3"7}8 9struct BasicSegmentedControl: View {10 @State private var state: SegmentedControlState = .option111 12 var body: some View {13 HStack(spacing: 2) {14 ForEach(SegmentedControlState.allCases) { option in15 Button {16 state = option17 } label: {18 Text(option.rawValue)19 .font(.subheadline)20 .fontWeight(.medium)21 .foregroundColor(state == option ? .white : .primary)22 .frame(maxWidth: .infinity)23 .padding(.vertical, 10)24 .background(25 state == option ? Color.blue : Color.clear26 )27 }28 .buttonStyle(.plain)29 }30 }31 .background(32 Capsule()33 .fill(Color.blue.opacity(0.1))34 )35 .padding(4)36 }37}

Creating Smooth Selection Animations with matchedGeometryEffect

The matchedGeometryEffect modifier is SwiftUI's powerful tool for creating seamless animations between views that share the same identifier. In the context of a segmented control, this effect enables the background highlight to smoothly slide from one option to another as the user makes selections. According to the Nil Coalescing tutorial on matchedGeometryEffect implementations, this approach creates a polished and engaging user experience that feels native to Apple's platforms.

Key Concepts

  • @Namespace: Creates a unique namespace for matching views across different view hierarchies. This namespace serves as the coordinate system for all geometry effects within it.

  • id Parameter: Each view receives a unique identifier within the namespace. When the identifier changes, SwiftUI animates the geometry transition automatically.

  • isSource Parameter: Controls whether the view provides geometry data (true) or receives it (false). Typically, buttons act as sources while the background is the receiver.

  • withAnimation: Wraps state changes to trigger the animation. The animation curve and duration control the feel of the transition.

The animation works by establishing a namespace that links views together and then assigning unique identifiers to each option's geometry. When the selection changes, SwiftUI automatically animates the background shape from its previous position to the new one, handling all the interpolation mathematics behind the scenes.

Matched Geometry Effect Implementation
1struct AnimatedSegmentedControl: View {2 @State private var state: SegmentedControlState = .option13 @Namespace private var segmentedControl4 5 var body: some View {6 HStack(spacing: 2) {7 ForEach(SegmentedControlState.allCases) { option in8 Button {9 withAnimation(.easeInOut(duration: 0.3)) {10 self.state = option11 }12 } label: {13 Text(option.rawValue)14 .font(.subheadline)15 .fontWeight(.medium)16 .foregroundColor(state == option ? .white : .primary)17 .frame(maxWidth: .infinity)18 .padding(.vertical, 10)19 }20 .buttonStyle(.plain)21 .matchedGeometryEffect(22 id: option,23 in: segmentedControl,24 properties: .frame,25 isSource: true26 )27 }28 }29 .background(30 Capsule()31 .fill(Color.blue)32 .matchedGeometryEffect(33 id: state,34 in: segmentedControl,35 properties: .frame,36 isSource: false37 )38 )39 .padding(4)40 .background(41 Capsule()42 .stroke(Color.blue.opacity(0.3), lineWidth: 1)43 )44 }45}

Advanced Customization Techniques

Custom segmented controls can incorporate numerous advanced features that enhance usability and align with modern app design standards. Each customization option provides opportunities to match your app's design language while maintaining the core interaction pattern users expect from segmented controls.

Colors

Custom color schemes allow the segmented control to integrate with any design system. You can define selected and unselected colors, background tints, and border colors that align with your app's palette. Using Color literals or custom Color extensions ensures consistency throughout your codebase.

var selectedColor: Color = .blue
var unselectedColor: Color = .gray.opacity(0.3)

Typography

Font customization enables you to match heading hierarchies and text styles. Consider using dynamic type text styles to support accessibility preferences while maintaining visual consistency.

Text(option.rawValue)
 .font(.system(size: 14, weight: .semibold))
 .fontDesign(.rounded)

Spacing and Padding

Controlled spacing affects the perceived importance of options and touch target sizes. Padding between options and the container edge creates visual breathing room while maintaining compactness.

Corner Radius

Various corner styles create different visual effects. Capsule shapes work well for sleek, modern interfaces, while smaller corner radii can create a more traditional appearance.

.background(
 RoundedRectangle(cornerRadius: 12)
 .fill(selectedColor)
)

Shadows and Elevation

Drop shadows create depth and indicate interactivity, while different shadow intensities can distinguish pressed states from normal states.

.shadow(color: .black.opacity(0.15), radius: 2, x: 0, y: 1)

Gradients

Linear and angular gradients add visual interest and can indicate selected states more subtly than solid colors. Animated gradients can create engaging interactions.

Fully Customizable Segmented Control
1struct CustomizableSegmentedControl<T: Hashable>: View {2 let options: [T]3 @Binding var selection: T4 let titleProvider: (T) -> String5 6 // Customization options7 var selectedColor: Color = .blue8 var unselectedColor: Color = .clear9 var textColor: Color = .primary10 var selectedTextColor: Color = .white11 var cornerRadius: CGFloat = 1212 var shadowRadius: CGFloat = 213 var font: Font = .subheadline.weight(.medium)14 var animationDuration: Double = 0.315 16 @Namespace private var animation17 18 var body: some View {19 HStack(spacing: 2) {20 ForEach(options, id: \.self) { option in21 Button {22 withAnimation(.easeInOut(duration: animationDuration)) {23 selection = option24 }25 } label: {26 Text(titleProvider(option))27 .font(font)28 .foregroundColor(29 selection == option ? selectedTextColor : textColor30 )31 .frame(maxWidth: .infinity)32 .padding(.vertical, 10)33 .padding(.horizontal, 16)34 }35 .buttonStyle(.plain)36 .matchedGeometryEffect(37 id: option,38 in: animation39 )40 }41 }42 .background(43 Capsule()44 .fill(unselectedColor)45 )46 .overlay(47 Capsule()48 .stroke(selectedColor.opacity(0.2), lineWidth: 1)49 )50 .shadow(color: .black.opacity(0.1), radius: shadowRadius, y: 1)51 .padding(4)52 }53}

Accessibility Considerations

Building inclusive user interfaces requires thoughtful attention to accessibility from the earliest stages of component development. Segmented controls present unique accessibility challenges because the visual representation of a selected state must be communicated to users who cannot perceive the visual cues. The Nil Coalescing guide on custom segmented controls emphasizes that VoiceOver support, dynamic type scaling, and appropriate accessibility labels are essential components of a truly accessible segmented control implementation. For professional applications, ensuring accessibility compliance is a critical aspect of web development services that serve diverse user populations.

VoiceOver Support

VoiceOver users need to understand both the current selection and the available options. Each button should have clear accessibility labels that indicate both the option name and its selection state.

Accessibility Labels and Hints

Provide descriptive labels that include context about the control's purpose. Accessibility hints guide users on how to interact with the component.

accessibilityRepresentation

For custom components with complex interactions, accessibilityRepresentation allows you to provide an alternative accessibility view that VoiceOver can navigate more effectively. This is particularly useful when the visual implementation differs significantly from standard controls.

Dynamic Type

Ensure text scales appropriately with user-selected font sizes. Use dynamic type text styles and test with accessibility settings enabled to verify readability and layout integrity.

Accessibility Implementation
1struct AccessibleSegmentedControl<T: Hashable>: View {2 let options: [T]3 @Binding var selection: T4 let labelProvider: (T) -> String5 let title: String6 7 var body: some View {8 VStack(alignment: .leading, spacing: 8) {9 Text(title)10 .font(.caption)11 .foregroundColor(.secondary)12 13 // Custom visual implementation14 HStack(spacing: 2) {15 ForEach(options, id: \.self) { option in16 Button {17 selection = option18 } label: {19 Text(labelProvider(option))20 .font(.subheadline)21 .fontWeight(.medium)22 .frame(maxWidth: .infinity)23 .padding(.vertical, 10)24 }25 .buttonStyle(.plain)26 .accessibilityLabel("\(labelProvider(option)), \(selection == option ? "selected" : "not selected")")27 .accessibilityHint("Tap to select \(labelProvider(option))")28 }29 }30 .background(31 Capsule()32 .fill(Color.blue.opacity(0.1))33 )34 }35 // Alternative accessibility representation for VoiceOver36 .accessibilityRepresentation {37 Picker(title, selection: $selection) {38 ForEach(options, id: \.self) { option in39 Text(labelProvider(option))40 .tag(option)41 }42 }43 .pickerStyle(.segmented)44 }45 }46}47 48// Usage example49struct SettingsView: View {50 @State private var themeSelection: ThemeOption = .light51 52 var body: some View {53 AccessibleSegmentedControl(54 options: ThemeOption.allCases,55 selection: $themeSelection,56 labelProvider: { $0.displayName },57 title: "App Theme"58 )59 }60}

Building a Reusable Component

Transforming a custom segmented control into a reusable SwiftUI component requires careful API design and thorough testing across different contexts. A well-designed reusable component should accept its options as a parameter, expose the selection through a binding, and provide sensible defaults while allowing full customization of its appearance.

Key Design Principles

Generic Selection Type: Using a generic type parameter constrained to Hashable allows the component to work with any hashable type--strings, enums, integers, or custom types. This flexibility makes the component useful across many different contexts without requiring type-specific implementations.

Binding API: The selection is exposed through a @Binding, which creates a two-way connection to the parent view's state. This approach follows SwiftUI's data flow principles and allows the parent to respond to selection changes while keeping state ownership centralized.

Customization Points: Public properties with default values allow callers to customize appearance while maintaining sensible defaults. Using the View protocol extension pattern for styling methods keeps the component's API clean and discoverable.

Clear Documentation: Providing usage examples and parameter descriptions helps other developers understand how to integrate the component effectively.

Testing Considerations

Before releasing a reusable component, test it with various option counts, different data types, rapid selection changes, and accessibility settings enabled. Edge cases like single-option and two-option segmented controls deserve special attention to ensure graceful handling.

Production-Ready Reusable Segmented Control
1import SwiftUI2 3/// A customizable segmented control component that supports smooth animations4/// and full styling customization.5///6/// ## Usage7/// ```swift8/// enum FilterOption: String, CaseIterable {9/// case all = "All"10/// case active = "Active"11/// case completed = "Completed"12/// }13///14/// @State private var selection: FilterOption = .all15///16/// var body: some View {17/// CustomSegmentedControl(18/// options: FilterOption.allCases,19/// selection: $selection,20/// titleProvider: { $0.rawValue }21/// )22/// }23/// ```24struct CustomSegmentedControl<T: Hashable>: View {25 /// The available options to display in the segmented control26 let options: [T]27 28 /// A binding to the currently selected option29 @Binding var selection: T30 31 /// A closure that converts an option to its display title32 let titleProvider: (T) -> String33 34 // MARK: - Customization Properties35 36 /// The background color of the selected segment37 var selectedColor: Color = .blue38 39 /// The background color of unselected segments40 var unselectedColor: Color = .clear41 42 /// The text color of the selected segment43 var selectedTextColor: Color = .white44 45 /// The text color of unselected segments46 var unselectedTextColor: Color = .primary47 48 /// The corner radius of the control49 var cornerRadius: CGFloat = 1250 51 /// Whether to show a border around the control52 var showBorder: Bool = true53 54 /// The color of the border55 var borderColor: Color = .blue.opacity(0.2)56 57 /// The font to use for segment titles58 var font: Font = .subheadline.weight(.medium)59 60 /// The animation to use when switching selections61 var animation: Animation = .easeInOut(duration: 0.3)62 63 /// Whether to use haptic feedback on selection change64 var useHaptics: Bool = true65 66 // MARK: - Private State67 68 @Namespace private var animationNamespace69 70 // MARK: - Body71 72 var body: some View {73 HStack(spacing: 2) {74 ForEach(options, id: \.self) { option in75 Button {76 selectOption(option)77 } label: {78 Text(titleProvider(option))79 .font(font)80 .foregroundColor(81 selection == option ? selectedTextColor : unselectedTextColor82 )83 .frame(maxWidth: .infinity)84 .padding(.vertical, 10)85 .padding(.horizontal, 8)86 }87 .buttonStyle(.plain)88 .matchedGeometryEffect(89 id: option,90 in: animationNamespace91 )92 }93 }94 .background(95 Group {96 if showBorder {97 Capsule()98 .stroke(borderColor, lineWidth: 1)99 }100 }101 )102 .background(103 Capsule()104 .fill(unselectedColor)105 )106 .padding(4)107 }108 109 // MARK: - Private Methods110 111 private func selectOption(_ option: T) {112 guard selection != option else { return }113 114 if useHaptics {115 let generator = UIImpactFeedbackGenerator(style: .light)116 generator.impactOccurred()117 }118 119 withAnimation(animation) {120 selection = option121 }122 }123}124 125// MARK: - View Extension for Styling126 127extension CustomSegmentedControl {128 /// Applies a gradient background to the selected segment129 func selectedGradient(_ gradient: LinearGradient) -> Self {130 var copy = self131 copy.selectedColor = .clear132 // Note: Full gradient support requires additional implementation133 return copy134 }135 136 /// Sets a custom corner radius137 func cornerRadius(_ radius: CGFloat) -> Self {138 var copy = self139 copy.cornerRadius = radius140 return copy141 }142 143 /// Enables or disables the border144 func border(_ enabled: Bool, color: Color = .blue.opacity(0.2)) -> Self {145 var copy = self146 copy.showBorder = enabled147 copy.borderColor = color148 return copy149 }150}

Common Use Cases and Examples

Segmented controls appear throughout iOS and macOS applications in various contexts. Filter interfaces in content apps, view mode switchers in document applications, and preference selectors in settings panels all benefit from the compact, mutually exclusive selection model that segmented controls provide. Understanding these patterns helps developers recognize when a segmented control is the appropriate UI choice.

Filter Interfaces

Content-heavy apps like email clients, note-taking applications, and social media feeds commonly use segmented controls for filtering content. Users can quickly toggle between views showing all content, unread items, or specific categories without navigating to separate screens. The compact nature of segmented controls makes them ideal for toolbar placement above scrolling content lists.

View Mode Switchers

Document editors, photo galleries, and file browsers often provide view mode switching through segmented controls. Users can choose between list, grid, or detail views with a single tap. This pattern is particularly effective because it maintains context while allowing users to optimize their view for the current task.

Preference Selectors

Settings panels and configuration screens frequently use segmented controls for binary or limited-choice options. Time range selectors, sort order toggles, and display mode switches all benefit from the immediate feedback and clear state indication that segmented controls provide.

Mode Switchers

Applications with multiple modes--such as camera apps with photo/video toggles, or editors with design/preview modes--use segmented controls to indicate and switch between operational modes. The visual clarity of the selected state helps users maintain awareness of their current context.

Use Case Integration Examples
1// MARK: - Filter Interface Example2struct FilterView: View {3 enum FilterOption: String, CaseIterable, Identifiable {4 case all = "All"5 case active = "Active"6 case completed = "Completed"7 8 var id: Self { self }9 10 var icon: String {11 switch self {12 case .all: return "tray.full"13 case .active: return "clock"14 case .completed: return "checkmark.circle"15 }16 }17 }18 19 @State private var selectedFilter: FilterOption = .all20 21 var body: some View {22 VStack(alignment: .leading, spacing: 16) {23 Text("Filter Tasks")24 .font(.headline)25 26 CustomSegmentedControl(27 options: FilterOption.allCases,28 selection: $selectedFilter,29 titleProvider: { $0.rawValue },30 selectedColor: .blue,31 cornerRadius: 832 )33 34 // Filtered content list would appear here35 VStack(spacing: 8) {36 ForEach(sampleTasks.filter { task in37 selectedFilter == .all ? true :38 selectedFilter == .active ? !task.isCompleted :39 task.isCompleted40 }) { task in41 TaskRow(task: task)42 }43 }44 }45 .padding()46 }47}48 49// MARK: - View Mode Picker Example50struct ViewModePicker: View {51 enum ViewMode: String, CaseIterable, Identifiable {52 case list = "List"53 case grid = "Grid"54 case detail = "Detail"55 56 var id: Self { self }57 58 var icon: String {59 switch self {60 case .list: return "list.bullet"61 case .grid: return "square.grid.2x2"62 case .detail: return "list.bullet.rectangle"63 }64 }65 }66 67 @Binding var mode: ViewMode68 69 var body: some View {70 CustomSegmentedControl(71 options: ViewMode.allCases,72 selection: $mode,73 titleProvider: { $0.rawValue },74 selectedColor: .purple,75 cornerRadius: 8,76 font: .caption.weight(.medium)77 )78 }79}80 81// MARK: - Time Range Selector Example82struct TimeRangeSelector: View {83 enum TimeRange: String, CaseIterable, Identifiable {84 case day = "1D"85 case week = "1W"86 case month = "1M"87 case quarter = "3M"88 case year = "1Y"89 90 var id: Self { self }91 }92 93 @State private var selectedRange: TimeRange = .week94 95 var body: some View {96 CustomSegmentedControl(97 options: TimeRange.allCases,98 selection: $selectedRange,99 titleProvider: { $0.rawValue },100 selectedColor: .green,101 cornerRadius: 6,102 font: .caption.weight(.bold),103 animation: .spring(response: 0.3, dampingFraction: 0.7)104 )105 }106}107 108// MARK: - Sample Data109 110struct Task: Identifiable {111 let id = UUID()112 let title: String113 let isCompleted: Bool114}115 116let sampleTasks = [117 Task(title: "Design review", isCompleted: true),118 Task(title: "Update documentation", isCompleted: false),119 Task(title: "Code review", isCompleted: false),120 Task(title: "Write tests", isCompleted: true)121]122 123struct TaskRow: View {124 let task: Task125 126 var body: some View {127 HStack {128 Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")129 .foregroundColor(task.isCompleted ? .green : .gray)130 Text(task.title)131 .strikethrough(task.isCompleted)132 .foregroundColor(task.isCompleted ? .secondary : .primary)133 }134 .padding(.vertical, 4)135 }136}

Performance Considerations

For applications with many segmented controls on a single screen or with frequently changing content, performance becomes a significant consideration. SwiftUI's view lifecycle and diffing algorithm mean that some implementations may cause unnecessary view updates. Understanding how to optimize the component's body and avoid unnecessary recalculations ensures smooth performance even in demanding scenarios.

Equatable Conformance

By making your component conform to Equatable, you can prevent unnecessary view updates when the selection hasn't actually changed. The equatable conformance compares the old and new values of properties, and SwiftUI only re-renders the view if any compared values differ. This is particularly valuable when the segmented control is embedded in a larger view that updates frequently.

View Identity and ForEach

Ensuring stable identities for ForEach items prevents SwiftUI from recreating views unnecessarily. Using stable identifiers--rather than indices--means that even if the array order changes, SwiftUI can correctly identify which views have been added, removed, or moved.

Animation Efficiency

Animation curves and durations affect both perceived and actual performance. Spring animations with appropriate damping feel natural while using fewer frames than overly long ease-in-out animations. Test animations on older devices to ensure smooth 60fps performance.

Lazy Containers

For segmented controls with many options (though typically not recommended), consider using LazyHStack instead of HStack. This defers the creation of views for off-screen content until they're needed.

State Management Optimization

Avoid recomputing derived values in the view body. Move expensive computations to computed properties or use @Observable (iOS 17+) for more efficient change detection.

Performance Optimized Implementations
1// MARK: - Equatable Conformance for Reduced Updates2 3struct OptimizedSegmentedControl<T: Hashable & Equatable>: View {4 let options: [T]5 @Binding var selection: T6 let titleProvider: (T) -> String7 8 var selectedColor: Color = .blue9 10 @Namespace private var animation11 12 var body: some View {13 HStack(spacing: 0) {14 ForEach(options, id: \.self) { option in15 Button {16 withAnimation(.easeInOut(duration: 0.25)) {17 selection = option18 }19 } label: {20 Text(titleProvider(option))21 .font(.subheadline)22 .fontWeight(.medium)23 .frame(maxWidth: .infinity)24 .padding(.vertical, 8)25 .foregroundColor(26 selection == option ? .white : .primary27 )28 }29 .buttonStyle(.plain)30 .matchedGeometryEffect(31 id: option,32 in: animation33 )34 }35 }36 .background(37 Capsule()38 .fill(Color.blue.opacity(0.1))39 )40 }41 42 static func == (lhs: OptimizedSegmentedControl, rhs: OptimizedSegmentedControl) -> Bool {43 lhs.selection == rhs.selection &&44 lhs.selectedColor == rhs.selectedColor45 // Note: We don't compare options array to avoid unnecessary updates46 // when the array reference is the same but content hasn't changed47 }48}49 50// MARK: - Enum with Equatable for State Management51 52enum FilterOption: String, CaseIterable, Equatable {53 case all = "All"54 case active = "Active"55 case completed = "Completed"56 57 static func == (lhs: FilterOption, rhs: FilterOption) -> Bool {58 lhs.rawValue == rhs.rawValue59 }60}61 62// MARK: - Avoiding Unnecessary Recalculations63 64struct EfficientFilterControl: View {65 @Binding var selection: FilterOption66 67 // Pre-computed to avoid recalculation68 private let options: [FilterOption] = FilterOption.allCases69 70 var body: some View {71 HStack(spacing: 2) {72 ForEach(options, id: \.self) { option in73 Button {74 selection = option75 } label: {76 Text(option.rawValue)77 .padding(.horizontal, 16)78 .padding(.vertical, 8)79 }80 .buttonStyle(.plain)81 }82 }83 .background(84 Capsule()85 .fill(Color.blue.opacity(0.1))86 )87 }88}89 90// MARK: - Profile for Performance Testing91 92struct PerformanceTestView: View {93 @State private var selection: FilterOption = .all94 95 var body: some View {96 VStack {97 // Test with rapid changes98 Button("Random Selection") {99 selection = FilterOption.allCases.randomElement() ?? .all100 }101 102 OptimizedSegmentedControl(103 options: FilterOption.allCases,104 selection: $selection,105 titleProvider: { $0.rawValue }106 )107 }108 }109}

Platform-Specific Adaptations

While the core implementation of a custom segmented control transfers well between iOS and macOS, each platform has unique design guidelines and interaction patterns that should inform adaptations. iOS users expect touch-friendly tap targets and swipe gestures, while macOS users benefit from hover states, keyboard navigation, and focus ring integration. Adapting the component for each platform ensures a native feel regardless of where it's deployed.

iOS Adaptations

iOS interfaces prioritize touch interaction, requiring larger tap targets and often incorporating haptic feedback. The minimum touch target size of 44x44 points recommended by Apple's Human Interface Guidelines should be considered, though segmented controls typically use slightly smaller targets due to their compact nature. Press gestures can add secondary interactions, such as a long-press to reveal additional options or a peek-and-pop preview.

macOS Adaptations

macOS interfaces support mouse and trackpad interaction, enabling hover states that provide visual feedback before any action is taken. Keyboard navigation is a first-class citizen on macOS, and properly configured focus rings help users navigate using tab and arrow keys. The appearance of controls should align with macOS design conventions, including appropriate corner radii and shadow treatments.

Using #if os() Compiler Directives

Swift's conditional compilation allows you to include platform-specific code paths within the same source file. This approach keeps related logic together while providing optimal experiences on each platform.

Watch and tvOS Considerations

While segmented controls are less common on Apple Watch and Apple TV, they can appear in complications and settings interfaces. WatchOS requires even larger touch targets and simpler interactions, while tvOS benefits from focus-based navigation that works with the Siri Remote.

Platform-Adaptive Segmented Control
1#if os(iOS) || os(macOS)2import SwiftUI3 4struct PlatformAdaptiveSegmentedControl<T: Hashable>: View {5 let options: [T]6 @Binding var selection: T7 let titleProvider: (T) -> String8 9 @State private var hoveredOption: T?10 11 // Platform-specific colors12 #if os(iOS)13 private var defaultColor: Color { .systemBlue }14 private var hoverColor: Color { .systemBlue.opacity(0.1) }15 #else16 private var defaultColor: Color { .accentColor }17 private var hoverColor: Color { .gray.opacity(0.2) }18 #endif19 20 var body: some View {21 HStack(spacing: 1) {22 ForEach(options, id: \.self) { option in23 Button {24 selection = option25 } label: {26 Text(titleProvider(option))27 .font(.subheadline.weight(.medium))28 .frame(maxWidth: .infinity)29 .padding(.horizontal, 12)30 .padding(.vertical, 8)31 .foregroundColor(32 selection == option ? .white : .primary33 )34 }35 .buttonStyle(.plain)36 #if os(macOS)37 .background(backgroundForOption(option))38 .onHover { isHovered in39 if isHovered {40 hoveredOption = option41 } else if hoveredOption == option {42 hoveredOption = nil43 }44 }45 .focusable()46 #endif47 }48 }49 .background(50 #if os(macOS)51 RoundedRectangle(cornerRadius: 6)52 .stroke(Color.gray.opacity(0.3), lineWidth: 1)53 #else54 Capsule()55 .stroke(Color.gray.opacity(0.2), lineWidth: 1)56 #endif57 )58 #if os(macOS)59 .focusRing(.automatic)60 #endif61 }62 63 #if os(macOS)64 private func backgroundForOption(_ option: T) -> some View {65 Group {66 if selection == option {67 Capsule()68 .fill(defaultColor)69 } else if hoveredOption == option {70 Capsule()71 .fill(hoverColor)72 }73 }74 }75 #endif76}77 78// MARK: - Keyboard Navigation for macOS79 80#if os(macOS)81struct KeyboardNavigableSegmentedControl<T: Hashable>: View where T: Comparable {82 let options: [T]83 @Binding var selection: T84 let titleProvider: (T) -> String85 86 @FocusState private var isFocused: Bool87 88 var body: some View {89 HStack(spacing: 1) {90 ForEach(options.sorted(), id: \.self) { option in91 Button {92 selection = option93 } label: {94 Text(titleProvider(option))95 .font(.subheadline)96 .frame(maxWidth: .infinity)97 .padding(.horizontal, 16)98 .padding(.vertical, 6)99 }100 .buttonStyle(.plain)101 .focusable(focusable)102 }103 }104 .background(105 RoundedRectangle(cornerRadius: 6)106 .stroke(isFocused ? Color.accentColor : Color.gray.opacity(0.3), lineWidth: isFocused ? 2 : 1)107 )108 .focused($isFocused)109 .onKeyPress(.leftArrow) {110 navigateToPrevious()111 return .handled112 }113 .onKeyPress(.rightArrow) {114 navigateToNext()115 return .handled116 }117 }118 119 private var focusable: Bool {120 isFocused121 }122 123 private func navigateToPrevious() {124 guard let currentIndex = options.firstIndex(of: selection),125 currentIndex > 0 else { return }126 selection = options[currentIndex - 1]127 }128 129 private func navigateToNext() {130 guard let currentIndex = options.firstIndex(of: selection),131 currentIndex < options.count - 1 else { return }132 selection = options[currentIndex + 1]133 }134}135#endif136 137// MARK: - Haptic Feedback for iOS138 139#if os(iOS)140struct HapticSegmentedControl<T: Hashable>: View {141 let options: [T]142 @Binding var selection: T143 let titleProvider: (T) -> String144 145 private let feedbackGenerator = UIImpactFeedbackGenerator(style: .light)146 147 var body: some View {148 HStack(spacing: 2) {149 ForEach(options, id: \.self) { option in150 Button {151 feedbackGenerator.impactOccurred()152 selection = option153 } label: {154 Text(titleProvider(option))155 .font(.subheadline)156 .frame(maxWidth: .infinity)157 .padding(.vertical, 10)158 }159 .buttonStyle(.plain)160 }161 }162 .background(163 Capsule()164 .fill(Color.blue.opacity(0.1))165 )166 .onAppear {167 feedbackGenerator.prepare()168 }169 }170}171#endif172#endif

Conclusion

Building custom SwiftUI segmented controls opens up possibilities for creating polished, on-brand user interfaces that go beyond the limitations of the native picker. By leveraging SwiftUI's declarative syntax, the matchedGeometryEffect modifier for smooth animations, and thoughtful attention to accessibility, you can create components that feel at home within any iOS or macOS application.

The techniques covered in this guide--from basic HStack implementations to advanced platform-specific adaptations--provide a foundation for building production-ready segmented controls. Start with the native Picker for simple use cases where the default blue styling and basic behavior meet your requirements. As your design requirements demand more sophisticated solutions, gradually introduce custom implementations that align with your app's design language.

Remember to consider performance implications when using multiple controls or large option sets. Implement Equatable conformance to prevent unnecessary updates, and test animations on older devices to ensure smooth 60fps performance. Most importantly, always prioritize accessibility to ensure your components work for all users, including those who rely on VoiceOver, dynamic type, or keyboard navigation.

The reusable component patterns demonstrated here can be adapted to your specific needs, whether you're building filter interfaces for content apps, view mode switchers for document editors, or preference selectors for settings panels. With SwiftUI's powerful composition model, the possibilities for creating custom controls are nearly limitless. If you need expert assistance building custom UI components for your applications, our web development team can help bring your vision to life with professional implementation services.

Need Help Building Custom UI Components?

Our team specializes in creating polished, accessible user interfaces for iOS and macOS applications. From custom controls to complete app implementations, we bring your vision to life.

Frequently Asked Questions