├── .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 | Continuous Integration 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 --------------------------------------------------------------------------------