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
Member discussion