SwiftUI matched geometry effect in a custom segmented control

I'm working on a small macOS utility app and using it as an opportunity to experiment with some SwiftUI features. As part of this project, I decided to build a custom segmented control and looked for a simple, clean way to animate a capsule shape that highlights the selected option as it transitions between choices when the user interacts with the control. I found that using matchedGeometryEffect() was the most straightforward solution.

While many examples of the matchedGeometryEffect() modifier focus on hero animations, it can also be applied in other contexts, like my custom segmented control. Here, it's not used to transition geometry when one view disappears and another appears. Instead, it matches the geometry of a non-source view to that of the source view while both remain present in the view hierarchy simultaneously.

Let me show you how I set it up.

First, I defined the foundation of my segmented control: an HStack of buttons. Tapping a button updates the selection state. I also added a background around all the options to form the control. At this stage, tapping a button changes the selection, but there’s no visual indication of the currently selected option.

enum SegmentedControlState: String, CaseIterable, Identifiable {
    var id: Self { self }
    
    case option1 = "Option 1"
    case option2 = "Option 2"
    case option3 = "Option 3"
}

struct SegmentedControl: View {
    @State private var state: SegmentedControlState = .option1
    
    var body: some View {
        HStack {
            ForEach(SegmentedControlState.allCases) { state in
                Button {
                    self.state = state
                } label: {
                    Text(state.rawValue)
                        .padding(10)
                }
            }
        }
        
        .background(.indigo)
        .clipShape(
            Capsule()
        )
        .buttonStyle(.plain)
    }
}
Xcode preview showing a HStack with three plain buttons with indigo background

Next, I added another capsule in the background of the HStack containing the buttons. This capsule will eventually highlight the selected option once everything is set up. For now, however, it simply fills the entire available space within the control, minus some padding.

struct SegmentedControl: View {
    @State private var state: SegmentedControlState = .option1
    
    var body: some View {
        HStack {
            ...
        }
        
        .background(
            Capsule()
                .fill(.background.tertiary)
        )
        .padding(6)
        
        .background(.indigo)
        .clipShape(
            Capsule()
        )
        .buttonStyle(.plain)
    }
}
Xcode preview showing a HStack with three plain buttons with indigo background and inner dark background capsule

To make the inner capsule dynamically match the size and position of the selected option, I used the matchedGeometryEffect(). First, I defined a namespace with the @Namespace property wrapper. Then, I applied the matchedGeometryEffect() modifier to the inner capsule, linking it to the namespace and using the selection state as the identifier. This identifier ensures that the capsule matches the geometry of the source view with the same ID. Additionally, it’s important to set the capsule as a non-source view by specifying isSource: false.

struct SegmentedControl: View {
    @State private var state: SegmentedControlState = .option1
    @Namespace private var segmentedControl
    
    var body: some View {
        HStack {
            ...
        }
        
        .background(
            Capsule()
                .fill(.background.tertiary)
                .matchedGeometryEffect(
                    id: state,
                    in: segmentedControl,
                    isSource: false
                )
        )
        .padding(6)
        
        ...
    }
}

For now, nothing will visibly change since it's not specified which view should act as the source for the geometry. That’s the next step.

Swift Gems by Natalia Panferova book coverSwift Gems by Natalia Panferova book cover

Level up your Swift skills!$35

100+ tips to take your Swift code to the next level

Swift Gemsby Natalia Panferova

  • Advanced Swift techniques for experienced developers bypassing basic tutorials
  • Curated, actionable tips ready for immediate integration into any Swift project
  • Strategies to improve code quality, structure, and performance across all platforms

Level up your Swift skills!

100+ tips to take your Swift code to the next level

Swift Gems by Natalia Panferova book coverSwift Gems by Natalia Panferova book cover

Swift Gems

by Natalia Panferova

$35

I applied the matchedGeometryEffect() modifier to each button in the HStack, assigning each button a unique ID corresponding to the state it represents. Note that I didn’t specify the isSource parameter for the buttons, as it defaults to true. This means the currently selected button automatically acts as the source for the matched geometry effect, allowing the capsule to align with the button whose ID matches the selection state.

struct SegmentedControl: View {
    @State private var state: SegmentedControlState = .option1
    @Namespace private var segmentedControl
    
    var body: some View {
        HStack {
            ForEach(SegmentedControlState.allCases) { state in
                Button {
                    self.state = state
                } label: {
                    Text(state.rawValue)
                        .padding(10)
                }
                .matchedGeometryEffect(
                    id: state,
                    in: segmentedControl
                )
            }
        }
        
        .background(
            Capsule()
                .fill(.background.tertiary)
                .matchedGeometryEffect(
                    id: state,
                    in: segmentedControl,
                    isSource: false
                )
        )
        .padding(6)
        
        ...
    }
}

If we check the preview now, we’ll see the capsule positioned just behind the selected option, matching its size and position. By default, the matched geometry effect aligns both size and position, but this behavior can be customized using the properties parameter. In this case, however, the default behavior works perfectly.

Xcode preview showing a HStack with three plain buttons where only option one button has dark capsule background

To animate the capsule when switching options, I wrapped the state change in a withAnimation block.

struct SegmentedControl: View {
    @State private var state: SegmentedControlState = .option1
    @Namespace private var segmentedControl
    
    var body: some View {
        HStack {
            ForEach(SegmentedControlState.allCases) { state in
                Button {
                    withAnimation {
                        self.state = state
                    }
                } label: {
                    Text(state.rawValue)
                        .padding(10)
                }
                .matchedGeometryEffect(
                    id: state,
                    in: segmentedControl
                )
            }
        }
        
        ...
    }
}

Now, the capsule slides smoothly between options as the selection changes.

A gif of a custom segmented control with the capsule shape sliding to indicate selected option as it is clicked

With just a few lines of code, I got a custom segmented control with a sliding animation. Using the matched geometry effect makes this approach much cleaner and simpler compared to alternative methods.

Keep in mind that when creating custom UI components, it's essential to test for accessibility features like Dynamic Type and VoiceOver. This example focuses on using the matched geometry effect for the background animation, so be sure to add any required accessibility functionality if you use it in your app. Here is a good accessibility solution using accessibilityRepresentation() from Bas Broek.

Since SwiftUI doesn’t yet allow custom styles for pickers, creating a custom segmented control like this requires building it as a separate component.

You can find the code example for this post on GitHub.

If you have older iOS apps and want to enhance them with modern SwiftUI features, check out my book Integrating SwiftUI into UIKit Apps. It provides detailed guidance on gradually adopting SwiftUI in your UIKit projects. Additionally, if you're eager to enhance your Swift programming skills, my latest book Swift Gems offers over a hundred advanced tips and techniques, including optimizing collections, handling strings, mastering asynchronous programming, and debugging, to take your Swift code to the next level.

Integrating SwiftUI into UIKit Apps by Natalia Panferova book coverIntegrating SwiftUI into UIKit Apps by Natalia Panferova book cover

Enhance older apps with SwiftUI!$45

A detailed guide on gradually adopting SwiftUI in UIKit projects

Updated for iOS 18 and Xcode 16!

Integrating SwiftUI into UIKit Appsby Natalia Panferova

  • Upgrade your apps with new features like Swift Charts and Widgets
  • Support older iOS versions with effective backward-compatible strategies
  • Seamlessly bridge state and data between UIKit and SwiftUI using the latest APIs

Enhance older apps with SwiftUI!

A detailed guide on gradually adopting SwiftUI in UIKit projects

Integrating SwiftUI into UIKit Apps by Natalia Panferova book coverIntegrating SwiftUI into UIKit Apps by Natalia Panferova book cover

Integrating SwiftUI
into UIKit Apps

by Natalia Panferova

Updated for iOS 18 and Xcode 16!

$45