3 min read

Custom Segmented Control Picker with SwiftUI

Not long ago, I needed a segmented picker, and as it usually happens, native things didn't fit designs quite well, and I had to go custom. Here is what happened next😄
Custom Segmented Control Picker with SwiftUI

Not long ago, I needed a segmented picker, and as it usually happens, native things didn't fit designs quite well, and I had to go custom.

Here is the example of SegmentedPicker usage:


struct SegmentedPickerExample: View {
    let titles: [String]
    @Binding var selectedIndex: Int

    var body: some View {
        SegmentedPicker(
            titles,
            selectedIndex: Binding(
                get: { selectedIndex },
                set: { selectedIndex = $0 ?? 0 }),
            content: { item, isSelected in
                Text(item)
                    .foregroundColor(isSelected ? Color.white : Color.gray )
                    .padding(.horizontal, 16)
                    .padding(.vertical, 8)
            },
            selection: {
                Capsule()
                    .fill(Color.gray)
            })
            .animation(.easeInOut(duration: 0.3))
    }
}


If I need to underline instead of capsule view for selection, it can be done like in the example below. The selection view size will automatically match the selected item.

struct SegmentedPickerExample: View {
    let titles: [String]
    @State var selectedIndex: Int = 0

    var body: some View {
        SegmentedPicker(
            titles,
            selectedIndex: Binding(
                get: { selectedIndex },
                set: { selectedIndex = $0 ?? 0 }),
            content: { item, isSelected in
                Text(item)
                    .foregroundColor(isSelected ? Color.black : Color.gray )
                    .padding(.horizontal, 16)
                    .padding(.vertical, 8)
            },
            selection: {
                VStack(spacing: 0) {
                    Spacer()
                    Rectangle()
                        .fill(Color.black)
                        .frame(height: 1)
                }
            })
            .animation(.easeInOut(duration: 0.3))
    }
}

Inside multisegment control

The implementation is rather simple. It's simply an HStack of buttons with a selection view behind them.


public struct SegmentedPicker<Element, Content, Selection>: View
    where
    Content: View,
    Selection: View {

    public typealias Data = [Element]

    @State private var frames: [CGRect]
    @Binding private var selectedIndex: Data.Index?

    private let data: Data
    private let selection: () -> Selection
    private let content: (Data.Element, Bool) -> Content

    public init(_ data: Data,
                selectedIndex: Binding<Data.Index?>,
                @ViewBuilder content: @escaping (Data.Element, Bool) -> Content,
                @ViewBuilder selection: @escaping () -> Selection) {

        self.data = data
        self.content = content
        self.selection = selection
        self._selectedIndex = selectedIndex
        self._frames = State(wrappedValue: Array(repeating: .zero,
                                                 count: data.count))
    }

    public var body: some View {
        ZStack(alignment: Alignment(horizontal: .horizontalCenterAlignment,
                                    vertical: .center)) {

            if let selectedIndex = selectedIndex {
                selection()
                    .frame(width: frames[selectedIndex].width,
                           height: frames[selectedIndex].height)
                    .alignmentGuide(.horizontalCenterAlignment) { dimensions in
                        dimensions[HorizontalAlignment.center]
                    }
            }

            HStack(spacing: 0) {
                ForEach(data.indices, id: \.self) { index in
                    Button(action: { selectedIndex = index },
                           label: { content(data[index], selectedIndex == index) }
                    )
                    .buttonStyle(PlainButtonStyle())
                    .background(GeometryReader { proxy in
                        Color.clear.onAppear { frames[index] = proxy.frame(in: .global) }
                    })
                    .alignmentGuide(.horizontalCenterAlignment,
                                    isActive: selectedIndex == index) { dimensions in
                        dimensions[HorizontalAlignment.center]
                    }
                }
            }
        }
    }
}

However there are several things to note.

Selection Size

When presenting items, I use GeometryReader to capture their frames.

Button(...)
    .background(GeometryReader { proxy in
        Color.clear.onAppear { frames[index] = proxy.frame(in: .global) }
    })

I use those frames' sizes to make the selection view's size match the size of the selected item.

selection()
    .frame(width: frames[selectedIndex].width,
           height: frames[selectedIndex].height)

Selection Alignment

All the segmented picker items and the selection view are contained in a ZStack using a custom horizontal center alignment guide:

extension HorizontalAlignment {
    private enum CenterAlignmentID: AlignmentID {
        static func defaultValue(in dimension: ViewDimensions) -> CGFloat {
            return dimension[HorizontalAlignment.center]
        }
    }
    
    static var horizontalCenterAlignment: HorizontalAlignment {
        HorizontalAlignment(CenterAlignmentID.self)
    }
}

and optional alignment extension:


extension View {
    @ViewBuilder
    @inlinable func alignmentGuide(_ alignment: HorizontalAlignment,
                                   isActive: Bool,
                                   computeValue: @escaping (ViewDimensions) -> CGFloat) -> some View {
        if isActive {
            alignmentGuide(alignment, computeValue: computeValue)
        } else {
            self
        }
    }

    @ViewBuilder
    @inlinable func alignmentGuide(_ alignment: VerticalAlignment,
                                   isActive: Bool,
                                   computeValue: @escaping (ViewDimensions) -> CGFloat) -> some View {

        if isActive {
            alignmentGuide(alignment, computeValue: computeValue)
        } else {
            self
        }
    }
}

Long story short, it takes the horizontal centres of the selection view:

selection()
    .alignmentGuide(.horizontalCenterAlignment) { dimensions in
        dimensions[HorizontalAlignment.center]
    }

and the selected item:

ForEach(...) { index in
    Button(...)       
        .alignmentGuide(.horizontalCenterAlignment,
                        isActive: selectedIndex == index) { dimensions in
            dimensions[HorizontalAlignment.center]
        }
}

and makes the ZStack to use that alignment. In other words, it tells ZStack that those views' horizontal centers should match:

ZStack(alignment: Alignment(horizontal: .horizontalCenterAlignment,
                            vertical: .center)) {
    
    //...

}

When another item is selected, the alignment guide will be attached to it, and the ZStack will layout them all accordingly.

That's a pretty powerful technique to play with alignments allowing to construct rather complicated layouts. More details about it can be found here.

It's also possible to do without alignment guides, get items' frames via GeometryReader and manipulate them directly. I've met a lot of custom segmented picker implementations doing exactly that.

Unfortunately, almost all implementations I found, got broken when either on iOS13 or on iOS14 (mostly because of different geometry reader behavior) or resulted in weird animations.

All the examples above can be found in the gist here.

The repo with full SegmentedPicker implementation is here