Animated Segmented Control

You can download the final code from here: https://gist.github.com/maysamsh/327c77e3f3c98ec29faf026ed5bd143e

A while ago, I stumbled upon this nice segmented control that had a smooth animation when the user switched between options. I assume the author’s intention was to demonstrate how to animate it, not to create a generic component. Since I am working on a personal project-recreating my old Kompressor app from scratch-I decided to use this control instead of the native one provided by Apple. All it needed was a bit of tweaking to make it reusable. In this article, I’ll explain how to make it generic.

In my experience, the best type for a segmented control is an enumeration. It makes the code more readable and easier to maintain Now let’s look at the original code and here you can read the article by Nil :

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
    @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
                )
            }
        }
        
        .background(
            Capsule()
                .fill(.background.tertiary)
                .matchedGeometryEffect(
                    id: state,
                    in: segmentedControl,
                    isSource: false
                )
        )
        .padding(6)
        
        .background(.indigo)
        .clipShape(
            Capsule()
        )
        .buttonStyle(.plain)
    }
}

So, all we need to do is modify this code to accept almost any enum instead of the SegmentedControlState provided in the sample code. To achieve this, we need to modify the view to conform to a number of protocols that make it agnostic about the data source it relies on.

Those protocols are: Identifiable, RandomAccessCollection, and optionally Hashable if you wish to save the settings provided by these enums in CoreData or SwiftData. The first two are required because we are using a ForEach block to render the control, which requires its input data to conform to both RandomAccessCollection and Identifiable. I guess Apple chose RandomAccessCollection over BidirectionalCollection for its better performance O(1). Identifiable ensures that each item is unique, which is crucial for maintaining data validity when dynamically updating the items. Then we need to bind the selected item to this view because the view should not know about the data it’s showing.

struct SegmentedControl<T: SegmentedControlType>: View where T: Identifiable, T: Hashable, T.AllCases: RandomAccessCollection {
    @Binding private var selection: T
    @Namespace private var segmentedControl
    
    init(selection: Binding<T>) {
        self._selection = selection
    }
    
    var body: some View {
        HStack {
            ForEach(T.allCases) { state in
                Button {
                    withAnimation {
                        selection = state
                    }
                } label: {
                    Text(state.title)
                        .padding(10)
                }
                .matchedGeometryEffect(
                    id: state,
                    in: segmentedControl
                )
            }
        }
        
        .background(
            Capsule()
                .fill(.background.tertiary)
                .matchedGeometryEffect(
                    id: selection,
                    in: segmentedControl,
                    isSource: false
                )
        )
        .padding(6)
        
        .background(.indigo)
        .clipShape(
            Capsule()
        )
        .buttonStyle(.plain)
    }
}

Now let’s create a sample enum that could be passed to this new control:

/// A type that provides a title for control
protocol SegmentedControlType: CaseIterable {
    var title: String { get }
}

enum SampleOptions: Identifiable, CustomStringConvertible, SegmentedControlType, Hashable {
    var id: Self { self }
    
    case option1
    case option2
    
    var title: String {
        switch self {
        case .option1:
            return "Option 1"
        case .option2:
            return "Option 2"
        }
    }
    
    var description: String {
        return title
    }
}

CustomStringConvertible is optional, but it helps with debugging. When you print a value of this type, it provides a clean and readable string, such as “Option 1” instead of something like SampleOptions.option1 or option1.

Now, all you need to do is create a state variable in your view or a @Published property in your view model, and pass it to this new control.

/// View
    @State var options: SampleOptions = .option1
    var body: some View {
       SegmentedControl(selection: $dimension)
    }

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.