├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Documentation └── CustomStylingGuide.md ├── Package.swift ├── README.md ├── Resources ├── Idle.png ├── Logo.png ├── Styles │ ├── Blank.png │ ├── BlankCentered.png │ ├── Centered.png │ └── Primary.png ├── example_percentage.png ├── lowerbound.png └── upperbound.png ├── Sources └── SlidingRuler │ ├── Enums │ ├── SlidingRulerState.swift │ ├── StickyMark.swift │ └── Tick.swift │ ├── Extensions │ ├── BinaryFloatingPointExtension.swift │ ├── BinaryIntegerExtension.swift │ ├── ClosedRangeExtension.swift │ ├── ComparableExtension.swift │ ├── EnvironmentValuesExtension.swift │ └── ViewExtension.swift │ ├── FlexibleWidthContainer.swift │ ├── HorizontalPanGesture.swift │ ├── InfiniteMarkOffsetModifier.swift │ ├── InfiniteOffsetEffect.swift │ ├── Mechanic.swift │ ├── Pointers.swift │ ├── PreferenceKeys.swift │ ├── Ruler │ ├── Ruler.swift │ └── RulerCell.swift │ ├── SlidingRuler.swift │ ├── Styling │ ├── AnyFractionableView.swift │ ├── AnySlidingRulerStyle.swift │ ├── CenteredStyle │ │ ├── BlankCenteredStyle.swift │ │ ├── CenteredCellBody.swift │ │ ├── CenteredScaleView.swift │ │ └── CenteredStyle.swift │ ├── DefaultStyle │ │ ├── BlankStyle.swift │ │ ├── DefaultCellBody.swift │ │ ├── DefaultScaleView.swift │ │ └── DefaultStyle.swift │ ├── NativeCursorBody.swift │ ├── Protocols │ │ ├── FractionableView.swift │ │ ├── MarkedRulerCellView.swift │ │ ├── NativeMarkedRulerCellView.swift │ │ ├── NativeRulerCellView.swift │ │ ├── RulerCellView.swift │ │ ├── ScaleView.swift │ │ └── SlidingRulerStyle.swift │ └── StyleConfiguation.swift │ ├── Utils.swift │ └── VSynchedTimer.swift └── Tests ├── LinuxMain.swift └── SlidingRulerTests ├── SlidingRulerTests.swift └── XCTestManifests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | /SlidingRulerTestingBoard 7 | /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | /Package.resolved 9 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Documentation/CustomStylingGuide.md: -------------------------------------------------------------------------------- 1 | # Custom Styling Guide 2 | 3 | WIP 4 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 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: "SlidingRuler", 8 | platforms: [.iOS(.v13)], 9 | products: [ 10 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 11 | .library( 12 | name: "SlidingRuler", 13 | targets: ["SlidingRuler"]), 14 | ], 15 | dependencies: [ 16 | // Dependencies declare other packages that this package depends on. 17 | // .package(url: /* package url */, from: "1.0.0"), 18 | .package(url: "https://github.com/Pyroh/SmoothOperators.git", .upToNextMajor(from: "0.4.0")), 19 | .package(url: "https://gitlab.com/Pyroh/CoreGeometry.git", .upToNextMajor(from: "4.0.0")), 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 23 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 24 | .target( 25 | name: "SlidingRuler", 26 | dependencies: ["SmoothOperators", "CoreGeometry"]), 27 | .testTarget( 28 | name: "SlidingRulerTests", 29 | dependencies: ["SlidingRuler"]), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | --- 6 | 7 |
8 | 9 | 10 | 11 | 12 |
13 |
14 | 15 | Website 16 | 17 | 18 | 19 | 20 |
21 | 22 | --- 23 | > At this time SlidingRuler shouldn't be used in production. 24 | 25 | **SlidingRuler** is a Swift package containing a SwiftUI control that acts like an linear infinite slider or a finite, more precise one. The notable difference is that the user can evaluate the value more precisely on a sliding ruler rather than on a slider. 26 | By default it shows a ruler you can slide around and a beautiful red cursor pointing toward the current value : 27 |
28 |
29 | 30 |
31 |
32 | These features are the supported features : 33 | 34 | - [x] Dynamic type 35 | - [x] Haptic feedback (on compatible devices) 36 | - [x] Light & dark color schemes 37 | - [x] Scroll inertia & rubber banding 38 | - [x] Custom styling 39 | - [x] Animations 40 | - [x] Pointer interactions 41 | - [ ] Layout direction 42 | - [ ] Accessibility 43 | 44 | It's been made to feel native and to integrate nicely in iOS and iPadOS. 45 | 46 | ## Installation 47 | 48 | ```Text 49 | dependencies: [ 50 | // Dependencies declare other packages that this package depends on. 51 | .package(url: "https://github.com/Pyroh/SlidingRuler", .upToNextMajor(from: "0.1.0")), 52 | ], 53 | ``` 54 | 55 | ## Usage 56 | > Before using anything be sure to `import SlidingRuler` in the target swift file. 57 | 58 | Like any SwiftUI control you can create a `SlidingRuler` with an unique parameter: the value. 59 | Like any SwiftUI input control the value is a `Binding<...>` : 60 | 61 | ```Swift 62 | @State private var value: Double = 0 63 | 64 | var body: some View { 65 | ... 66 | SlidingRuler(value: $value) 67 | ... 68 | } 69 | ``` 70 | 71 | Note that `value` must conform to `BinaryFloatingPoint`. 72 | 73 | ### ✅ When to use ? 74 | It's good to use a sliding ruler in these cases: 75 | 76 | - To input a numeric value that belongs to an unlimited range or a particularly large one. 77 | - To input measurable values like masses or lenghts. 78 | - To pick a precise value in a tiny range —for this use a small `step` value. 79 | - You're already using multiple sliding rulers in your form and using a slider for this value will break the continuity. —*Ok, but read the next section first.* 80 | - You just feel like to and you're confident it'll be ok. —*Ok, but read the next section first.* 81 | 82 | Additionaly a disabled slinding ruler can be used as a meter. 83 | 84 | ### ⛔️ When not to use 85 | It's bad to use a sliding ruler in these cases: 86 | 87 | - To make the user chose between a small to medium set of discrete values. → *Use a `Picker` or a `Stepper`.* 88 | - To pick an unprecise value in a small closed range. → *Use a `Slider`.* 89 | - To change a device audio volume. → *Use a `Slider`.* 90 | - To let the user input an arbitrary value like its age. → *Use a `TextField`. Perhaps UI/UX design is not for you after all...* 91 | - To input a date component. → Use a `DatePicker`. Are you out of your mind ? 92 | 93 | ### Using finite or semi-finite ranges 94 | In some cases you may want to use such ranges when it makes sense —particularly when inputing strictly positive or negative values. 95 | A slinding ruler will show these boundaries clearly to the user : 96 | 97 | 98 |
99 | 100 | 101 | The user is not allowed to drag the ruler above these boudaries. Trying so will result in an haptic feedback (on compatible devices) and the over drag will feel like a rubber band, like a scroll view. 102 | 103 | ### Methods added to `View` 104 | `SlidingRuler` don't have no direct method but like many SwiftUI controls it adds some methods to `View`. They work in the same fashion as other `View` methods that impact a component and all its descendent in a view tree. 105 | 106 | #### `slidingRulerStyle` 107 | 108 | ```Swift 109 | func slidingRulerStyle(_ style: S) -> some View where S: SlidingRulerStyle 110 | ``` 111 | Sets the style for all sliding rulers within the view tree. See the [Custom Styling Guide](./Documentation/CustomStylingGuide.md) (once it's been written). 112 | 113 | #### `slidingRulerCellOverflow` 114 | 115 | ```Swift 116 | func slidingRulerCellOverflow(_ overflow: Int) -> some View 117 | ``` 118 | Sets the cell overflow for all sliding rulers within the view tree. See the [Custom Styling Guide](./Documentation/CustomStylingGuide.md) (once it's been written). 119 | *You may get retired without even using this method, ever.* 120 | 121 | ### Parameter list 122 | The complete `init` method signature is : 123 | 124 | ```Swift 125 | init(value: Binding, 126 | in bounds: ClosedRange = -V.infinity...V.infinity, 127 | step: V.Stride = 1, 128 | snap: Mark = .none, 129 | tick: Mark = .none, 130 | onEditingChanged: @escaping (Bool) -> () = { _ in }, 131 | formatter: NumberFormatter? = nil) 132 | ``` 133 | 134 | #### `bounds` : *`ClosedRange`* 135 | The closed range of possible values. 136 | By default it is `-V.infinity...V.infinity`. Meaning that the sliding ruler is virtualy infinite. 137 | 138 | #### `step` : *`V.Stride`* 139 | The stride of the SlidingRuler. 140 | By default it is `1.0`. 141 | 142 | #### `snap` : *`Mark`* 143 | Possible values : `.none`, `.unit`, `.half`, `.fraction`. 144 | Describes the ruler's marks stickyness: when the ruler stops and the cursor is near a graduation it will snap to it. 145 | 146 | - `.none`: no snap. 147 | - `.unit`: snaps on every whole unit graduations. 148 | - `.half`: snaps on every whle unit and half unit graduations. 149 | - `.fraction`: snaps on every graduations. 150 | 151 | By default it is `.none`. 152 | 153 | Note: to trigger a snap the cursor must be _near_ the graduation. Here _near_ means that the delta between the cursor and the graduation is strictly less than a fraction of the ruler unit. 154 | The value of a fraction is driven by the style's `fractions` property. The default styles have a `fractions` property equal to `10` so a fraction equals to `1/10` of a unit or `0.1` with the default `step` (`1.0`). 155 | 156 | #### `tick` : *`Mark`* 157 | Possible values : `.none`, `.unit`, `.half`, `.fraction`. 158 | Defines what kind of graduation produces an haptic feedback when reached. 159 | 160 | - `.none`: no haptic feedback. 161 | - `.unit`: haptic feedbak on every whole unit graduations. 162 | - `.half`: haptic feedbak on every whole unit and half unit graduations. (If the style's fraction count allows an half) 163 | - `.fraction`: haptic feedbak on every graduations. 164 | 165 | By default it is `.none`. 166 | 167 | #### `onEditingChanged` : *`(Bool) -> Void`* 168 | A closure executed when a drag session happens. It receives a boolean value set to `true` when the drag session starts and `false` when the value stops changing. 169 | By default it is an empty closure that produces no action. 170 | 171 | #### `formatter` : *`NumberFormatter`* 172 | A `NumberFormatter` instance the ruler uses to format the ruler's marks. 173 | By default it is `nil`. 174 | 175 | ### Slinding ruler styles 176 | > For a comprehensive custom styling documentation See the [Custom Styling Guide](./Documentation/CustomStylingGuide.md) (once it's been written). 177 | > Custom styling is still a work in progress. As it is tied to accessibility some work on this topic is still required to determine how a style should adapt to it. 178 | 179 | By default `SlindingRuler` ships with four styles. Two of them don't show any mark on the ruler 180 | 181 | #### `PrimarySlidingRulerStyle` 182 | > This is the default style. 183 | 184 | 185 | 186 | #### `CenteredSlindingRulerStyle` 187 | 188 | 189 | 190 | #### `BlankSlidingRulerStyle` 191 | 192 | 193 | 194 | #### `BlankCenteredSlidingRulerStyle` 195 | 196 | 197 | 198 | 199 | ### Example 200 | #### Percentage value 201 | A SlindingRuler that goes from 0 to 100%, that snaps and gives haptic feedback on any graduation. 202 | 203 | 204 | 205 | ```Swift 206 | struct PercentSlidingRuler: View { 207 | @State private var value: Double = .zero 208 | 209 | private var formatter: NumberFormatter { 210 | let f = NumberFormatter() 211 | f.numberStyle = .percent 212 | f.maximumFractionDigits = 0 213 | return f 214 | } 215 | 216 | var body: some View { 217 | SlidingRuler(value: $value, 218 | in: 0...1, 219 | step: 0.1, 220 | snap: .fraction, 221 | tick: .fraction, 222 | formatter: formatter) 223 | } 224 | } 225 | ``` 226 | 227 | ### License 228 | See LICENSE 229 | -------------------------------------------------------------------------------- /Resources/Idle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pyroh/SlidingRuler/707b15e0ade64b488e7146a487cc5ee9570af9fa/Resources/Idle.png -------------------------------------------------------------------------------- /Resources/Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pyroh/SlidingRuler/707b15e0ade64b488e7146a487cc5ee9570af9fa/Resources/Logo.png -------------------------------------------------------------------------------- /Resources/Styles/Blank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pyroh/SlidingRuler/707b15e0ade64b488e7146a487cc5ee9570af9fa/Resources/Styles/Blank.png -------------------------------------------------------------------------------- /Resources/Styles/BlankCentered.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pyroh/SlidingRuler/707b15e0ade64b488e7146a487cc5ee9570af9fa/Resources/Styles/BlankCentered.png -------------------------------------------------------------------------------- /Resources/Styles/Centered.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pyroh/SlidingRuler/707b15e0ade64b488e7146a487cc5ee9570af9fa/Resources/Styles/Centered.png -------------------------------------------------------------------------------- /Resources/Styles/Primary.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pyroh/SlidingRuler/707b15e0ade64b488e7146a487cc5ee9570af9fa/Resources/Styles/Primary.png -------------------------------------------------------------------------------- /Resources/example_percentage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pyroh/SlidingRuler/707b15e0ade64b488e7146a487cc5ee9570af9fa/Resources/example_percentage.png -------------------------------------------------------------------------------- /Resources/lowerbound.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pyroh/SlidingRuler/707b15e0ade64b488e7146a487cc5ee9570af9fa/Resources/lowerbound.png -------------------------------------------------------------------------------- /Resources/upperbound.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pyroh/SlidingRuler/707b15e0ade64b488e7146a487cc5ee9570af9fa/Resources/upperbound.png -------------------------------------------------------------------------------- /Sources/SlidingRuler/Enums/SlidingRulerState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SlidingRulerState.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | enum SlidingRulerState { 31 | case idle 32 | case dragging 33 | case animating 34 | case flicking 35 | case springing 36 | case stoppedFlick 37 | case stoppedSpring 38 | } 39 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Enums/StickyMark.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StickyMark.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | public enum StickyMark { 31 | public enum Bound { case any, unit, half, tenth } 32 | 33 | case none, nearest, lower(Bound), upper(Bound) 34 | 35 | var bound: Bound { 36 | switch self { 37 | case .none, .nearest: 38 | return .any 39 | case .lower(let b), .upper(let b): 40 | return b 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Enums/Tick.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tick.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | public enum Mark { 30 | case none, unit, half, fraction 31 | } 32 | 33 | public enum Tick { 34 | case none, unit, half, tenth 35 | 36 | var coeff: Double { 37 | switch self { 38 | case .none: return .nan 39 | case .unit: return 1 40 | case .half: return 0.5 41 | case .tenth: return 0.1 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Extensions/BinaryFloatingPointExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BinaryFloatingPointExtension.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import Foundation 31 | 32 | extension BinaryFloatingPoint { 33 | mutating func approximate() { self = approximated() } 34 | func approximated() -> Self { (self * 1_000_000).rounded() / 1_000_000 } 35 | 36 | func nearestBound(of range: ClosedRange, equality: Bool = false) -> Self { 37 | let deltaLow = self - range.lowerBound 38 | let deltaUp = range.upperBound - self 39 | 40 | if deltaUp == deltaLow { 41 | return !equality ? range.lowerBound : range.upperBound 42 | } else { 43 | return deltaLow < deltaUp ? range.lowerBound : range.upperBound 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Extensions/BinaryIntegerExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BinaryIntegerExtension.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SmoothOperators 31 | 32 | extension BinaryInteger { 33 | @inlinable var isEven: Bool { self.isMultiple(of: 2) } 34 | 35 | @inlinable func nextOdd() -> Self { isEven ? self+ : self } 36 | @inlinable func previousOdd() -> Self { isEven ? self- : self } 37 | 38 | @inlinable func nextEven() -> Self { isEven ? self : self+ } 39 | @inlinable func previousEven() -> Self { isEven ? self : self- } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Extensions/ClosedRangeExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClosedRangeExtension.swift 3 | // 4 | // SlidingRulerTestingBoard 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | extension ClosedRange { 31 | func contains(_ range: Self) -> Bool { 32 | self.contains(range.lowerBound) && self.contains(range.upperBound) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Extensions/ComparableExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComparableExtension.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import Foundation 31 | 32 | 33 | 34 | extension Comparable { 35 | static func clamp(_ x: Self, _ min: Self, _ max: Self) -> Self { 36 | Swift.min(Swift.max(x, min), max) 37 | } 38 | 39 | func clamped(to min: Self, _ max: Self) -> Self { 40 | Self.clamp(self, min, max) 41 | } 42 | 43 | mutating func clamp(to min: Self, _ max: Self) { 44 | self = Self.clamp(self, min, max) 45 | } 46 | 47 | func clamped(to range: ClosedRange) -> Self { 48 | Self.clamp(self, range.lowerBound, range.upperBound) 49 | } 50 | 51 | mutating func clamp(to range: ClosedRange) { 52 | self = self.clamped(to: range) 53 | } 54 | 55 | func isBound(of range: ClosedRange) -> Bool { 56 | range.lowerBound == self || range.upperBound == self 57 | } 58 | } 59 | 60 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Extensions/EnvironmentValuesExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EnvironmentValuesExtension.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SwiftUI 31 | 32 | enum StaticSlidingRulerStyleEnvironment { 33 | @Environment(\.slidingRulerStyle.cellWidth) static var cellWidth 34 | @Environment(\.slidingRulerStyle.cursorAlignment) static var alignment 35 | @Environment(\.slidingRulerStyle.hasMarks) static var hasMarks 36 | } 37 | 38 | struct SlidingRulerStyleEnvironmentKey: EnvironmentKey { 39 | static var defaultValue: AnySlidingRulerStyle { .init(style: PrimarySlidingRulerStyle()) } 40 | } 41 | 42 | struct SlideRulerCellOverflow: EnvironmentKey { 43 | static var defaultValue: Int { 3 } 44 | } 45 | 46 | extension EnvironmentValues { 47 | var slidingRulerStyle: AnySlidingRulerStyle { 48 | get { self[SlidingRulerStyleEnvironmentKey.self] } 49 | set { self[SlidingRulerStyleEnvironmentKey.self] = newValue } 50 | } 51 | 52 | var slidingRulerCellOverflow: Int { 53 | get { self[SlideRulerCellOverflow.self] } 54 | set { self[SlideRulerCellOverflow.self] = newValue } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Extensions/ViewExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewExtension.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SwiftUI 31 | 32 | extension View { 33 | public func slidingRulerStyle(_ style: S) -> some View where S: SlidingRulerStyle { 34 | environment(\.slidingRulerStyle, .init(style: style)) 35 | } 36 | 37 | public func slidingRulerCellOverflow(_ overflow: Int) -> some View { 38 | environment(\.slidingRulerCellOverflow, overflow) 39 | } 40 | } 41 | 42 | extension View { 43 | func frame(size: CGSize?, alignment: Alignment = .center) -> some View { 44 | self.frame(width: size?.width, height: size?.height, alignment: alignment) 45 | } 46 | 47 | func onPreferenceChange(_ key: K.Type, 48 | storeValueIn storage: Binding, 49 | action: (() -> ())? = nil ) -> some View where K.Value: Equatable { 50 | onPreferenceChange(key, perform: { 51 | storage.wrappedValue = $0 52 | action?() 53 | }) 54 | } 55 | 56 | func propagateHeight(_ key: K.Type, transform: @escaping (K.Value) -> K.Value = { $0 }) -> some View where K.Value == CGFloat? { 57 | overlay( 58 | GeometryReader { proxy in 59 | Color.clear 60 | .preference(key: key, value: transform(proxy.size.height)) 61 | } 62 | ) 63 | } 64 | 65 | func propagateWidth(_ key: K.Type, transform: @escaping (K.Value) -> K.Value = { $0 }) -> some View where K.Value == CGFloat? { 66 | overlay( 67 | GeometryReader { proxy in 68 | Color.clear 69 | .preference(key: key, value: transform(proxy.size.width)) 70 | } 71 | ) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/FlexibleWidthContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIView.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SwiftUI 31 | 32 | private struct _FlexibleWidthContainerHeightPreferenceKey: PreferenceKey { 33 | static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) { 34 | if let newValue = nextValue() { 35 | value = newValue 36 | } 37 | } 38 | } 39 | 40 | struct FlexibleWidthContainer: View { 41 | @State private var height: CGFloat? 42 | private let content: Content 43 | 44 | init(@ViewBuilder content: @escaping () -> Content) { 45 | self.content = content() 46 | } 47 | 48 | var body: some View { 49 | Color.clear 50 | .frame(height: height) 51 | .overlay(content.propagateHeight(_FlexibleWidthContainerHeightPreferenceKey.self)) 52 | .onPreferenceChange(_FlexibleWidthContainerHeightPreferenceKey.self, storeValueIn: $height) 53 | .clipped() 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/HorizontalPanGesture.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HorizontalPanGesture.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SwiftUI 31 | import CoreGeometry 32 | 33 | struct HorizontalDragGestureValue { 34 | let state: UIGestureRecognizer.State 35 | let translation: CGSize 36 | let velocity: CGFloat 37 | let startLocation: CGPoint 38 | let location: CGPoint 39 | } 40 | 41 | protocol HorizontalPanGestureReceiverViewDelegate: class { 42 | func viewTouchedWithoutPan(_ view: UIView) 43 | } 44 | 45 | class HorizontalPanGestureReceiverView: UIView { 46 | weak var delegate: HorizontalPanGestureReceiverViewDelegate? 47 | 48 | override func touchesEnded(_ touches: Set, with event: UIEvent?) { 49 | super.touchesEnded(touches, with: event) 50 | delegate?.viewTouchedWithoutPan(self) 51 | } 52 | } 53 | 54 | extension View { 55 | func onHorizontalDragGesture(initialTouch: @escaping () -> () = { }, 56 | prematureEnd: @escaping () -> () = { }, 57 | perform action: @escaping (HorizontalDragGestureValue) -> ()) -> some View { 58 | self.overlay(HorizontalPanGesture(beginTouch: initialTouch, prematureEnd: prematureEnd, action: action)) 59 | } 60 | } 61 | 62 | private struct HorizontalPanGesture: UIViewRepresentable { 63 | typealias Action = (HorizontalDragGestureValue) -> () 64 | 65 | class Coordinator: NSObject, UIGestureRecognizerDelegate, HorizontalPanGestureReceiverViewDelegate { 66 | private let beginTouch: () -> () 67 | private let prematureEnd: () -> () 68 | private let action: Action 69 | weak var view: UIView? 70 | 71 | init(_ beginTouch: @escaping () -> () = { }, _ prematureEnd: @escaping () -> () = { }, _ action: @escaping Action) { 72 | self.beginTouch = beginTouch 73 | self.prematureEnd = prematureEnd 74 | self.action = action 75 | } 76 | 77 | @objc func panGestureHandler(_ gesture: UIPanGestureRecognizer) { 78 | let translation = gesture.translation(in: view) 79 | let velocity = gesture.velocity(in: view) 80 | let location = gesture.location(in: view) 81 | let startLocation = location - translation 82 | 83 | let value = HorizontalDragGestureValue(state: gesture.state, 84 | translation: .init(horizontal: translation.x), 85 | velocity: velocity.x, 86 | startLocation: startLocation, 87 | location: location) 88 | self.action(value) 89 | } 90 | 91 | func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { 92 | guard let pgr = gestureRecognizer as? UIPanGestureRecognizer else { return false } 93 | let velocity = pgr.velocity(in: view) 94 | return abs(velocity.x) > abs(velocity.y) 95 | } 96 | 97 | func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive event: UIEvent) -> Bool { 98 | beginTouch() 99 | return true 100 | } 101 | 102 | func viewTouchedWithoutPan(_ view: UIView) { 103 | prematureEnd() 104 | } 105 | } 106 | 107 | @Environment(\.slidingRulerStyle) private var style 108 | 109 | let beginTouch: () -> () 110 | let prematureEnd: () -> () 111 | let action: Action 112 | 113 | func makeCoordinator() -> Coordinator { 114 | .init(beginTouch, prematureEnd, action) 115 | } 116 | 117 | func makeUIView(context: Context) -> UIView { 118 | let view = HorizontalPanGestureReceiverView(frame: .init(size: .init(square: 42))) 119 | let pgr = UIPanGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.panGestureHandler(_:))) 120 | view.delegate = context.coordinator 121 | pgr.delegate = context.coordinator 122 | view.addGestureRecognizer(pgr) 123 | context.coordinator.view = view 124 | 125 | // Pointer interactions 126 | if #available(iOS 13.4, *), style.supportsPointerInteraction { 127 | pgr.allowedScrollTypesMask = .continuous 128 | view.addInteraction(UIPointerInteraction(delegate: context.coordinator)) 129 | } 130 | 131 | return view 132 | } 133 | 134 | func updateUIView(_ uiView: UIView, context: Context) { } 135 | } 136 | 137 | @available(iOS 13.4, *) 138 | extension HorizontalPanGesture.Coordinator: UIPointerInteractionDelegate { 139 | func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? { 140 | .init(shape: .path(Pointers.standard), constrainedAxes: .vertical) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/InfiniteMarkOffsetModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InfiniteMarkOffsetModifier.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SwiftUI 31 | 32 | struct InfiniteMarkOffsetModifier: AnimatableModifier { 33 | @Environment(\.slidingRulerCellOverflow) private var overflow 34 | 35 | var value: CGFloat 36 | let step: CGFloat 37 | 38 | var offset: CGFloat { 39 | let overflow = CGFloat(self.overflow) 40 | return (value / step / overflow).approximated().rounded(.towardZero) * overflow 41 | } 42 | 43 | var animatableData: CGFloat { 44 | get { value } 45 | set { value = newValue } 46 | } 47 | 48 | init(_ value: CGFloat, step: CGFloat) { 49 | self.value = value 50 | self.step = step 51 | } 52 | 53 | func body(content: Content) -> some View { 54 | content.preference(key: MarkOffsetPreferenceKey.self, value: offset) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/InfiniteOffsetEffect.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InfiniteOffsetEffect.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SwiftUI 31 | 32 | struct InfiniteOffsetEffect: GeometryEffect { 33 | var offset: CGSize 34 | let maxOffset: CGFloat 35 | 36 | var correctedOffset: CGSize { 37 | let tx = offset.width.truncatingRemainder(dividingBy: maxOffset) 38 | return .init(horizontal: tx) 39 | } 40 | 41 | var animatableData: CGSize.AnimatableData { 42 | get { offset.animatableData } 43 | set { offset.animatableData = newValue } 44 | } 45 | 46 | func effectValue(size: CGSize) -> ProjectionTransform { 47 | assert(!size.width.isNaN && !size.height.isNaN) 48 | return ProjectionTransform(CGAffineTransform(translationX: correctedOffset.width, y: correctedOffset.height)) 49 | } 50 | } 51 | 52 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Mechanic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Mechanic.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | import UIKit.UIScrollView 30 | import CoreGraphics 31 | 32 | enum Mechanic { 33 | 34 | enum Inertia { 35 | private static let epsilon: CGFloat = 0.6 36 | 37 | /// Velocity at time `t` of the initial velocity `v0` decelerated by the given deceleration rate. 38 | static func velocity(atTime t: TimeInterval, v0: CGFloat, decelerationRate rate: UIScrollView.DecelerationRate) -> CGFloat { 39 | v0 * pow(rate.rawValue, (1000 * CGFloat(t))) 40 | } 41 | 42 | /// Travelled distance at time `t` for the initial velocity `v0` decelerated by the given deceleration rate. 43 | static func distance(atTime t: TimeInterval, v0: CGFloat, decelerationRate rate: UIScrollView.DecelerationRate) -> CGFloat { 44 | v0 * (pow(rate.rawValue, 1000 * CGFloat(t)) - 1) / (coef(rate)) 45 | } 46 | 47 | /// Total distance travelled for he initial velocity `v0` decelerated by the given deceleration rate before being completely still. 48 | static func totalDistance(forVelocity v0: CGFloat, decelerationRate rate: UIScrollView.DecelerationRate) -> CGFloat { 49 | distance(atTime: duration(forVelocity: v0, decelerationRate: rate), v0: v0, decelerationRate: rate) 50 | } 51 | 52 | /// Total time ellapsed before the motion become completely still for the initial velocity `v0` decelerated by the given deceleration rate. 53 | static func duration(forVelocity v0: CGFloat, decelerationRate rate: UIScrollView.DecelerationRate) -> TimeInterval { 54 | TimeInterval((log((-1000 * epsilon * log(rate.rawValue)) / abs(v0))) / coef(rate)) 55 | } 56 | 57 | static func time(toReachDistance x: CGFloat, forVelocity v0: CGFloat, decelerationRate rate: UIScrollView.DecelerationRate) -> TimeInterval { 58 | TimeInterval(log(1 + coef(rate) * x / v0) / coef(rate)) 59 | } 60 | 61 | static func coef(_ rate: UIScrollView.DecelerationRate) -> CGFloat { 62 | 1000 * log(rate.rawValue) 63 | } 64 | } 65 | 66 | enum Spring { 67 | private static var e: CGFloat { CGFloat(M_E) } 68 | private static var threshold: CGFloat { 0.25 } 69 | 70 | private static var stiffness: CGFloat { 100 } 71 | private static var damping: CGFloat { 2 * sqrt(stiffness) } 72 | private static var beta: CGFloat { sqrt(stiffness) } 73 | 74 | static func duration(forVelocity v0: CGFloat, displacement c1: CGFloat) -> TimeInterval { 75 | guard v0 + c1 != 0 else { return .zero } 76 | 77 | let c2 = v0 + beta * c1 78 | 79 | let t1 = 1 / beta * log(2 * c1 / threshold) 80 | let t2 = 2 / beta * log(4 * c2 / (e * beta * threshold)) 81 | 82 | return TimeInterval(max(t1, t2)) 83 | } 84 | 85 | static func value(atTime t: TimeInterval, v0: CGFloat, displacement c1: CGFloat) -> CGFloat { 86 | let c2 = v0 + beta * c1 87 | 88 | return exp(-beta * CGFloat(t)) * (c1 + c2 * CGFloat(t)) 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Pointers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Pointers.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import UIKit.UIBezierPath 31 | 32 | enum Pointers { 33 | static var standard: UIBezierPath { 34 | let path = UIBezierPath() 35 | 36 | path.move(to: CGPoint(x: 18.78348, y: 1.134168)) 37 | path.addCurve(to: CGPoint(x: 19, y: 2.051366), controlPoint1: CGPoint(x: 18.925869, y: 1.418949), controlPoint2: CGPoint(x: 19, y: 1.732971)) 38 | path.addLine(to: CGPoint(x: 19, y: 16.949083)) 39 | path.addCurve(to: CGPoint(x: 16.949083, y: 19), controlPoint1: CGPoint(x: 19, y: 18.081774), controlPoint2: CGPoint(x: 18.081774, y: 19)) 40 | path.addCurve(to: CGPoint(x: 16.031885, y: 18.78348), controlPoint1: CGPoint(x: 16.63069, y: 19), controlPoint2: CGPoint(x: 16.316668, y: 18.925869)) 41 | path.addLine(to: CGPoint(x: 1.134168, y: 11.33462)) 42 | path.addCurve(to: CGPoint(x: 0.21697, y: 8.583027), controlPoint1: CGPoint(x: 0.121059, y: 10.828066), controlPoint2: CGPoint(x: -0.289584, y: 9.596135)) 43 | path.addCurve(to: CGPoint(x: 1.134168, y: 7.665829), controlPoint1: CGPoint(x: 0.415425, y: 8.186118), controlPoint2: CGPoint(x: 0.737259, y: 7.864284)) 44 | path.addLine(to: CGPoint(x: 16.031885, y: 0.21697)) 45 | path.addCurve(to: CGPoint(x: 18.78348, y: 1.134168), controlPoint1: CGPoint(x: 17.044994, y: -0.289584), controlPoint2: CGPoint(x: 18.276924, y: 0.121059)) 46 | path.close() 47 | path.move(to: CGPoint(x: 30.21652, y: 1.134168)) 48 | path.addCurve(to: CGPoint(x: 32.968113, y: 0.21697), controlPoint1: CGPoint(x: 30.723076, y: 0.121059), controlPoint2: CGPoint(x: 31.955006, y: -0.289584)) 49 | path.addLine(to: CGPoint(x: 32.968113, y: 0.21697)) 50 | path.addLine(to: CGPoint(x: 47.865833, y: 7.665829)) 51 | path.addCurve(to: CGPoint(x: 48.783031, y: 8.583027), controlPoint1: CGPoint(x: 48.262741, y: 7.864284), controlPoint2: CGPoint(x: 48.584576, y: 8.186118)) 52 | path.addCurve(to: CGPoint(x: 47.865833, y: 11.33462), controlPoint1: CGPoint(x: 49.289585, y: 9.596135), controlPoint2: CGPoint(x: 48.878941, y: 10.828066)) 53 | path.addLine(to: CGPoint(x: 47.865833, y: 11.33462)) 54 | path.addLine(to: CGPoint(x: 32.968113, y: 18.78348)) 55 | path.addCurve(to: CGPoint(x: 32.050915, y: 19), controlPoint1: CGPoint(x: 32.683334, y: 18.925869), controlPoint2: CGPoint(x: 32.369312, y: 19)) 56 | path.addCurve(to: CGPoint(x: 30, y: 16.949083), controlPoint1: CGPoint(x: 30.918226, y: 19), controlPoint2: CGPoint(x: 30, y: 18.081774)) 57 | path.addLine(to: CGPoint(x: 30, y: 16.949083)) 58 | path.addLine(to: CGPoint(x: 30, y: 2.051366)) 59 | path.addCurve(to: CGPoint(x: 30.21652, y: 1.134168), controlPoint1: CGPoint(x: 30, y: 1.732971), controlPoint2: CGPoint(x: 30.074131, y: 1.418949)) 60 | path.close() 61 | path.move(to: CGPoint(x: 24.5, y: 6)) 62 | path.addCurve(to: CGPoint(x: 28, y: 9.5), controlPoint1: CGPoint(x: 26.432997, y: 6), controlPoint2: CGPoint(x: 28, y: 7.567003)) 63 | path.addCurve(to: CGPoint(x: 24.5, y: 13), controlPoint1: CGPoint(x: 28, y: 11.432997), controlPoint2: CGPoint(x: 26.432997, y: 13)) 64 | path.addCurve(to: CGPoint(x: 21, y: 9.5), controlPoint1: CGPoint(x: 22.567003, y: 13), controlPoint2: CGPoint(x: 21, y: 11.432997)) 65 | path.addCurve(to: CGPoint(x: 24.5, y: 6), controlPoint1: CGPoint(x: 21, y: 7.567003), controlPoint2: CGPoint(x: 22.567003, y: 6)) 66 | path.close() 67 | 68 | path.apply(.init(translationX: -24.5, y: 0)) 69 | 70 | return path 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/PreferenceKeys.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreferenceKeys.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SwiftUI 31 | 32 | struct ControlWidthPreferenceKey: PreferenceKey { 33 | static var defaultValue: CGFloat? 34 | static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) { 35 | if let newValue = nextValue() { value = newValue } 36 | } 37 | } 38 | 39 | struct MarkOffsetPreferenceKey: PreferenceKey { 40 | static var defaultValue: CGFloat = .zero 41 | static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { 42 | value = nextValue() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Ruler/Ruler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Ruler.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SwiftUI 31 | 32 | struct Ruler: View, Equatable { 33 | @Environment(\.slidingRulerStyle) private var style 34 | 35 | let cells: [RulerCell] 36 | let step: CGFloat 37 | let markOffset: CGFloat 38 | let bounds: ClosedRange 39 | let formatter: NumberFormatter? 40 | 41 | var body: some View { 42 | HStack(spacing: 0) { 43 | ForEach(self.cells) { cell in 44 | self.style.makeCellBody(configuration: self.configuration(forCell: cell)) 45 | } 46 | } 47 | .animation(nil) 48 | } 49 | 50 | private func configuration(forCell cell: RulerCell) -> SlidingRulerStyleConfiguation { 51 | return .init(mark: (cell.mark + markOffset) * step, bounds: bounds, step: step, formatter: formatter) 52 | } 53 | 54 | static func ==(lhs: Self, rhs: Self) -> Bool { 55 | lhs.step == rhs.step && 56 | lhs.cells.count == rhs.cells.count && 57 | (!StaticSlidingRulerStyleEnvironment.hasMarks || lhs.markOffset == rhs.markOffset) 58 | } 59 | } 60 | 61 | struct Ruler_Previews: PreviewProvider { 62 | static var previews: some View { 63 | Ruler(cells: [.init(CGFloat(0))], 64 | step: 1.0, markOffset: 0, bounds: -1...1, formatter: nil) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Ruler/RulerCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RulerCell.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import CoreGraphics 31 | 32 | class RulerCell: Identifiable { 33 | var id: CGFloat { mark } 34 | var mark: CGFloat 35 | 36 | init(_ mark: Int) { self.mark = .init(mark) } 37 | init(_ mark: CGFloat) { self.mark = mark } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/SlidingRuler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SlidingRuler.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SwiftUI 31 | import SmoothOperators 32 | 33 | @available(iOS 13.0, *) 34 | public struct SlidingRuler: View where V: BinaryFloatingPoint, V.Stride: BinaryFloatingPoint { 35 | 36 | @Environment(\.slidingRulerCellOverflow) private var cellOverflow 37 | 38 | @Environment(\.slidingRulerStyle) private var style 39 | @Environment(\.slidingRulerStyle.cellWidth) private var cellWidth 40 | @Environment(\.slidingRulerStyle.cursorAlignment) private var verticalCursorAlignment 41 | @Environment(\.slidingRulerStyle.fractions) private var fractions 42 | @Environment(\.slidingRulerStyle.hasHalf) private var hasHalf 43 | 44 | @Environment(\.layoutDirection) private var layoutDirection 45 | 46 | /// Bound value. 47 | @Binding private var controlValue: V 48 | /// Possible value range. 49 | private let bounds: ClosedRange 50 | /// Value stride. 51 | private let step: CGFloat 52 | /// When to snap. 53 | private let snap: Mark 54 | /// When to tick. 55 | private let tick: Mark 56 | /// Edit changed callback. 57 | private let editingChangedCallback: (Bool) -> () 58 | /// Number formatter for ruler's marks. 59 | private let formatter: NumberFormatter? 60 | 61 | /// Width of the control, retrieved through preference key. 62 | @State private var controlWidth: CGFloat? 63 | /// Height of the ruller, retrieved through preference key. 64 | @State private var rulerHeight: CGFloat? 65 | 66 | /// Cells of the ruler. 67 | @State private var cells: [RulerCell] = [.init(CGFloat(0))] 68 | 69 | /// Control state. 70 | @State private var state: SlidingRulerState = .idle 71 | /// The reference offset set at the start of a drag session. 72 | @State private var referenceOffset: CGSize = .zero 73 | /// The virtual ruler's drag offset. 74 | @State private var dragOffset: CGSize = .zero 75 | /// Offset of the ruler's displayed marks. 76 | @State private var markOffset: CGFloat = .zero 77 | 78 | /// Non-bound value used for rubber release animation. 79 | @State private var animatedValue: CGFloat = .zero 80 | /// The last value the receiver did set. Used to define if the rendered value was set by the receiver or from another component. 81 | @State private var lastValueSet: CGFloat = .zero 82 | 83 | /// VSynch timer that drives animations. 84 | @State private var animationTimer: VSynchedTimer? = nil 85 | 86 | private var value: CGFloat { 87 | get { CGFloat(controlValue) ?? 0 } 88 | nonmutating set { controlValue = V(newValue) } 89 | } 90 | 91 | /// Allowed drag offset range. 92 | private var dragBounds: ClosedRange { 93 | let lower = bounds.upperBound.isInfinite ? -CGFloat.infinity : -bounds.upperBound * cellWidth / step 94 | let upper = bounds.lowerBound.isInfinite ? CGFloat.infinity : -bounds.lowerBound * cellWidth / step 95 | return .init(uncheckedBounds: (lower, upper)) 96 | } 97 | 98 | /// Over-ranged drag rubber should be released. 99 | private var isRubberBandNeedingRelease: Bool { !dragBounds.contains(dragOffset.width) } 100 | /// Amount of units the ruler can translate in both direction before needing to refresh the cells and reset offset. 101 | private var cellWidthOverflow: CGFloat { cellWidth * CGFloat(cellOverflow) } 102 | /// Current value clamped to the receiver's value bounds. 103 | private var clampedValue: CGFloat { value.clamped(to: bounds) } 104 | 105 | /// Ruler offset used to render the control depending on the state. 106 | private var effectiveOffset: CGSize { 107 | switch state { 108 | case .idle: 109 | return self.offset(fromValue: clampedValue ?? 0) 110 | case .animating: 111 | return self.offset(fromValue: animatedValue ?? 0) 112 | default: 113 | return dragOffset 114 | } 115 | } 116 | 117 | /// Creates a SlidingRuler 118 | /// - Parameters: 119 | /// - value: A binding connected to the control value. 120 | /// - bounds: A closed range of possible values. Defaults to `-V.infinity...V.infinity`. 121 | /// - step: The stride of the SlidingRuler. Defaults to `1`. 122 | /// - snap: The ruler's marks stickyness. Defaults to `.none` 123 | /// - tick: The graduation type that produce an haptic feedback when reached. Defaults to `.none` 124 | /// - onEditingChanged: A closure executed when a drag session happens. It receives a boolean value set to `true` when the drag session starts and `false` when the value stops changing. Defaults to no action. 125 | /// - formatter: A `NumberFormatter` instance the ruler uses to format the ruler's marks. Defaults to `nil`. 126 | public init(value: Binding, 127 | in bounds: ClosedRange = -V.infinity...V.infinity, 128 | step: V.Stride = 1, 129 | snap: Mark = .none, 130 | tick: Mark = .none, 131 | onEditingChanged: @escaping (Bool) -> () = { _ in }, 132 | formatter: NumberFormatter? = nil) { 133 | self._controlValue = value 134 | self.bounds = .init(uncheckedBounds: (CGFloat(bounds.lowerBound), CGFloat(bounds.upperBound))) 135 | self.step = CGFloat(step) 136 | self.snap = snap 137 | self.tick = tick 138 | self.editingChangedCallback = onEditingChanged 139 | self.formatter = formatter 140 | } 141 | 142 | // MARK: Rendering 143 | 144 | public var body: some View { 145 | let renderedValue: CGFloat, renderedOffset: CGSize 146 | 147 | (renderedValue, renderedOffset) = renderingValues() 148 | 149 | return FlexibleWidthContainer { 150 | ZStack(alignment: .init(horizontal: .center, vertical: self.verticalCursorAlignment)) { 151 | Ruler(cells: self.cells, step: self.step, markOffset: self.markOffset, bounds: self.bounds, formatter: self.formatter) 152 | .equatable() 153 | .animation(nil) 154 | .modifier(InfiniteOffsetEffect(offset: renderedOffset, maxOffset: self.cellWidthOverflow)) 155 | self.style.makeCursorBody() 156 | } 157 | } 158 | .modifier(InfiniteMarkOffsetModifier(renderedValue, step: step)) 159 | .propagateWidth(ControlWidthPreferenceKey.self) 160 | .onPreferenceChange(MarkOffsetPreferenceKey.self, storeValueIn: $markOffset) 161 | .onPreferenceChange(ControlWidthPreferenceKey.self, storeValueIn: $controlWidth) { 162 | self.updateCellsIfNeeded() 163 | } 164 | .transaction { 165 | if $0.animation != nil { $0.animation = .easeIn(duration: 0.1) } 166 | } 167 | .onHorizontalDragGesture(initialTouch: firstTouchHappened, 168 | prematureEnd: panGestureEndedPrematurely, 169 | perform: horizontalDragAction(withValue:)) 170 | } 171 | 172 | private func renderingValues() -> (CGFloat, CGSize) { 173 | let value: CGFloat 174 | let offset: CGSize 175 | 176 | switch self.state { 177 | case .flicking, .springing: 178 | if self.value != self.lastValueSet { 179 | self.animationTimer?.cancel() 180 | NextLoop { self.state = .idle } 181 | value = clampedValue ?? 0 182 | offset = self.offset(fromValue: value) 183 | } else { 184 | fallthrough 185 | } 186 | case .dragging, .stoppedFlick, .stoppedSpring: 187 | offset = dragOffset 188 | value = self.value(fromOffset: offset) 189 | case .animating: 190 | if self.value != lastValueSet { 191 | NextLoop { self.state = .idle } 192 | fallthrough 193 | } 194 | value = animatedValue 195 | offset = self.offset(fromValue: value) 196 | case .idle: 197 | value = clampedValue ?? 0 198 | offset = self.offset(fromValue: value) 199 | } 200 | 201 | return (value, offset) 202 | } 203 | } 204 | 205 | // MARK: Drag Gesture Management 206 | extension SlidingRuler { 207 | 208 | /// Callback handling first touch event. 209 | private func firstTouchHappened() { 210 | switch state { 211 | case .flicking: 212 | cancelCurrentTimer() 213 | state = .stoppedFlick 214 | case .springing: 215 | cancelCurrentTimer() 216 | state = .stoppedSpring 217 | default: break 218 | } 219 | } 220 | 221 | /// Callback handling gesture premature ending. 222 | private func panGestureEndedPrematurely() { 223 | switch state { 224 | case .stoppedFlick: 225 | state = .idle 226 | snapIfNeeded() 227 | case .stoppedSpring: 228 | releaseRubberBand() 229 | default: 230 | break 231 | } 232 | } 233 | 234 | /// Composite callback passed to the horizontal drag gesture recognizer. 235 | private func horizontalDragAction(withValue value: HorizontalDragGestureValue) { 236 | switch value.state { 237 | case .began: horizontalDragBegan(value) 238 | case .changed: horizontalDragChanged(value) 239 | case .ended: horizontalDragEnded(value) 240 | default: return 241 | } 242 | } 243 | 244 | /// Callback handling horizontal drag gesture begining. 245 | private func horizontalDragBegan(_ value: HorizontalDragGestureValue) { 246 | editingChangedCallback(true) 247 | if state != .stoppedSpring { 248 | dragOffset = self.offset(fromValue: clampedValue ?? 0) 249 | } 250 | referenceOffset = dragOffset 251 | state = .dragging 252 | } 253 | 254 | /// Callback handling horizontal drag gesture updating. 255 | private func horizontalDragChanged(_ value: HorizontalDragGestureValue) { 256 | let newOffset = self.directionalOffset(value.translation.horizontal + referenceOffset) 257 | let newValue = self.value(fromOffset: newOffset) 258 | 259 | self.tickIfNeeded(dragOffset, newOffset) 260 | 261 | withoutAnimation { 262 | self.setValue(newValue) 263 | dragOffset = self.applyRubber(to: newOffset) 264 | } 265 | } 266 | 267 | /// Callback handling horizontal drag gesture ending. 268 | private func horizontalDragEnded(_ value: HorizontalDragGestureValue) { 269 | if isRubberBandNeedingRelease { 270 | self.releaseRubberBand() 271 | self.endDragSession() 272 | } else if abs(value.velocity) > 90 { 273 | self.applyInertia(initialVelocity: value.velocity) 274 | } else { 275 | state = .idle 276 | self.endDragSession() 277 | self.snapIfNeeded() 278 | } 279 | } 280 | 281 | /// Drag session clean-up. 282 | private func endDragSession() { 283 | referenceOffset = .zero 284 | self.editingChangedCallback(false) 285 | } 286 | } 287 | 288 | // MARK: Value Management 289 | extension SlidingRuler { 290 | 291 | /// Compute the value from the given ruler's offset. 292 | private func value(fromOffset offset: CGSize) -> CGFloat { 293 | self.directionalValue(-CGFloat(offset.width / cellWidth) * step) 294 | } 295 | 296 | /// Compute the ruler's offset from the given value. 297 | private func offset(fromValue value: CGFloat) -> CGSize { 298 | let width = -value * cellWidth / step 299 | return self.directionalOffset(.init(horizontal: width)) 300 | } 301 | 302 | /// Sets the value. 303 | private func setValue(_ newValue: CGFloat) { 304 | let clampedValue = newValue.clamped(to: bounds) 305 | 306 | if clampedValue.isBound(of: bounds) && !value.isBound(of: self.bounds) { 307 | self.boundaryMet() 308 | } 309 | 310 | if lastValueSet != clampedValue { lastValueSet = clampedValue } 311 | if value != clampedValue { value = clampedValue } 312 | } 313 | 314 | /// Snaps the value to the nearest mark based on the `snap` property. 315 | private func snapIfNeeded() { 316 | let nearest = self.nearestSnapValue(self.value) 317 | guard nearest != value else { return } 318 | 319 | let delta = abs(nearest - value) 320 | let fractionalValue = step / CGFloat(fractions) 321 | 322 | guard delta < fractionalValue else { return } 323 | 324 | let animThreshold = step / 200 325 | let animation: Animation? = delta > animThreshold ? .easeOut(duration: 0.1) : nil 326 | 327 | dragOffset = offset(fromValue: nearest) 328 | withAnimation(animation) { self.value = nearest } 329 | } 330 | 331 | /// Returns the nearest value to snap on based on the `snap` property. 332 | private func nearestSnapValue(_ value: CGFloat) -> CGFloat { 333 | guard snap != .none else { return value } 334 | 335 | let t: CGFloat 336 | 337 | switch snap { 338 | case .unit: t = step 339 | case .half: t = step / 2 340 | case .fraction: t = step / CGFloat(fractions) 341 | default: fatalError() 342 | } 343 | 344 | let lower = (value / t).rounded(.down) * t 345 | let upper = (value / t).rounded(.up) * t 346 | let deltaDown = abs(value - lower).approximated() 347 | let deltaUp = abs(value - upper).approximated() 348 | 349 | return deltaDown < deltaUp ? lower : upper 350 | } 351 | 352 | /// Transforms any numerical value based the layout direction. /!\ not properly tested. 353 | func directionalValue(_ value: T) -> T { 354 | value * (layoutDirection == .rightToLeft ? -1 : 1) 355 | } 356 | 357 | /// Transforms an offsetr based on the layout direction. /!\ not properly tested. 358 | func directionalOffset(_ offset: CGSize) -> CGSize { 359 | let width = self.directionalValue(offset.width) 360 | return .init(width: width, height: offset.height) 361 | } 362 | } 363 | 364 | // MARK: Control Update 365 | extension SlidingRuler { 366 | 367 | /// Adjusts the number of cells as the control size changes. 368 | private func updateCellsIfNeeded() { 369 | guard let controlWidth = controlWidth else { return } 370 | let count = (Int(ceil(controlWidth / cellWidth)) + cellOverflow * 2).nextOdd() 371 | if count != cells.count { self.populateCells(count: count) } 372 | } 373 | 374 | /// Creates `count` cells for the ruler. 375 | private func populateCells(count: Int) { 376 | let boundary = count.previousEven() / 2 377 | cells = (-boundary...boundary).map { .init($0) } 378 | } 379 | } 380 | 381 | extension UIScrollView.DecelerationRate { 382 | static var ruler: Self { Self.init(rawValue: 0.9972) } 383 | } 384 | 385 | // MARK: Mechanic Simulation 386 | extension SlidingRuler { 387 | 388 | private func applyInertia(initialVelocity: CGFloat) { 389 | func shiftOffset(by distance: CGSize) { 390 | let newOffset = directionalOffset(self.referenceOffset + distance) 391 | let newValue = self.value(fromOffset: newOffset) 392 | 393 | self.tickIfNeeded(self.dragOffset, newOffset) 394 | 395 | withoutAnimation { 396 | self.setValue(newValue) 397 | self.dragOffset = newOffset 398 | } 399 | } 400 | 401 | referenceOffset = dragOffset 402 | 403 | let rate = UIScrollView.DecelerationRate.ruler 404 | let totalDistance = Mechanic.Inertia.totalDistance(forVelocity: initialVelocity, decelerationRate: rate) 405 | let finalOffset = self.referenceOffset + .init(horizontal: totalDistance) 406 | 407 | state = .flicking 408 | 409 | if dragBounds.contains(finalOffset.width) { 410 | let duration = Mechanic.Inertia.duration(forVelocity: initialVelocity, decelerationRate: rate) 411 | 412 | animationTimer = .init(duration: duration, animations: { (progress, interval) in 413 | let distance = CGSize(horizontal: Mechanic.Inertia.distance(atTime: progress, v0: initialVelocity, decelerationRate: rate)) 414 | shiftOffset(by: distance) 415 | }, completion: { (completed) in 416 | if completed { 417 | self.state = .idle 418 | shiftOffset(by: .init(horizontal: totalDistance)) 419 | self.snapIfNeeded() 420 | self.endDragSession() 421 | } else { 422 | NextLoop { self.endDragSession() } 423 | } 424 | }) 425 | } else { 426 | let allowedDistance = finalOffset.width.clamped(to: dragBounds) - self.referenceOffset.width 427 | let duration = Mechanic.Inertia.time(toReachDistance: allowedDistance, forVelocity: initialVelocity, decelerationRate: rate) 428 | animationTimer = .init(duration: duration, animations: { (progress, interval) in 429 | let distance = CGSize(horizontal: Mechanic.Inertia.distance(atTime: progress, v0: initialVelocity, decelerationRate: rate)) 430 | shiftOffset(by: distance) 431 | }, completion: { (completed) in 432 | if completed { 433 | shiftOffset(by: .init(horizontal: allowedDistance)) 434 | let remainingVelocity = Mechanic.Inertia.velocity(atTime: duration, v0: initialVelocity, decelerationRate: rate) 435 | self.applyInertialRubber(remainingVelocity: remainingVelocity) 436 | self.endDragSession() 437 | } else { 438 | NextLoop { self.endDragSession() } 439 | } 440 | }) 441 | } 442 | } 443 | 444 | private func applyInertialRubber(remainingVelocity: CGFloat) { 445 | let duration = Mechanic.Spring.duration(forVelocity: abs(remainingVelocity), displacement: 0) 446 | let targetOffset = dragOffset.width.nearestBound(of: dragBounds) 447 | 448 | state = .springing 449 | 450 | animationTimer = .init(duration: duration, animations: { (progress, interval) in 451 | let delta = Mechanic.Spring.value(atTime: progress, v0: remainingVelocity, displacement: 0) 452 | self.dragOffset = .init(horizontal: targetOffset + delta) 453 | }, completion: { (completed) in 454 | if completed { 455 | self.dragOffset = .init(horizontal: targetOffset) 456 | self.state = .idle 457 | } 458 | }) 459 | } 460 | 461 | /// Applies rubber effect to an off-range offset. 462 | private func applyRubber(to offset: CGSize) -> CGSize { 463 | let dragBounds = self.dragBounds 464 | guard !dragBounds.contains(offset.width) else { return offset } 465 | 466 | let tx = offset.width 467 | let limit = tx.clamped(to: dragBounds) 468 | let delta = abs(tx - limit) 469 | let factor: CGFloat = tx - limit < 0 ? -1 : 1 470 | let d = controlWidth ?? 0 471 | let c = CGFloat(0.55) 472 | let rubberDelta = (1 - (1 / ((c * delta / d) + 1))) * d * factor 473 | let rubberTx = limit + rubberDelta 474 | 475 | return .init(horizontal: rubberTx) 476 | } 477 | 478 | /// Animates an off-range offset back in place 479 | private func releaseRubberBand() { 480 | let targetOffset = dragOffset.width.clamped(to: dragBounds) 481 | let delta = dragOffset.width - targetOffset 482 | let duration = Mechanic.Spring.duration(forVelocity: 0, displacement: abs(delta)) 483 | 484 | state = .springing 485 | 486 | animationTimer = .init(duration: duration, animations: { (progress, interval) in 487 | let newDelta = Mechanic.Spring.value(atTime: progress, v0: 0, displacement: delta) 488 | self.dragOffset = .init(horizontal: targetOffset + newDelta) 489 | }, completion: { (completed) in 490 | if completed { 491 | self.dragOffset = .init(horizontal: targetOffset) 492 | self.state = .idle 493 | } 494 | }) 495 | } 496 | 497 | /// Stops the current animation and cleans the timer. 498 | private func cancelCurrentTimer() { 499 | animationTimer?.cancel() 500 | animationTimer = nil 501 | } 502 | 503 | private func cleanTimer() { 504 | animationTimer = nil 505 | } 506 | } 507 | 508 | 509 | 510 | // MARK: Tick Management 511 | extension SlidingRuler { 512 | private func boundaryMet() { 513 | let fg = UIImpactFeedbackGenerator(style: .rigid) 514 | fg.impactOccurred(intensity: 0.667) 515 | } 516 | 517 | private func tickIfNeeded(_ offset0: CGSize, _ offset1: CGSize) { 518 | let width0 = offset0.width, width1 = offset1.width 519 | 520 | let dragBounds = self.dragBounds 521 | guard dragBounds.contains(width0), dragBounds.contains(width1), 522 | !width0.isBound(of: dragBounds), !width1.isBound(of: dragBounds) else { return } 523 | 524 | let t: CGFloat 525 | switch tick { 526 | case .unit: t = cellWidth 527 | case .half: t = hasHalf ? cellWidth / 2 : cellWidth 528 | case .fraction: t = cellWidth / CGFloat(fractions) 529 | case .none: return 530 | } 531 | 532 | if width1 == 0 || 533 | (width0 < 0) != (width1 < 0) || 534 | Int((width0 / t).approximated()) != Int((width1 / t).approximated()) { 535 | valueTick() 536 | } 537 | } 538 | 539 | private func valueTick() { 540 | let fg = UIImpactFeedbackGenerator(style: .light) 541 | fg.impactOccurred(intensity: 0.5) 542 | } 543 | } 544 | 545 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Styling/AnyFractionableView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnyFractionableView.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SwiftUI 31 | 32 | struct AnyFractionableView: FractionableView { 33 | static var fractions: Int { 0 } 34 | private let view: AnyView 35 | var body: some View { view } 36 | init(_ view: V) { self.view = AnyView(view) } 37 | } 38 | 39 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Styling/AnySlidingRulerStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnySlidingRulerStyle.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SwiftUI 31 | 32 | struct AnySlidingRulerStyle: SlidingRulerStyle { 33 | private let cellProvider: (SlidingRulerStyleConfiguation) -> AnyFractionableView 34 | private let cursorProvider: () -> AnyView 35 | 36 | let fractions: Int 37 | let cellWidth: CGFloat 38 | let cursorAlignment: VerticalAlignment 39 | let hasMarks: Bool 40 | 41 | init (style: T) { 42 | self.cellProvider = { (configuration: SlidingRulerStyleConfiguation) -> AnyFractionableView in 43 | AnyFractionableView(style.makeCellBody(configuration: configuration)) 44 | } 45 | self.cursorProvider = { 46 | AnyView(style.makeCursorBody()) 47 | } 48 | self.fractions = style.fractions 49 | self.cellWidth = style.cellWidth 50 | self.cursorAlignment = style.cursorAlignment 51 | self.hasMarks = style.hasMarks 52 | } 53 | 54 | func makeCellBody(configuration: SlidingRulerStyleConfiguation) -> some FractionableView { 55 | cellProvider(configuration) 56 | } 57 | 58 | func makeCursorBody() -> some View { 59 | cursorProvider() 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Styling/CenteredStyle/BlankCenteredStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BlankCenteredStyle.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SwiftUI 31 | 32 | public struct BlankCenteredSlidingRulerStyle: SlidingRulerStyle { 33 | public let cursorAlignment: VerticalAlignment = .top 34 | 35 | public func makeCellBody(configuration: SlidingRulerStyleConfiguation) -> some FractionableView { 36 | BlankCenteredCellBody(mark: configuration.mark, 37 | bounds: configuration.bounds, 38 | step: configuration.step, 39 | cellWidth: cellWidth) 40 | } 41 | 42 | public func makeCursorBody() -> some View { 43 | NativeCursorBody() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Styling/CenteredStyle/CenteredCellBody.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CenteredCellBody.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SwiftUI 31 | 32 | struct BlankCenteredCellBody: NativeRulerCellView { 33 | var mark: CGFloat 34 | var bounds: ClosedRange 35 | var step: CGFloat 36 | var cellWidth: CGFloat 37 | 38 | var scale: some ScaleView { CenteredScaleView(width: cellWidth) } 39 | } 40 | 41 | struct CenteredCellBody: NativeMarkedRulerCellView { 42 | var mark: CGFloat 43 | var bounds: ClosedRange 44 | var step: CGFloat 45 | var cellWidth: CGFloat 46 | var numberFormatter: NumberFormatter? 47 | 48 | var cell: some RulerCellView { BlankCenteredCellBody(mark: mark, bounds: bounds, step: step, cellWidth: cellWidth) } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Styling/CenteredStyle/CenteredScaleView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BlankCellView.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SwiftUI 31 | 32 | struct CenteredScaleView: ScaleView { 33 | struct ScaleShape: Shape { 34 | fileprivate var unitMarkSize: CGSize { .init(width: 3.0, height: 27.0)} 35 | fileprivate var halfMarkSize: CGSize { .init(width: UIScreen.main.scale == 3 ? 1.8 : 2.0, height: 19.0) } 36 | fileprivate var fractionMarkSize: CGSize { .init(width: 1.0, height: 11.0)} 37 | 38 | func path(in rect: CGRect) -> Path { 39 | let centerX = rect.center.x 40 | let centerY = rect.center.y 41 | var p = Path() 42 | 43 | p.addRoundedRect(in: unitRect(x: centerX, y: centerY), cornerSize: .init(square: unitMarkSize.width/2)) 44 | p.addRoundedRect(in: halfRect(x: 0, y: centerY), cornerSize: .init(square: halfMarkSize.width/2)) 45 | p.addRoundedRect(in: halfRect(x: rect.maxX, y: centerY), cornerSize: .init(square: halfMarkSize.width/2)) 46 | 47 | let tenth = rect.width / 10 48 | for i in 1...4 { 49 | p.addRoundedRect(in: tenthRect(x: centerX + CGFloat(i) * tenth, y: centerY), cornerSize: .init(square: fractionMarkSize.width/2)) 50 | p.addRoundedRect(in: tenthRect(x: centerX - CGFloat(i) * tenth, y: centerY), cornerSize: .init(square: fractionMarkSize.width/2)) 51 | } 52 | 53 | return p 54 | } 55 | 56 | private func unitRect(x: CGFloat, y: CGFloat) -> CGRect { .init(center: .init(x: x, y: y), size: unitMarkSize) } 57 | private func halfRect(x: CGFloat, y: CGFloat) -> CGRect { .init(center: .init(x: x, y: y), size: halfMarkSize) } 58 | private func tenthRect(x: CGFloat, y: CGFloat) -> CGRect { .init(center: .init(x: x, y: y), size: fractionMarkSize) } 59 | } 60 | 61 | let width: CGFloat 62 | let height: CGFloat 63 | var shape: ScaleShape { .init() } 64 | 65 | var unitMarkWidth: CGFloat { shape.unitMarkSize.width } 66 | var halfMarkWidth: CGFloat { shape.halfMarkSize.width } 67 | var fractionMarkWidth: CGFloat { shape.fractionMarkSize.width } 68 | 69 | init(width: CGFloat, height: CGFloat = 30) { 70 | self.width = width 71 | self.height = height 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Styling/CenteredStyle/CenteredStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CenteredStyle.swift 3 | // 4 | // SlidingRulerTestingBoard 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SwiftUI 31 | 32 | public struct CenteredSlindingRulerStyle: SlidingRulerStyle { 33 | public var cursorAlignment: VerticalAlignment = .top 34 | 35 | public func makeCellBody(configuration: SlidingRulerStyleConfiguation) -> some FractionableView { 36 | CenteredCellBody(mark: configuration.mark, 37 | bounds: configuration.bounds, 38 | step: configuration.step, 39 | cellWidth: cellWidth, 40 | numberFormatter: configuration.formatter) 41 | } 42 | 43 | public func makeCursorBody() -> some View { 44 | NativeCursorBody() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Styling/DefaultStyle/BlankStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BlankStyle.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SwiftUI 31 | 32 | public struct BlankSlidingRulerStyle: SlidingRulerStyle { 33 | public let cursorAlignment: VerticalAlignment = .top 34 | 35 | public func makeCellBody(configuration: SlidingRulerStyleConfiguation) -> some FractionableView { 36 | BlankCellBody(mark: configuration.mark, 37 | bounds: configuration.bounds, 38 | step: configuration.step, 39 | cellWidth: cellWidth) 40 | } 41 | 42 | public func makeCursorBody() -> some View { 43 | NativeCursorBody() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Styling/DefaultStyle/DefaultCellBody.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultCellBody.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SwiftUI 31 | 32 | struct BlankCellBody: NativeRulerCellView { 33 | var mark: CGFloat 34 | var bounds: ClosedRange 35 | var step: CGFloat 36 | var cellWidth: CGFloat 37 | 38 | var scale: some ScaleView { DefaultScaleView(width: cellWidth) } 39 | } 40 | 41 | struct DefaultCellBody: NativeMarkedRulerCellView { 42 | var mark: CGFloat 43 | var bounds: ClosedRange 44 | var step: CGFloat 45 | var cellWidth: CGFloat 46 | var numberFormatter: NumberFormatter? 47 | 48 | var cell: some RulerCellView { BlankCellBody(mark: mark, bounds: bounds, step: step, cellWidth: cellWidth) } 49 | } 50 | 51 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Styling/DefaultStyle/DefaultScaleView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScaleView.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SwiftUI 31 | 32 | struct DefaultScaleView: ScaleView { 33 | struct ScaleShape: Shape { 34 | fileprivate var unitMarkSize: CGSize { .init(width: 3.0, height: 27.0)} 35 | fileprivate var halfMarkSize: CGSize { .init(width: UIScreen.main.scale == 3 ? 1.8 : 2.0, height: 19.0) } 36 | fileprivate var fractionMarkSize: CGSize { .init(width: 1.0, height: 11.0)} 37 | 38 | func path(in rect: CGRect) -> Path { 39 | let centerX = rect.center.x 40 | var p = Path() 41 | 42 | p.addRoundedRect(in: unitRect(x: centerX), cornerSize: .init(square: unitMarkSize.width/2)) 43 | p.addRoundedRect(in: halfRect(x: 0), cornerSize: .init(square: halfMarkSize.width/2)) 44 | p.addRoundedRect(in: halfRect(x: rect.maxX), cornerSize: .init(square: halfMarkSize.width/2)) 45 | 46 | let tenth = rect.width / 10 47 | for i in 1...4 { 48 | p.addRoundedRect(in: tenthRect(x: centerX + CGFloat(i) * tenth), cornerSize: .init(square: fractionMarkSize.width/2)) 49 | p.addRoundedRect(in: tenthRect(x: centerX - CGFloat(i) * tenth), cornerSize: .init(square: fractionMarkSize.width/2)) 50 | } 51 | 52 | return p 53 | } 54 | 55 | private func unitRect(x: CGFloat) -> CGRect { rect(centerX: x, size: unitMarkSize) } 56 | private func halfRect(x: CGFloat) -> CGRect { rect(centerX: x, size: halfMarkSize) } 57 | private func tenthRect(x: CGFloat) -> CGRect { rect(centerX: x, size: fractionMarkSize) } 58 | 59 | private func rect(centerX x: CGFloat, size: CGSize) -> CGRect { 60 | CGRect(origin: .init(x: x - size.width / 2, y: 0), size: size) 61 | } 62 | } 63 | 64 | var shape: ScaleShape { .init() } 65 | let width: CGFloat 66 | let height: CGFloat 67 | 68 | var unitMarkWidth: CGFloat { shape.unitMarkSize.width } 69 | var halfMarkWidth: CGFloat { shape.halfMarkSize.width } 70 | var fractionMarkWidth: CGFloat { shape.fractionMarkSize.width } 71 | 72 | init(width: CGFloat, height: CGFloat = 30) { 73 | self.width = width 74 | self.height = height 75 | } 76 | } 77 | 78 | struct ScaleView_Previews: PreviewProvider { 79 | static var previews: some View { 80 | Group { 81 | DefaultScaleView(width: 120) 82 | } 83 | .previewLayout(.sizeThatFits) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Styling/DefaultStyle/DefaultStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultSlidingRulerStyle.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SwiftUI 31 | 32 | public struct PrimarySlidingRulerStyle: SlidingRulerStyle { 33 | public let cursorAlignment: VerticalAlignment = .top 34 | 35 | public func makeCellBody(configuration: SlidingRulerStyleConfiguation) -> some FractionableView { 36 | DefaultCellBody(mark: configuration.mark, 37 | bounds: configuration.bounds, 38 | step: configuration.step, 39 | cellWidth: cellWidth, 40 | numberFormatter: configuration.formatter) 41 | } 42 | 43 | public func makeCursorBody() -> some View { 44 | NativeCursorBody() 45 | } 46 | } 47 | 48 | struct DefaultStyle_Previews: PreviewProvider { 49 | struct CellTrio: View { 50 | let range: ClosedRange 51 | let width: CGFloat 52 | 53 | var body: some View { 54 | HStack(spacing: 0) { 55 | BlankCellBody(mark: -1, bounds: range, step: 1, cellWidth: width).clipped() 56 | BlankCellBody(mark: 0, bounds: range, step: 1, cellWidth: width).clipped() 57 | BlankCellBody(mark: 1, bounds: range, step: 1, cellWidth: width).clipped() 58 | } 59 | } 60 | } 61 | 62 | static var previews: some View { 63 | CellTrio(range: -0.4...0.9, width: 120) 64 | .previewLayout(.sizeThatFits) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Styling/NativeCursorBody.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultCursorBody.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | import SwiftUI 30 | 31 | public struct NativeCursorBody: View { 32 | public var body: some View { 33 | Capsule() 34 | .foregroundColor(.red) 35 | .frame(width: UIScreen.main.scale == 3 ? 1.8 : 2, height: 30) 36 | } 37 | } 38 | 39 | struct CursorBody_Previews: PreviewProvider { 40 | static var previews: some View { 41 | NativeCursorBody() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Styling/Protocols/FractionableView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Fractionable.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SwiftUI 31 | 32 | public protocol FractionableView: View { 33 | static var fractions: Int { get } 34 | static var hasHalf: Bool { get } 35 | } 36 | 37 | extension FractionableView { 38 | static var fractions: Int { 10 } 39 | static var hasHalf: Bool { fractions.isEven } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Styling/Protocols/MarkedRulerCellView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MarkedCellBody.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SwiftUI 31 | 32 | public protocol MarkedRulerCellView: FractionableView { 33 | associatedtype CellView: RulerCellView 34 | 35 | var mark: CGFloat { get } 36 | var bounds: ClosedRange { get } 37 | var step: CGFloat { get } 38 | var cellWidth: CGFloat { get } 39 | 40 | var numberFormatter: NumberFormatter? { get } 41 | var markColor: Color { get } 42 | var cell: CellView { get } 43 | } 44 | 45 | extension MarkedRulerCellView { 46 | static var fractions: Int { CellView.fractions } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Styling/Protocols/NativeMarkedRulerCellView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NativeMarkedCellBody.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SwiftUI 31 | 32 | protocol NativeMarkedRulerCellView: MarkedRulerCellView { } 33 | extension NativeMarkedRulerCellView { 34 | var markColor: Color { 35 | bounds.contains(mark) ? .init(.label) : .init(.tertiaryLabel) 36 | } 37 | var displayMark: String { numberFormatter?.string(for: mark) ?? "\(mark.approximated())" } 38 | 39 | var body: some View { 40 | VStack { 41 | cell.equatable() 42 | Spacer() 43 | Text(verbatim: displayMark) 44 | .font(Font.footnote.monospacedDigit()) 45 | .foregroundColor(markColor) 46 | .lineLimit(1) 47 | } 48 | .fixedSize() 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Styling/Protocols/NativeRulerCellView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NativeCellBody.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SwiftUI 31 | import SmoothOperators 32 | 33 | protocol NativeRulerCellView: RulerCellView { } 34 | extension NativeRulerCellView { 35 | var maskShape: some Shape { 36 | guard !isComplete else { return ScaleMask(originX: .zero, width: cellWidth) } 37 | guard cellBounds.overlaps(bounds) else { return ScaleMask.zero } 38 | 39 | let availableRange = bounds.clamped(to: cellBounds) 40 | 41 | let leadingOffset: CGFloat = adjustOffset(abs(CGFloat((availableRange.lowerBound - cellBounds.lowerBound) / step) * cellWidth).approximated()) 42 | let trailingOffset: CGFloat = adjustOffset(abs(CGFloat((cellBounds.upperBound - availableRange.upperBound) / step) * cellWidth).approximated()) 43 | 44 | let maskWidth = cellWidth - (leadingOffset + trailingOffset) 45 | 46 | return ScaleMask(originX: leadingOffset, width: maskWidth) 47 | } 48 | 49 | private var hasHalf: Bool { Scale.hasHalf } 50 | private var fractions: Int { Self.fractions } 51 | private var fractionWidth: CGFloat { cellWidth / CGFloat(fractions) } 52 | 53 | private func areaLimits(_ area: Int) -> (leading: CGFloat, trailing: CGFloat) { 54 | let leadingLimit: CGFloat 55 | let trailingLimit: CGFloat 56 | 57 | let leadingOffset = CGFloat(area) * fractionWidth 58 | let trailingOffset = CGFloat(area+) * fractionWidth 59 | 60 | if hasHalf { 61 | let leadingUnitArea = fractions / 2 62 | let trailingUnitArea = leadingUnitArea- 63 | 64 | if area == 0 { leadingLimit = scale.halfMarkWidth / 2 + scale.halfMarkOffset } 65 | else if area == leadingUnitArea { leadingLimit = leadingOffset + scale.unitMarkWidth / 2 + scale.unitMarkOffset } 66 | else { leadingLimit = leadingOffset + scale.fractionMarkWidth / 2 + scale.fractionMarkOffset } 67 | 68 | if area == fractions- { trailingLimit = trailingOffset - scale.unitMarkWidth / 2 + scale.unitMarkOffset } 69 | else if area == trailingUnitArea { trailingLimit = trailingOffset - scale.unitMarkWidth / 2 + scale.unitMarkOffset } 70 | else { trailingLimit = trailingOffset - scale.fractionMarkWidth / 2 + scale.fractionMarkOffset } 71 | } else { 72 | if area == 0 { leadingLimit = .nan } 73 | else if Scale.hasHalf && area == (fractions / 2) { leadingLimit = CGFloat(area) * fractionWidth + scale.halfMarkWidth / 2 } 74 | else { leadingLimit = CGFloat(area) * fractionWidth + fractionWidth / 2 } 75 | 76 | if area == fractions - 1 { trailingLimit = cellWidth - scale.unitMarkWidth / 2 } 77 | else if Scale.hasHalf && area == (fractions / 2 - 1) { trailingLimit = CGFloat(area + 1) * fractionWidth + scale.halfMarkWidth / 2 } 78 | else { trailingLimit = CGFloat(area + 1) * fractionWidth - fractionWidth / 2 } 79 | } 80 | 81 | return (leadingLimit, trailingLimit) 82 | } 83 | 84 | private func adjustOffset(_ offset: CGFloat) -> CGFloat { 85 | guard !offset.isZero else { return offset } 86 | 87 | let area = offset.truncatingRemainder(dividingBy: fractionWidth) > 0 ? 88 | Int(offset / fractionWidth) : 89 | Int(offset / fractionWidth)- 90 | let limits = areaLimits(area) 91 | 92 | if offset < limits.leading { return limits.leading } 93 | else if offset > limits.trailing { return limits.trailing } 94 | else { return offset } 95 | } 96 | } 97 | 98 | private struct ScaleMask: Shape { 99 | let originX: CGFloat 100 | let centerMaskWidth: CGFloat 101 | 102 | static var zero: Self { .init(originX: .zero, width: .zero) } 103 | 104 | init(originX: CGFloat, width: CGFloat) { 105 | self.originX = originX 106 | self.centerMaskWidth = width 107 | } 108 | 109 | func path(in rect: CGRect) -> Path { 110 | var p = Path() 111 | let centerRect = CGRect(origin: .init(x: originX, y: 0), 112 | size: .init(width: centerMaskWidth, height: rect.height)) 113 | p.addRect(centerRect) 114 | 115 | return p 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Styling/Protocols/RulerCellView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CellBody.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SwiftUI 31 | 32 | public protocol RulerCellView: FractionableView, Equatable { 33 | associatedtype Scale: ScaleView 34 | associatedtype MaskShape: Shape 35 | 36 | var mark: CGFloat { get } 37 | var bounds: ClosedRange { get } 38 | var cellBounds: ClosedRange { get } 39 | var step: CGFloat { get } 40 | var cellWidth: CGFloat { get } 41 | 42 | var scale: Scale { get } 43 | var maskShape: MaskShape { get } 44 | } 45 | 46 | extension RulerCellView { 47 | static var fractions: Int { Scale.fractions } 48 | 49 | var cellBounds: ClosedRange { 50 | ClosedRange(uncheckedBounds: (mark - step / 2, mark + step / 2)) 51 | } 52 | 53 | var isComplete: Bool { bounds.contains(cellBounds) } 54 | 55 | var body: some View { 56 | ZStack { 57 | scale 58 | .equatable() 59 | .foregroundColor(.init(.label)) 60 | .clipShape(maskShape) 61 | scale 62 | .equatable() 63 | .foregroundColor(.init(.tertiaryLabel)) 64 | } 65 | .frame(width: cellWidth) 66 | } 67 | 68 | static func ==(_ lhs: Self, _ rhs: Self) -> Bool { 69 | lhs.isComplete && rhs.isComplete 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Styling/Protocols/ScaleView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScaleView.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SwiftUI 31 | 32 | public protocol ScaleView: FractionableView, Equatable { 33 | associatedtype ScaleShape: Shape 34 | 35 | var shape: ScaleShape { get } 36 | var width: CGFloat { get } 37 | var height: CGFloat { get } 38 | 39 | var unitMarkWidth: CGFloat { get } 40 | var halfMarkWidth: CGFloat { get } 41 | var fractionMarkWidth: CGFloat { get } 42 | 43 | var unitMarkOffset: CGFloat { get } 44 | var halfMarkOffset: CGFloat { get } 45 | var fractionMarkOffset: CGFloat { get } 46 | } 47 | 48 | extension ScaleView { 49 | var body: some View { 50 | shape 51 | .frame(size: .init(width: width, height: height)) 52 | .fixedSize() 53 | } 54 | 55 | var unitMarkOffset: CGFloat { 0 } 56 | var halfMarkOffset: CGFloat { 0 } 57 | var fractionMarkOffset: CGFloat { 0 } 58 | 59 | static func ==(lhs: Self, rhs: Self) -> Bool { 60 | lhs.width == rhs.width && lhs.height == rhs.height 61 | } 62 | } 63 | 64 | 65 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Styling/Protocols/SlidingRulerStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SlidingRulerStyle.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import SwiftUI 31 | 32 | public protocol SlidingRulerStyle { 33 | associatedtype CellBody: FractionableView 34 | associatedtype CursorBody: View 35 | 36 | var fractions: Int { get } 37 | var cellWidth: CGFloat { get } 38 | var cursorAlignment: VerticalAlignment { get } 39 | var hasMarks: Bool { get } 40 | var hasHalf: Bool { get } 41 | var supportsPointerInteraction: Bool { get } 42 | 43 | func makeCellBody(configuration: SlidingRulerStyleConfiguation) -> CellBody 44 | func makeCursorBody() -> CursorBody 45 | } 46 | 47 | public extension SlidingRulerStyle { 48 | var fractions: Int { CellBody.fractions } 49 | var cellWidth: CGFloat { 120 } 50 | var hasMarks: Bool { true } 51 | var hasHalf: Bool { CellBody.hasHalf } 52 | var supportsPointerInteraction: Bool { true } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Styling/StyleConfiguation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StyleConfiguation.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | 30 | import Foundation 31 | import CoreGraphics 32 | 33 | public struct SlidingRulerStyleConfiguation { 34 | let mark: CGFloat 35 | let bounds: ClosedRange 36 | let step: CGFloat 37 | let formatter: NumberFormatter? 38 | } 39 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/Utils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Utils.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | import SwiftUI 30 | 31 | func NextLoop(_ perform: @escaping () -> ()) { 32 | DispatchQueue.main.async(execute: perform) 33 | } 34 | 35 | func withoutAnimation(_ body: () throws -> Result) rethrows -> Result { 36 | try withAnimation(nil, body) 37 | } 38 | -------------------------------------------------------------------------------- /Sources/SlidingRuler/VSynchedTimer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VSynchedTimer.swift 3 | // 4 | // SlidingRuler 5 | // 6 | // MIT License 7 | // 8 | // Copyright (c) 2020 Pierre Tacchi 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | // 28 | 29 | import UIKit 30 | 31 | struct VSynchedTimer { 32 | typealias Animations = (TimeInterval, TimeInterval) -> () 33 | typealias Completion = (Bool) -> () 34 | 35 | private let timer: SynchedTimer 36 | 37 | init(duration: TimeInterval, animations: @escaping Animations, completion: Completion? = nil) { 38 | self.timer = .init(duration, animations, completion) 39 | } 40 | 41 | func cancel() { timer.cancel() } 42 | } 43 | 44 | 45 | private final class SynchedTimer { 46 | private let duration: TimeInterval 47 | private let animationBlock: VSynchedTimer.Animations 48 | private let completionBlock: VSynchedTimer.Completion? 49 | private weak var displayLink: CADisplayLink? 50 | 51 | private var isRunning: Bool 52 | private let startTimeStamp: TimeInterval 53 | private var lastTimeStamp: TimeInterval 54 | 55 | deinit { 56 | self.displayLink?.invalidate() 57 | } 58 | 59 | init(_ duration: TimeInterval, _ animations: @escaping VSynchedTimer.Animations, _ completion: VSynchedTimer.Completion? = nil) { 60 | self.duration = duration 61 | self.animationBlock = animations 62 | self.completionBlock = completion 63 | 64 | self.isRunning = true 65 | self.startTimeStamp = CACurrentMediaTime() 66 | self.lastTimeStamp = startTimeStamp 67 | self.displayLink = self.createDisplayLink() 68 | } 69 | 70 | func cancel() { 71 | guard isRunning else { return } 72 | 73 | isRunning.toggle() 74 | displayLink?.invalidate() 75 | self.completionBlock?(false) 76 | } 77 | 78 | private func complete() { 79 | guard isRunning else { return } 80 | 81 | isRunning.toggle() 82 | displayLink?.invalidate() 83 | self.completionBlock?(true) 84 | } 85 | 86 | @objc private func displayLinkTick(_ displayLink: CADisplayLink) { 87 | guard isRunning else { return } 88 | 89 | let currentTimeStamp = CACurrentMediaTime() 90 | let progress = currentTimeStamp - startTimeStamp 91 | let elapsed = currentTimeStamp - lastTimeStamp 92 | lastTimeStamp = currentTimeStamp 93 | 94 | if progress < duration { 95 | animationBlock(progress, elapsed) 96 | } else { 97 | complete() 98 | } 99 | } 100 | 101 | private func createDisplayLink() -> CADisplayLink { 102 | let dl = CADisplayLink(target: self, selector: #selector(displayLinkTick(_:))) 103 | dl.add(to: .main, forMode: .common) 104 | 105 | return dl 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import SlidingRulerTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += SlidingRulerTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/SlidingRulerTests/SlidingRulerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SlidingRuler 3 | 4 | final class SlidingRulerTests: XCTestCase { 5 | func testExample() { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | } 10 | 11 | static var allTests = [ 12 | ("testExample", testExample), 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /Tests/SlidingRulerTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(SlidingRulerTests.allTests), 7 | ] 8 | } 9 | #endif 10 | --------------------------------------------------------------------------------