├── .github
└── workflows
│ └── tests.yml
├── .gitignore
├── Package.swift
├── README.md
├── Sources
└── SegmentedPicker
│ ├── HorizontalAlignment.swift
│ ├── SegmentedPicker.swift
│ └── View+OptionalAlignment.swift
└── Tests
└── SegmentedPickerTests
├── CapsuleSelectionViewTests.swift
├── TestViews
├── CapsuleSelectionView.swift
└── UnderlineSelectionView.swift
├── UnderlineSelectionViewTests.swift
└── __Snapshots__
├── CapsuleSelectionViewTests
├── test_WhenSelectionIsNotProvided_SelectionIsNotVisible_Snapshot.1.png
└── test_WhenSelectionIsProvided_SelectionIsVisible_Snapshot.1.png
└── UnderlineSelectionViewTests
├── test_WhenSelectionIsNotProvided_SelectionIsNotVisible_Snapshot.1.png
└── test_WhenSelectionIsProvided_SelectionIsVisible_Snapshot.1.png
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | # This workflow will build a Swift project
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift
3 |
4 | name: Tests
5 |
6 | on:
7 | push:
8 | branches: [ "main" ]
9 | pull_request:
10 | branches: [ "main" ]
11 |
12 | jobs:
13 | lint:
14 | runs-on: macos-latest
15 | steps:
16 | - uses: actions/checkout@v4
17 | - uses: cirruslabs/swiftlint-action@v1
18 | with:
19 | version: latest
20 |
21 | build:
22 | runs-on: macos-latest
23 | steps:
24 | - uses: actions/checkout@v4
25 | - name: Build for iOS
26 | run: set -o pipefail && env NSUnbufferedIO=YES xcodebuild build-for-testing -scheme SegmentedPicker -destination "platform=iOS Simulator,OS=17.4,name=iPhone 14" | xcpretty
27 | - name: Run iOS tests
28 | run: set -o pipefail && env NSUnbufferedIO=YES xcodebuild test-without-building -scheme SegmentedPicker -destination "platform=iOS Simulator,OS=17.4,name=iPhone 14" | xcpretty
29 |
30 |
31 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | /.swiftpm/xcode
7 | Package.resolved
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.3
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "SegmentedPicker",
8 | platforms: [
9 | .iOS(.v13),
10 | .macOS(.v10_15),
11 | .watchOS(.v6),
12 | .tvOS(.v13)
13 | ],
14 | products: [
15 | .library(
16 | name: "SegmentedPicker",
17 | targets: ["SegmentedPicker"])
18 | ],
19 | dependencies: [
20 | .package(
21 | url: "https://github.com/pointfreeco/swift-snapshot-testing",
22 | from: "1.12.0"
23 | )
24 |
25 | ],
26 | targets: [
27 | .target(
28 | name: "SegmentedPicker",
29 | dependencies: []),
30 | .testTarget(
31 | name: "SegmentedPickerTests",
32 | dependencies: [
33 | "SegmentedPicker",
34 | .product(name: "SnapshotTesting", package: "swift-snapshot-testing")
35 | ])
36 | ]
37 | )
38 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Swifty Segmented Picker
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Custom segmented picker for SwiftUI.
10 |
11 | ## Installation
12 |
13 | ### Swift Package Manager.
14 |
15 | SwiftySegmentedPicker is available through Swift Package Manager.
16 | To install it, in Xcode 11.0 or later select File > Swift Packages > Add Package Dependency...
17 |
18 | and add SwiftySegmentedPicker repository URL:
19 |
20 | ```
21 | https://github.com/KazaiMazai/SwiftySegmentedPicker.git
22 | ```
23 |
24 |
25 | ## Underline Selection Usage Example
26 |
27 | ```swift
28 |
29 | struct SegmentedPickerExample: View {
30 | let titles: [String]
31 | @State var selectedIndex: Int?
32 |
33 | var body: some View {
34 | SegmentedPicker(
35 | titles,
36 | selectedIndex: Binding(
37 | get: { selectedIndex },
38 | set: { selectedIndex = $0 }),
39 | selectionAlignment: .bottom,
40 | content: { item, isSelected in
41 | Text(item)
42 | .foregroundColor(isSelected ? Color.black : Color.gray )
43 | .padding(.horizontal, 16)
44 | .padding(.vertical, 8)
45 | },
46 | selection: {
47 | VStack(spacing: 0) {
48 | Spacer()
49 | Color.black.frame(height: 1)
50 | }
51 | })
52 | .onAppear {
53 | selectedIndex = 0
54 | }
55 | .animation(.easeInOut(duration: 0.3))
56 | }
57 | }
58 |
59 | ```
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 | ## Capsule Selection Usage Example
69 |
70 |
71 | ```swift
72 |
73 | struct SegmentedPickerExample: View {
74 | let titles: [String]
75 | @State var selectedIndex: Int?
76 |
77 | var body: some View {
78 | SegmentedPicker(
79 | titles,
80 | selectedIndex: Binding(
81 | get: { selectedIndex },
82 | set: { selectedIndex = $0 }),
83 | content: { item, isSelected in
84 | Text(item)
85 | .foregroundColor(isSelected ? Color.white : Color.gray )
86 | .padding(.horizontal, 16)
87 | .padding(.vertical, 8)
88 | },
89 | selection: {
90 | Capsule()
91 | .fill(Color.gray)
92 | })
93 | .onAppear {
94 | selectedIndex = 0
95 | }
96 | .animation(.easeInOut(duration: 0.3))
97 | }
98 | }
99 |
100 | ```
101 |
102 |
103 |
104 |
105 |
106 | ## Licensing
107 |
108 | SwiftySegmentedPicker is licensed under MIT license.
109 |
--------------------------------------------------------------------------------
/Sources/SegmentedPicker/HorizontalAlignment.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HorizontalAlignment.swift
3 | // SwiftySegmentedPicker
4 | //
5 | // Created by Sergey Kazakov on 17.03.2021.
6 | //
7 |
8 | import SwiftUI
9 |
10 | extension HorizontalAlignment {
11 | private enum CenterAlignmentID: AlignmentID {
12 | static func defaultValue(in dimension: ViewDimensions) -> CGFloat {
13 | return dimension[HorizontalAlignment.center]
14 | }
15 | }
16 |
17 | static var horizontalCenterAlignment: HorizontalAlignment {
18 | HorizontalAlignment(CenterAlignmentID.self)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Sources/SegmentedPicker/SegmentedPicker.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GenericMultiSegmentSelectorView.swift
3 | // SwiftySegmentedPicker
4 | //
5 | // Created by Sergey Kazakov on 17.03.2021.
6 | //
7 |
8 | import SwiftUI
9 |
10 | public struct SegmentedPicker: View
11 | where
12 | Content: View,
13 | Selection: View {
14 |
15 | public typealias Data = [Element]
16 |
17 | @State private var segmentSizes: [Data.Index: CGSize] = [:]
18 | @Binding private var selectedIndex: Data.Index?
19 |
20 | private let data: Data
21 | private let selection: () -> Selection
22 | private let content: (Data.Element, Bool) -> Content
23 | private let selectionAlignment: VerticalAlignment
24 |
25 | public init(_ data: Data,
26 | selectedIndex: Binding,
27 | selectionAlignment: VerticalAlignment = .center,
28 | @ViewBuilder content: @escaping (Data.Element, Bool) -> Content,
29 | @ViewBuilder selection: @escaping () -> Selection) {
30 |
31 | self.data = data
32 | self.content = content
33 | self.selection = selection
34 | self._selectedIndex = selectedIndex
35 | self.selectionAlignment = selectionAlignment
36 | }
37 |
38 | public var body: some View {
39 | ZStack(alignment: Alignment(horizontal: .horizontalCenterAlignment,
40 | vertical: selectionAlignment)) {
41 |
42 | if let index = selectedIndex {
43 | selection()
44 | .frame(width: selectionSize(at: index).width,
45 | height: selectionSize(at: index).height)
46 | .alignmentGuide(.horizontalCenterAlignment) { dimensions in
47 | dimensions[HorizontalAlignment.center]
48 | }
49 | }
50 |
51 | HStack(spacing: 0) {
52 | ForEach(data.indices, id: \.self) { index in
53 | Button(action: { selectedIndex = index },
54 | label: { content(data[index], selectedIndex == index) }
55 | )
56 | .buttonStyle(PlainButtonStyle())
57 | .background(GeometryReader { proxy in
58 | Color.clear.preference(
59 | key: SegmentSizePreferenceKey.self,
60 | value: SegmentSize(index: index, size: proxy.size)
61 | )
62 | })
63 | .onPreferenceChange(SegmentSizePreferenceKey.self) { segment in
64 | segmentSizes[segment.index] = segment.size
65 | }
66 | .alignmentGuide(.horizontalCenterAlignment,
67 | isActive: selectedIndex == index) { dimensions in
68 | dimensions[HorizontalAlignment.center]
69 | }
70 | }
71 | }
72 | }
73 | }
74 | }
75 |
76 | private extension SegmentedPicker {
77 |
78 | func selectionSize(at index: Data.Index) -> CGSize {
79 | segmentSizes[index] ?? .zero
80 | }
81 | }
82 |
83 | private extension SegmentedPicker {
84 | struct SegmentSize: Equatable {
85 | let index: Int
86 | let size: CGSize
87 | }
88 |
89 | struct SegmentSizePreferenceKey: PreferenceKey {
90 | static var defaultValue: SegmentSize { SegmentSize(index: .zero, size: .zero) }
91 |
92 | static func reduce(value: inout SegmentSize,
93 | nextValue: () -> SegmentSize) {
94 |
95 | value = nextValue()
96 | }
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/Sources/SegmentedPicker/View+OptionalAlignment.swift:
--------------------------------------------------------------------------------
1 | //
2 | // View+OptionalAlignment.swift
3 | // SwiftySegmentedPicker
4 | //
5 | // Created by Sergey Kazakov on 17.03.2021.
6 | //
7 |
8 | import SwiftUI
9 |
10 | extension View {
11 | @ViewBuilder
12 | @inlinable func alignmentGuide(_ alignment: HorizontalAlignment,
13 | isActive: Bool,
14 | computeValue: @escaping (ViewDimensions) -> CGFloat) -> some View {
15 | if isActive {
16 | alignmentGuide(alignment, computeValue: computeValue)
17 | } else {
18 | self
19 | }
20 | }
21 |
22 | @ViewBuilder
23 | @inlinable func alignmentGuide(_ alignment: VerticalAlignment,
24 | isActive: Bool,
25 | computeValue: @escaping (ViewDimensions) -> CGFloat) -> some View {
26 |
27 | if isActive {
28 | alignmentGuide(alignment, computeValue: computeValue)
29 | } else {
30 | self
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/Tests/SegmentedPickerTests/CapsuleSelectionViewTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // File.swift
3 | //
4 | //
5 | // Created by Sergey Kazakov on 15/06/2024.
6 | //
7 |
8 | import SwiftUI
9 | import UIKit
10 | import XCTest
11 | import SnapshotTesting
12 | @testable import SegmentedPicker
13 |
14 | #if os(iOS) || os(tvOS)
15 | final class CapsuleSelectionViewTests: XCTestCase {
16 |
17 | func test_WhenSelectionIsNotProvided_SelectionIsNotVisible_Snapshot() {
18 | let view = CapsuleSelectionView(titles: ["One", "Two", "Three"])
19 | assertSnapshots(of: view, as: [.image(layout: .device(config: .iPhone13))])
20 | }
21 |
22 | func test_WhenSelectionIsProvided_SelectionIsVisible_Snapshot() {
23 | let view = CapsuleSelectionView(titles: ["One", "Two", "Three"], selectedIndex: 1)
24 | assertSnapshots(of: view, as: [.image(layout: .device(config: .iPhone13))])
25 | }
26 | }
27 | #endif
28 |
--------------------------------------------------------------------------------
/Tests/SegmentedPickerTests/TestViews/CapsuleSelectionView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // File.swift
3 | //
4 | //
5 | // Created by Sergey Kazakov on 15/06/2024.
6 | //
7 |
8 | import SwiftUI
9 | @testable import SegmentedPicker
10 |
11 | struct CapsuleSelectionView: View {
12 | let titles: [String]
13 | @State var selectedIndex: Int?
14 |
15 | var body: some View {
16 | VStack {
17 | SegmentedPicker(
18 | titles,
19 | selectedIndex: Binding(
20 | get: { selectedIndex },
21 | set: { selectedIndex = $0 }),
22 | content: { item, isSelected in
23 | Text(item)
24 | .foregroundColor(isSelected ? Color.white : Color.gray )
25 | .padding(.horizontal, 16)
26 | .padding(.vertical, 8)
27 | },
28 | selection: {
29 | Capsule()
30 | .fill(Color.gray)
31 | })
32 | }
33 |
34 | Spacer()
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/Tests/SegmentedPickerTests/TestViews/UnderlineSelectionView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // File.swift
3 | //
4 | //
5 | // Created by Sergey Kazakov on 15/06/2024.
6 | //
7 |
8 | import SwiftUI
9 | @testable import SegmentedPicker
10 |
11 | struct UnderlineSelectionView: View {
12 | let titles: [String]
13 | @State var selectedIndex: Int?
14 |
15 | var body: some View {
16 | VStack {
17 | SegmentedPicker(
18 | titles,
19 | selectedIndex: Binding(
20 | get: { selectedIndex },
21 | set: { selectedIndex = $0 }),
22 | selectionAlignment: .bottom,
23 | content: { item, isSelected in
24 | Text(item)
25 |
26 | .foregroundColor(isSelected ? Color.black : Color.gray )
27 | .padding(.horizontal, 16)
28 | .padding(.vertical, 8)
29 | },
30 | selection: {
31 | VStack(spacing: 0) {
32 | Spacer()
33 | Color.black.frame(height: 1)
34 | }
35 | })
36 | Spacer()
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/Tests/SegmentedPickerTests/UnderlineSelectionViewTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // File.swift
3 | //
4 | //
5 | // Created by Sergey Kazakov on 15/06/2024.
6 | //
7 | import SwiftUI
8 | import UIKit
9 | import XCTest
10 | import SnapshotTesting
11 | @testable import SegmentedPicker
12 |
13 | extension CGSize {
14 | static let testView: CGSize = CGSize(width: 390, height: 844)
15 | }
16 |
17 | #if os(iOS) || os(tvOS)
18 | final class UnderlineSelectionViewTest: XCTestCase {
19 |
20 | func test_WhenSelectionIsNotProvided_SelectionIsNotVisible_Snapshot() {
21 | let view = UnderlineSelectionView(titles: ["One", "Two", "Three"])
22 | assertSnapshots(of: view, as: [.image(layout: .device(config: .iPhone13))])
23 | }
24 |
25 | func test_WhenSelectionIsProvided_SelectionIsVisible_Snapshot() {
26 | let view = UnderlineSelectionView(titles: ["One", "Two", "Three"], selectedIndex: 1)
27 | assertSnapshots(of: view, as: [.image(layout: .device(config: .iPhone13))])
28 | }
29 | }
30 | #endif
31 |
--------------------------------------------------------------------------------
/Tests/SegmentedPickerTests/__Snapshots__/CapsuleSelectionViewTests/test_WhenSelectionIsNotProvided_SelectionIsNotVisible_Snapshot.1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KazaiMazai/SwiftySegmentedPicker/2eba81888c722d5ab3be0fae455b3c8fa0c03869/Tests/SegmentedPickerTests/__Snapshots__/CapsuleSelectionViewTests/test_WhenSelectionIsNotProvided_SelectionIsNotVisible_Snapshot.1.png
--------------------------------------------------------------------------------
/Tests/SegmentedPickerTests/__Snapshots__/CapsuleSelectionViewTests/test_WhenSelectionIsProvided_SelectionIsVisible_Snapshot.1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KazaiMazai/SwiftySegmentedPicker/2eba81888c722d5ab3be0fae455b3c8fa0c03869/Tests/SegmentedPickerTests/__Snapshots__/CapsuleSelectionViewTests/test_WhenSelectionIsProvided_SelectionIsVisible_Snapshot.1.png
--------------------------------------------------------------------------------
/Tests/SegmentedPickerTests/__Snapshots__/UnderlineSelectionViewTests/test_WhenSelectionIsNotProvided_SelectionIsNotVisible_Snapshot.1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KazaiMazai/SwiftySegmentedPicker/2eba81888c722d5ab3be0fae455b3c8fa0c03869/Tests/SegmentedPickerTests/__Snapshots__/UnderlineSelectionViewTests/test_WhenSelectionIsNotProvided_SelectionIsNotVisible_Snapshot.1.png
--------------------------------------------------------------------------------
/Tests/SegmentedPickerTests/__Snapshots__/UnderlineSelectionViewTests/test_WhenSelectionIsProvided_SelectionIsVisible_Snapshot.1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KazaiMazai/SwiftySegmentedPicker/2eba81888c722d5ab3be0fae455b3c8fa0c03869/Tests/SegmentedPickerTests/__Snapshots__/UnderlineSelectionViewTests/test_WhenSelectionIsProvided_SelectionIsVisible_Snapshot.1.png
--------------------------------------------------------------------------------