├── .gitignore ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── SwiftUIRingSlider │ └── SwiftUIRingSlider.swift └── Tests └── SwiftUIRingSliderTests └── SwiftUIRingSliderTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/swift 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=swift 3 | 4 | ### Swift ### 5 | # Xcode 6 | # 7 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 8 | 9 | ## User settings 10 | xcuserdata/ 11 | 12 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 13 | *.xcscmblueprint 14 | *.xccheckout 15 | 16 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 17 | build/ 18 | DerivedData/ 19 | *.moved-aside 20 | *.pbxuser 21 | !default.pbxuser 22 | *.mode1v3 23 | !default.mode1v3 24 | *.mode2v3 25 | !default.mode2v3 26 | *.perspectivev3 27 | !default.perspectivev3 28 | 29 | ## Obj-C/Swift specific 30 | *.hmap 31 | 32 | ## App packaging 33 | *.ipa 34 | *.dSYM.zip 35 | *.dSYM 36 | 37 | ## Playgrounds 38 | timeline.xctimeline 39 | playground.xcworkspace 40 | 41 | # Swift Package Manager 42 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 43 | # Packages/ 44 | # Package.pins 45 | # Package.resolved 46 | # *.xcodeproj 47 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 48 | # hence it is not needed unless you have added a package configuration file to your project 49 | # .swiftpm 50 | 51 | .build/ 52 | 53 | # CocoaPods 54 | # We recommend against adding the Pods directory to your .gitignore. However 55 | # you should judge for yourself, the pros and cons are mentioned at: 56 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 57 | # Pods/ 58 | # Add this line if you want to avoid checking in source code from the Xcode workspace 59 | # *.xcworkspace 60 | 61 | # Carthage 62 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 63 | # Carthage/Checkouts 64 | 65 | Carthage/Build/ 66 | 67 | # Accio dependency management 68 | Dependencies/ 69 | .accio/ 70 | 71 | # fastlane 72 | # It is recommended to not store the screenshots in the git repo. 73 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 74 | # For more information about the recommended setup visit: 75 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 76 | 77 | fastlane/report.xml 78 | fastlane/Preview.html 79 | fastlane/screenshots/**/*.png 80 | fastlane/test_output 81 | 82 | # Code Injection 83 | # After new code Injection tools there's a generated folder /iOSInjectionProject 84 | # https://github.com/johnno1962/injectionforxcode 85 | 86 | iOSInjectionProject/ 87 | 88 | .DS_Store 89 | .swiftpm 90 | 91 | #Tuist 92 | Derived 93 | *.xcodeproj 94 | # End of https://www.toptal.com/developers/gitignore/api/swift 95 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swiftui-introspect", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/siteline/swiftui-introspect", 7 | "state" : { 8 | "revision" : "9e1cc02a65b22e09a8251261cccbccce02731fc5", 9 | "version" : "1.1.1" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 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: "SwiftUIRingSlider", 8 | platforms: [ 9 | .iOS(.v17), 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, making them visible to other packages. 13 | .library( 14 | name: "SwiftUIRingSlider", 15 | targets: ["SwiftUIRingSlider"] 16 | ) 17 | ], 18 | dependencies: [ 19 | .package(url: "https://github.com/siteline/swiftui-introspect", from: "1.1.0"), 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package, defining a module or a test suite. 23 | // Targets can depend on other targets in this package and products from dependencies. 24 | .target( 25 | name: "SwiftUIRingSlider", 26 | dependencies: [ 27 | .product(name: "SwiftUIIntrospect", package: "swiftui-introspect"),] 28 | ), 29 | .testTarget( 30 | name: "SwiftUIRingSliderTests", 31 | dependencies: ["SwiftUIRingSlider"] 32 | ), 33 | ] 34 | ) 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RingSlider 2 | 3 | > [!NOTE] 4 | > It's using SwiftUIIntrospect for making infinite scrolling. 5 | 6 | ![CleanShot 2024-01-02 at 4  46 41](https://github.com/FluidGroup/swiftui-ring-slider/assets/1888355/fd50dba5-a786-4561-906f-1fd5850c4f82) 7 | -------------------------------------------------------------------------------- /Sources/SwiftUIRingSlider/SwiftUIRingSlider.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | @_spi(Advanced) import SwiftUIIntrospect 3 | 4 | public struct RingSlider: View { 5 | 6 | final class Proxy: ObservableObject { 7 | var value: Double = 0 { 8 | didSet { 9 | print(value, oldValue) 10 | let diff = value - oldValue 11 | self.incrementValue = diff 12 | } 13 | } 14 | 15 | @Published var incrementValue: Double = 0 16 | 17 | var contentOffsetObservation: NSKeyValueObservation? 18 | 19 | init() {} 20 | 21 | deinit { 22 | contentOffsetObservation?.invalidate() 23 | } 24 | } 25 | 26 | private let stride: Double 27 | @Binding var value: Double 28 | @State private var page: Int = 0 29 | 30 | @StateObject private var uiProxy: Proxy = .init() 31 | private let valueRange: ClosedRange 32 | 33 | public init( 34 | value: Binding, 35 | stride: Double = 1, 36 | valueRange: ClosedRange = (-Double.greatestFiniteMagnitude...Double.greatestFiniteMagnitude) 37 | ) { 38 | self.stride = stride 39 | self.valueRange = valueRange 40 | self._value = value 41 | } 42 | 43 | public var body: some View { 44 | 45 | let content = HStack(spacing: 0) { 46 | ShortBar() 47 | .foregroundStyle(Color.accentColor) 48 | Group { 49 | Spacer(minLength: 0) 50 | ShortBar() 51 | Spacer(minLength: 0) 52 | ShortBar() 53 | Spacer(minLength: 0) 54 | ShortBar() 55 | Spacer(minLength: 0) 56 | ShortBar() 57 | Spacer(minLength: 0) 58 | } 59 | .foregroundStyle(Color.accentColor.secondary) 60 | } 61 | .padding(.vertical, 10) 62 | 63 | // for sizing 64 | content 65 | .hidden() 66 | .overlay( 67 | GeometryReader(content: { geometry in 68 | ScrollViewReader(content: { proxy in 69 | ScrollView(.horizontal) { 70 | HStack(spacing: 0) { 71 | ForEach(0..<2) { _ in 72 | HStack(spacing: 0) { 73 | ForEach(0..<6) { i in 74 | content 75 | } 76 | } 77 | .frame( 78 | width: geometry.size.width, 79 | height: geometry.size.height, 80 | alignment: .center 81 | ) 82 | } 83 | } 84 | } 85 | .sensoryFeedback(.selection, trigger: value) 86 | .scrollIndicators(.hidden) 87 | .introspect(.scrollView, on: .iOS(.v15...)) { (view: UIScrollView) in 88 | 89 | view.decelerationRate = .fast 90 | 91 | uiProxy.contentOffsetObservation?.invalidate() 92 | 93 | uiProxy.contentOffsetObservation = view.observe(\.contentOffset) { view, value in 94 | 95 | let v = view.contentOffset.x + Double(page) * view.bounds.width 96 | 97 | withTransaction(.init()) { 98 | let value = (v / 20).rounded() 99 | if uiProxy.value != value { 100 | uiProxy.value = value 101 | } 102 | } 103 | 104 | // for start 105 | if view.contentOffset.x < 0 { 106 | page -= 1 107 | view.contentOffset.x = view.contentSize.width - view.bounds.width 108 | return 109 | } 110 | 111 | // for end 112 | if view.contentOffset.x > view.contentSize.width - view.bounds.width { 113 | page += 1 114 | view.contentOffset.x = 0 115 | return 116 | } 117 | } 118 | } 119 | }) 120 | .mask { 121 | HStack(spacing: 0) { 122 | LinearGradient( 123 | stops: [ 124 | .init(color: .black, location: 0), 125 | .init(color: .clear, location: 1), 126 | ], 127 | startPoint: .init(x: 1, y: 0), 128 | endPoint: .init(x: 0, y: 0) 129 | ) 130 | Color.black.frame(width: 30) 131 | LinearGradient( 132 | stops: [ 133 | .init(color: .black, location: 0), 134 | .init(color: .clear, location: 1), 135 | ], 136 | startPoint: .init(x: 0, y: 0), 137 | endPoint: .init(x: 1, y: 0) 138 | ) 139 | } 140 | } 141 | .onReceive( 142 | uiProxy.$incrementValue, 143 | perform: { value in 144 | let newValue = self.value + (value * stride) 145 | 146 | self.value = newValue 147 | 148 | if newValue > valueRange.upperBound { 149 | self.value = valueRange.upperBound 150 | } 151 | 152 | if newValue < valueRange.lowerBound { 153 | self.value = valueRange.lowerBound 154 | } 155 | 156 | } 157 | ) 158 | }) 159 | ) 160 | } 161 | 162 | // MARK: - nested types 163 | 164 | struct Bar: View { 165 | var body: some View { 166 | RoundedRectangle(cornerRadius: 8) 167 | .frame(width: 3, height: 30) 168 | } 169 | } 170 | 171 | struct ShortBar: View { 172 | var body: some View { 173 | RoundedRectangle(cornerRadius: 8) 174 | .frame(width: 3, height: 20) 175 | } 176 | } 177 | } 178 | 179 | #if DEBUG 180 | 181 | private struct Demo: View { 182 | 183 | @State var value: Double = 20 184 | 185 | var body: some View { 186 | 187 | VStack { 188 | Text("\(String(format: "%.2f", value))") 189 | RingSlider(value: $value) 190 | } 191 | } 192 | 193 | } 194 | 195 | #Preview { 196 | Demo() 197 | } 198 | 199 | #Preview { 200 | HStack(spacing: 0) { 201 | ForEach(0..<6) { i in 202 | HStack(spacing: 0) { 203 | // Spacer(minLength: 0) 204 | 205 | RingSlider.Bar() 206 | .foregroundColor(.red) 207 | Spacer(minLength: 0) 208 | RingSlider.ShortBar() 209 | Spacer(minLength: 0) 210 | RingSlider.ShortBar() 211 | Spacer(minLength: 0) 212 | RingSlider.ShortBar() 213 | Spacer(minLength: 0) 214 | RingSlider.ShortBar() 215 | Spacer(minLength: 0) 216 | } 217 | } 218 | } 219 | .background(Color.blue) 220 | } 221 | #endif 222 | -------------------------------------------------------------------------------- /Tests/SwiftUIRingSliderTests/SwiftUIRingSliderTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftUIRingSlider 3 | 4 | final class SwiftUIRingSliderTests: XCTestCase { 5 | func testExample() throws { 6 | // XCTest Documentation 7 | // https://developer.apple.com/documentation/xctest 8 | 9 | // Defining Test Cases and Test Methods 10 | // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods 11 | } 12 | } 13 | --------------------------------------------------------------------------------