Swift PiecesFree
Glass

Glass Segments

A segmented control whose glass indicator you can grab and drag: it lifts under the finger, rubber-bands past the ends, stretches with velocity and settles with a spring, while the label ink flips exactly under its edge; tap still works.

Free · MIT + Commons ClauseiOS 17.0+Liquid Glasssegmentedpickerglassdraghaptics
Type
GlassSegments
Files
GlassSegments.swift
Depends on
Nothing (Apple frameworks only)
Version
2.0.0

In a full screen · Swift Pieces Pro

Lessons

A language-learning home with a streak, today's goals as color blocks and a floating tab bar whose lens follows your finger.

Visual tuning for GlassSegments. standard is a solid track with a glass indicator; set indicatorTint for a color block indicator.

Notes

  • A touch that lands on the indicator grabs it: it lifts (scale 1.04, deeper shadow), follows the finger, rubber-bands up to one segment past either end and stretches along the drag with velocity. Release commits to the segment the flick would land on (predictedEndLocation) with a .spring(duration: 0.45, bounce: 0.3). A touch anywhere else is a plain tap on that segment.
  • One .selection haptic fires each time the indicator's center crosses into a new segment, and once on tap; there is no second tick on release.
  • Label emphasis is continuous: each label's opacity tracks how much of the indicator overlaps it, so the emphasis hands over during the drag instead of swapping at the end.
  • Reduce Transparency swaps the track for an opaque system fill. Reduce Motion removes the stretch and the lift scale and shortens the settle.
  • Segments share the width equally; the control fills its container, so constrain it with .frame(width:) when you want it compact.
  • Labels are drawn twice: once in Style.ink, and once in Style.selectedInk masked to the indicator's live shape, so the color hands over exactly under the indicator edge while dragging or stretching.
  • The indicator is a solid fill (indicatorSurface, or indicatorTint for a color block) under clear Liquid Glass on iOS 26, so it reads on the track in light and dark. GlassSegmentsStyle.block(_:) makes a block indicator with dark ink.
  • Style (GlassSegments.Style, alias of GlassSegmentsStyle): track, ink, selectedInk, indicatorTint, indicatorSurface, font, disabledOpacity. .disabled(true) fades the control and ignores touches. The row height scales with Dynamic Type (capped at 1.5x).
  • For a floating tab bar whose lens follows the finger, see the Pro Lessons screen (lens-tab-bar).

Usage

GlassSegmentsExample()
    .preferredColorScheme(.light)

Source

GlassSegments.swift
import SwiftUI

/// Visual tuning for `GlassSegments`. `standard` is a solid track with a glass indicator; set `indicatorTint` for a color block indicator.
public struct GlassSegmentsStyle: Sendable {
    /// Track fill behind the segments.
    public var track: Color
    /// Label color outside the indicator.
    public var ink: Color
    /// Label color under the indicator. It is masked to the indicator's shape, so it flips mid-drag.
    public var selectedInk: Color
    /// Indicator fill. `nil` uses `indicatorSurface`; a color draws a solid block. On iOS 26 the fill sits under clear Liquid Glass.
    public var indicatorTint: Color?
    /// Raised indicator fill when `indicatorTint` is `nil`.
    public var indicatorSurface: Color
    /// Label font.
    public var font: Font
    /// Opacity of the control while disabled with `.disabled(true)`.
    public var disabledOpacity: Double

    public init(
        track: Color = GlassSegmentsStyle.adaptive(light: 0xE6E4DF, dark: 0x262626),
        ink: Color = GlassSegmentsStyle.adaptive(light: 0x5C5A56, dark: 0xA6A49F),
        selectedInk: Color = GlassSegmentsStyle.adaptive(light: 0x141414, dark: 0xF4F3EF),
        indicatorTint: Color? = nil,
        indicatorSurface: Color = GlassSegmentsStyle.adaptive(light: 0xFFFFFF, dark: 0x3A3A3A),
        font: Font = .subheadline.weight(.semibold),
        disabledOpacity: Double = 0.45
    ) {
        self.track = track
        self.ink = ink
        self.selectedInk = selectedInk
        self.indicatorTint = indicatorTint
        self.indicatorSurface = indicatorSurface
        self.font = font
        self.disabledOpacity = disabledOpacity
    }

    public static let standard = GlassSegmentsStyle()

    /// A block indicator in `fill` with dark ink under it.
    public static func block(_ fill: Color) -> GlassSegmentsStyle {
        GlassSegmentsStyle(selectedInk: Color(red: 0.078, green: 0.078, blue: 0.078), indicatorTint: fill)
    }

    /// A color that follows the appearance, from two 0xRRGGBB values.
    public static func adaptive(light: UInt32, dark: UInt32) -> Color {
        Color(UIColor { traits in
            let hex = traits.userInterfaceStyle == .dark ? dark : light
            return UIColor(red: CGFloat(hex >> 16 & 0xFF) / 255, green: CGFloat(hex >> 8 & 0xFF) / 255, blue: CGFloat(hex & 0xFF) / 255, alpha: 1)
        })
    }
}

/// Segmented control over any `Hashable` option type with a draggable indicator.
///
/// - Parameters:
///   - options: Segments in display order.
///   - selection: Bound selected option. Changing it externally slides the indicator.
///   - height: Height of the segment row, excluding the 3pt track inset. Scales with Dynamic Type.
///   - label: Display text for an option.
///   - systemImage: Optional SF Symbol for an option, shown before its label.
///   - style: Track, label inks, indicator fill and font. `GlassSegmentsStyle.block(_:)` makes a color block indicator.
public struct GlassSegments<Option: Hashable>: View {
    public typealias Style = GlassSegmentsStyle

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @Environment(\.accessibilityReduceTransparency) private var reduceTransparency
    @Environment(\.isEnabled) private var isEnabled
    @ScaledMetric(relativeTo: .subheadline) private var typeScale: CGFloat = 1
    @Binding private var selection: Option
    @State private var displayedIndex: Int
    @State private var dragCenter: CGFloat?
    @State private var grabOffset: CGFloat = 0
    @State private var dragging = false
    @State private var touching = false
    @State private var hoverIndex: Int?
    @State private var stretch: CGFloat = 0
    @State private var crossings = 0

    private let options: [Option]
    private let height: CGFloat
    private let label: (Option) -> String
    private let systemImage: ((Option) -> String?)?
    private let style: Style

    public init(options: [Option], selection: Binding<Option>, height: CGFloat = 40, style: Style = .standard, label: @escaping (Option) -> String, systemImage: ((Option) -> String?)? = nil) {
        self.options = options
        self._selection = selection
        self.height = height
        self.label = label
        self.systemImage = systemImage
        self.style = style
        self._displayedIndex = State(initialValue: options.firstIndex(of: selection.wrappedValue) ?? 0)
    }

    public var body: some View {
        GeometryReader { proxy in
            let width = proxy.size.width
            let segmentWidth = width / CGFloat(max(options.count, 1))
            let center = dragCenter ?? (CGFloat(displayedIndex) + 0.5) * segmentWidth
            ZStack(alignment: .leading) {
                indicator
                    .frame(width: segmentWidth, height: proxy.size.height)
                    .scaleEffect(x: 1 + stretch, y: 1 - stretch * 0.35)
                    .scaleEffect(dragging && !reduceMotion ? 1.04 : 1)
                    .shadow(color: .black.opacity(dragging ? 0.2 : 0.08), radius: dragging ? 10 : 3, y: dragging ? 5 : 1)
                    .offset(x: center - segmentWidth / 2)
                    .animation(.spring(duration: 0.25, bounce: 0.2), value: dragging)
                labels(segmentWidth: segmentWidth, height: proxy.size.height, color: style.ink, accessible: true)
                // The same row in the selected ink, cut to the indicator, so the color hands over under its edge.
                labels(segmentWidth: segmentWidth, height: proxy.size.height, color: style.selectedInk, accessible: false)
                    .mask(alignment: .topLeading) {
                        Capsule()
                            .frame(width: segmentWidth, height: proxy.size.height)
                            .scaleEffect(x: 1 + stretch, y: 1 - stretch * 0.35)
                            .scaleEffect(dragging && !reduceMotion ? 1.04 : 1)
                            .offset(x: center - segmentWidth / 2)
                            .animation(.spring(duration: 0.25, bounce: 0.2), value: dragging)
                    }
                    .accessibilityHidden(true)
            }
            .contentShape(.rect)
            .gesture(drag(segmentWidth: segmentWidth, width: width), including: isEnabled ? .all : .none)
        }
        .frame(height: height * min(typeScale, 1.5))
        .padding(3)
        .background(reduceTransparency ? Color(.secondarySystemFill) : style.track, in: .capsule)
        .opacity(isEnabled ? 1 : style.disabledOpacity)
        .animation(.smooth(duration: 0.25), value: isEnabled)
        .sensoryFeedback(.selection, trigger: crossings)
        .onChange(of: selection) { _, new in
            guard let index = options.firstIndex(of: new), index != displayedIndex else { return }
            withAnimation(reduceMotion ? .smooth(duration: 0.2) : .snappy(duration: 0.32)) { displayedIndex = index }
        }
        .accessibilityElement(children: .contain)
    }

    private func labels(segmentWidth: CGFloat, height: CGFloat, color: Color, accessible: Bool) -> some View {
        HStack(spacing: 0) {
            ForEach(options.indices, id: \.self) { index in
                segmentLabel(options[index])
                    .font(style.font)
                    .foregroundStyle(color)
                    .lineLimit(1)
                    .minimumScaleFactor(0.8)
                    .padding(.horizontal, 8)
                    .frame(width: segmentWidth, height: height)
                    .accessibilityElement(children: .combine)
                    .accessibilityAddTraits(index == displayedIndex ? [.isButton, .isSelected] : .isButton)
                    .accessibilityAction { select(index) }
                    .accessibilityHidden(!accessible)
            }
        }
    }

    @ViewBuilder private func segmentLabel(_ option: Option) -> some View {
        if let image = systemImage.flatMap({ $0(option) }) {
            Label(label(option), systemImage: image)
        } else {
            Text(label(option))
        }
    }

    @ViewBuilder private var indicator: some View {
        if #available(iOS 26, *), !reduceTransparency {
            // A solid fill under clear glass: the indicator reads on any track in both appearances and still lenses the edge.
            Capsule()
                .fill(style.indicatorTint ?? style.indicatorSurface)
                .glassEffect(.clear, in: .capsule)
        } else {
            Capsule().fill(style.indicatorTint ?? style.indicatorSurface)
        }
    }

    // MARK: Interaction

    /// A touch that starts on the indicator drags it; a touch elsewhere is a tap on that segment.
    private func drag(segmentWidth: CGFloat, width: CGFloat) -> some Gesture {
        DragGesture(minimumDistance: 0)
            .onChanged { value in
                if !touching {
                    touching = true
                    let resting = (CGFloat(displayedIndex) + 0.5) * segmentWidth
                    dragging = abs(value.startLocation.x - resting) <= segmentWidth / 2
                    grabOffset = value.startLocation.x - resting
                    hoverIndex = displayedIndex
                    if dragging { dragCenter = resting }
                }
                guard dragging else { return }
                let raw = value.location.x - grabOffset
                let banded = rubberBand(raw, lower: segmentWidth / 2, upper: width - segmentWidth / 2, limit: segmentWidth)
                let velocityStretch = reduceMotion ? 0 : min(abs(value.velocity.width) / 4000, 0.12)
                withAnimation(.interactiveSpring(duration: 0.15)) {
                    dragCenter = banded
                    stretch = velocityStretch
                }
                let nearest = clampIndex(Int(banded / segmentWidth))
                if nearest != hoverIndex {
                    hoverIndex = nearest
                    crossings += 1
                }
            }
            .onEnded { value in
                defer { touching = false; dragging = false; hoverIndex = nil }
                if dragging {
                    // Commit to where the flick would land, not just where the finger let go.
                    let projected = value.predictedEndLocation.x - grabOffset
                    let index = clampIndex(Int(projected / segmentWidth))
                    if index != hoverIndex { crossings += 1 }
                    withAnimation(.spring(duration: 0.45, bounce: 0.3)) {
                        displayedIndex = index
                        selection = options[index]
                        dragCenter = nil
                        stretch = 0
                    }
                } else if abs(value.translation.width) < 10, abs(value.translation.height) < 10 {
                    select(clampIndex(Int(value.location.x / segmentWidth)))
                }
            }
    }

    private func select(_ index: Int) {
        guard index != displayedIndex else { return }
        crossings += 1
        withAnimation(reduceMotion ? .smooth(duration: 0.2) : .snappy(duration: 0.32)) {
            displayedIndex = index
            selection = options[index]
        }
    }

    private func clampIndex(_ index: Int) -> Int { min(max(index, 0), options.count - 1) }

    /// Past either end the indicator keeps moving with diminishing returns, never more than `limit` beyond.
    private func rubberBand(_ x: CGFloat, lower: CGFloat, upper: CGFloat, limit: CGFloat) -> CGFloat {
        func band(_ distance: CGFloat) -> CGFloat { (1 - 1 / (distance * 0.55 / limit + 1)) * limit }
        if x < lower { return lower - band(lower - x) }
        if x > upper { return upper + band(x - upper) }
        return x
    }
}

// MARK: - Example

/// The component alone: the picker centred on the stage, cycling through its options.
private struct GlassSegmentsExample: View {
    enum Period: String, CaseIterable { case day, week, month, year }
    @State private var period: Period = .week

    var body: some View {
        GlassSegments(options: Period.allCases, selection: $period) { $0.rawValue.capitalized }
            .frame(maxWidth: 350)
            .padding(.horizontal, 24)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(GlassSegmentsStyle.adaptive(light: 0xF3F2EE, dark: 0x121212))
            .task {
                while !Task.isCancelled {
                    try? await Task.sleep(for: .seconds(1.8))
                    let all = Period.allCases
                    period = all[(all.firstIndex(of: period)! + 1) % all.count]
                }
            }
    }
}

#Preview("Light") {
    GlassSegmentsExample()
        .preferredColorScheme(.light)
}

#Preview("Dark") {
    GlassSegmentsExample()
        .preferredColorScheme(.dark)
}

Install

Pick one. Run it from the folder that contains your .xcodeproj and the files land inside your app.

Terminal

$npx swiftpieces add GlassSegments

Or ask your coding agent · Claude Code, Cursor or Xcode, once the MCP server is connected

>Add the Swift Pieces "Glass Segments" piece to my app

Or copy the source above into your app.

Building a whole app? See Swift Pieces Pro →