Created
July 15, 2026 18:18
-
-
Save seifscape/5b2e7ab6e704510dce8ffc6defbacc97 to your computer and use it in GitHub Desktop.
Reusable animated segmented control in SwiftUI (config-driven, generic over segment type)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import SwiftUI | |
| struct SegmentedView<Segment: Hashable & CustomStringConvertible>: View { | |
| struct Config { | |
| var tint: Color = .green | |
| var inactiveColor: Color = Color(.systemGray) | |
| var font: Font = .footnote.weight(.medium) | |
| var underlineHeight: CGFloat = 4 | |
| var spacing: CGFloat = 6 | |
| var animation: Animation = .spring(response: 0.3, dampingFraction: 0.8) | |
| static var `default`: Config { Config() } | |
| } | |
| let segments: [Segment] | |
| @Binding var selected: Segment | |
| var config: Config = .default | |
| @Namespace private var underlineNamespace | |
| var body: some View { | |
| HStack(spacing: 0) { | |
| ForEach(segments, id: \.self) { segment in | |
| let isSelected = segment == selected | |
| Button { | |
| withAnimation(config.animation) { | |
| selected = segment | |
| } | |
| } label: { | |
| VStack(spacing: config.spacing) { | |
| Text(segment.description) | |
| .font(config.font) | |
| .foregroundColor(isSelected ? config.tint : config.inactiveColor) | |
| ZStack { | |
| Capsule() | |
| .fill(Color.clear) | |
| .frame(height: config.underlineHeight) | |
| if isSelected { | |
| Capsule() | |
| .fill(config.tint) | |
| .frame(height: config.underlineHeight) | |
| .matchedGeometryEffect(id: "underline", in: underlineNamespace) | |
| } | |
| } | |
| } | |
| .frame(maxWidth: .infinity) | |
| } | |
| .buttonStyle(.plain) | |
| .accessibilityAddTraits(isSelected ? .isSelected : []) | |
| } | |
| } | |
| .accessibilityElement(children: .contain) | |
| } | |
| } | |
| // MARK: - Usage | |
| private enum Status: String, CaseIterable, CustomStringConvertible { | |
| case open = "OPEN", completed = "COMPLETED", cancelled = "CANCELLED", all = "ALL" | |
| var description: String { rawValue } | |
| } | |
| private struct ContentView: View { | |
| @State private var selected: Status = .open | |
| var body: some View { | |
| SegmentedView(segments: Status.allCases, selected: $selected) | |
| // Custom styling: | |
| // SegmentedView(segments: Status.allCases, selected: $selected, | |
| // config: .init(tint: .blue, inactiveColor: .secondary)) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment