├── .gitignore ├── LICENSE ├── Media ├── colors.gif ├── default.gif └── rtl.gif ├── Package.swift ├── README.md ├── Sources └── SliderControl │ ├── Extensions │ ├── ClosedRange+Conversion.swift │ └── NSLayoutConstraint+Multiplier.swift │ ├── SliderControl.swift │ ├── SliderControlView.swift │ └── SliderFeedbackGenerator │ ├── ImpactSliderFeedbackGenerator.swift │ └── SliderFeedbackGenerator.swift └── Tests └── SliderControlExtensionsTests └── ClosedRangeExtensionsTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | ## macOS 2 | .DS_Store 3 | 4 | # Xcode 5 | # 6 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 7 | 8 | ## User settings 9 | xcuserdata/ 10 | 11 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 12 | *.xcscmblueprint 13 | *.xccheckout 14 | 15 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 16 | build/ 17 | DerivedData/ 18 | *.moved-aside 19 | *.pbxuser 20 | !default.pbxuser 21 | *.mode1v3 22 | !default.mode1v3 23 | *.mode2v3 24 | !default.mode2v3 25 | *.perspectivev3 26 | !default.perspectivev3 27 | 28 | ## Obj-C/Swift specific 29 | *.hmap 30 | 31 | ## App packaging 32 | *.ipa 33 | *.dSYM.zip 34 | *.dSYM 35 | 36 | ## Playgrounds 37 | timeline.xctimeline 38 | playground.xcworkspace 39 | 40 | ## Swift Package Manager 41 | Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 42 | Packages/ 43 | Package.pins 44 | Package.resolved 45 | *.xcodeproj 46 | 47 | Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 48 | hence it is not needed unless you have added a package configuration file to your project 49 | .swiftpm 50 | 51 | .build/ 52 | 53 | # CocoaPods 54 | # 55 | # We recommend against adding the Pods directory to your .gitignore. However 56 | # you should judge for yourself, the pros and cons are mentioned at: 57 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 58 | # 59 | # Pods/ 60 | # 61 | # Add this line if you want to avoid checking in source code from the Xcode workspace 62 | # *.xcworkspace 63 | 64 | # Carthage 65 | # 66 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 67 | # Carthage/Checkouts 68 | 69 | Carthage/Build/ 70 | 71 | # Accio dependency management 72 | Dependencies/ 73 | .accio/ 74 | 75 | # fastlane 76 | # 77 | # It is recommended to not store the screenshots in the git repo. 78 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 79 | # For more information about the recommended setup visit: 80 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 81 | 82 | fastlane/report.xml 83 | fastlane/Preview.html 84 | fastlane/screenshots/**/*.png 85 | fastlane/test_output 86 | 87 | # Code Injection 88 | # 89 | # After new code Injection tools there's a generated folder /iOSInjectionProject 90 | # https://github.com/johnno1962/injectionforxcode 91 | 92 | iOSInjectionProject/ 93 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Alexander Chekel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Media/colors.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexChekel1337/SliderControl/6a14984daeb7b41243b50aad9e5943fb9ba68860/Media/colors.gif -------------------------------------------------------------------------------- /Media/default.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexChekel1337/SliderControl/6a14984daeb7b41243b50aad9e5943fb9ba68860/Media/default.gif -------------------------------------------------------------------------------- /Media/rtl.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexChekel1337/SliderControl/6a14984daeb7b41243b50aad9e5943fb9ba68860/Media/rtl.gif -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.8 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "SliderControl", 7 | platforms: [ 8 | .iOS(.v13) 9 | ], 10 | products: [ 11 | .library(name: "SliderControl", targets: ["SliderControl"]) 12 | ], 13 | targets: [ 14 | .target(name: "SliderControl", dependencies: []), 15 | 16 | .testTarget(name: "SliderControlExtensionsTests", dependencies: ["SliderControl"]), 17 | ] 18 | ) 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SliderControl 2 | 3 | ![Platform badge] ![OS badge] ![SPM badge] ![Swift badge] 4 | 5 | SliderControl is a small Swift Package aiming to recreate volume and track sliders found in Apple Music on iOS 16 and later. 6 | 7 | ![Default configuration](./Media/default.gif) 8 | 9 | ## Usage 10 | 11 | To use `SliderControl` in **UIKit** projects, simply create it and add as a subview like any other view or control. It maintains an API similar to built-in `UISlider` with the same properties, like `value` and `isContinuous`, and allows you to track the progress by employing the target-action pattern: 12 | 13 | ```swift 14 | sliderControl.addTarget(self, action: #selector(sliderValueChanged), for: .valueChanged) 15 | ``` 16 | 17 | Alternatively, you can subscribe to `valuePublisher` to receive value updates: 18 | 19 | ```swift 20 | sliderCancellable = sliderControl.valuePublisher.sink { value in 21 | ... 22 | } 23 | ``` 24 | 25 | To use the control in **SwiftUI** project, create its wrapper view called `SliderControlView`: 26 | 27 | ```swift 28 | @State private var sliderValue: Float = 0.5 29 | 30 | var body: some View { 31 | SliderControlView(value: $sliderValue) 32 | } 33 | ``` 34 | 35 | ## Customization 36 | #### Track color 37 | 38 | Slider's track color can be customized by changing the `defaultTrackColor` and the `enlargedTrackColor` properties. By default `enlargedTrackColor` property is set to `nil`, so the slider won't change the track color upon interaction. However, by setting different colors, you can configure the slider to change its track color when user interacts with it. 39 | 40 | UIKit: 41 | ```swift 42 | sliderControl.defaultTrackColor = .quaternarySystemFill 43 | sliderControl.enlargedTrackColor = .secondarySystemFill 44 | ``` 45 | 46 | SwiftUI: 47 | ```swift 48 | SliderControlView(value: $value) 49 | .trackColor(.gray) 50 | // or 51 | .trackColor(.gray, enlarged: .black) 52 | ``` 53 | 54 | #### Progress color 55 | The same customization can also be applied to progress color by changing the `defaultProgressColor` and the `enlargedProgressColor` properties. And again, `enlargedProgressColor` property behaves in the same way as `enlargedTrackColor` and is `nil` by default. 56 | 57 | UIKit: 58 | ```swift 59 | sliderControl.defaultProgressColor = .tertiarySystemFill 60 | sliderControl.enlargedProgressColor = .systemFill 61 | ``` 62 | 63 | SwiftUI: 64 | ```swift 65 | SliderControlView(value: $value) 66 | .progressColor(.blue) 67 | // or 68 | .progressColor(.blue, enlarged: .purple) 69 | ``` 70 | 71 | These customizations allow you to create different appearances for different states of the slider. Here's an example: 72 | ![Different colors](./Media/colors.gif) 73 | 74 | #### Haptic feedback 75 | 76 | By default `SliderControl` and its SwiftUI wrapper `SliderControlView` provide haptic feedback when slider reaches its minimum or maximum values. This behavior can be changed by setting a custom feedback generator, or setting it to `nil` to disable it completely. To make your own feedback generator, create a class that conforms to `SliderFeedbackGenerator` protocol. Here's an example of custom feedback generator: 77 | ```swift 78 | class MyFeedbackGenerator: SliderFeedbackGenerator { 79 | private let feedbackGenerator: UINotificationFeedbackGenerator = .init() 80 | 81 | func preapre() { 82 | feedbackGenerator.prepare() 83 | } 84 | 85 | func generateMinimumValueFeedback() { 86 | feedbackGenerator.notificationOccurred(.warning) 87 | } 88 | 89 | func generateMaximumValueFeedback() { 90 | feedbackGenerator.notificationOccurred(.success) 91 | } 92 | } 93 | ``` 94 | 95 | UIKit: 96 | ```swift 97 | sliderControl.feedbackGenerator = MyFeedbackGenerator() 98 | // or, to disable the haptic feedback 99 | sliderControl.feedbackGenerator = nil 100 | ``` 101 | 102 | SwiftUI: 103 | ```swift 104 | SliderControlView(value: $value) 105 | .feedbackGenerator(MyFeedbackGenerator()) 106 | // or, to disable the haptic feedback 107 | .feedbackGenerator(nil) 108 | ``` 109 | 110 | This control provides its own implementation of feedback generator, so if you want to reset this behavior, use `ImpactFeedbackGenerator()` with `feedbackGenerator` property and SwiftUI modifier. 111 | 112 | #### Value range 113 | 114 | Just like the built-in slider, `SliderControl` also supports value ranges. To specify valid value range just change the `valueRange` property or pass it the initializer of `SliderControlView`. 115 | 116 | UIKit: 117 | ```swift 118 | sliderControl.valueRange = 0...178 119 | ``` 120 | 121 | SwiftUI: 122 | ```swift 123 | SliderControlView(value: $sliderValue, in: 0...178) 124 | ``` 125 | 126 | #### Callbacks 127 | 128 | - `onEditingChanged` - Fired when user starts dragging the slider, and then fired again when users lets go of the slider; 129 | 130 | UIKit: 131 | ```swift 132 | sliderControl.onEditingChanged = { isEditing in 133 | if isEditing { 134 | // dragging began 135 | } else { 136 | // dragging ended 137 | } 138 | } 139 | ``` 140 | 141 | SwiftUI: 142 | ```swift 143 | SliderControlView(value: $sliderValue) { isEditing in 144 | if isEditing { 145 | // dragging began 146 | } else { 147 | // dragging ended 148 | } 149 | } 150 | ``` 151 | 152 | ## RTL Support 153 | 154 | `SliderControl` and its SwiftUI wrapper `SliderControlView` support right-to-left languages. 155 | 156 | ![RTL Example](./Media/rtl.gif) 157 | 158 | [Platform badge]: https://img.shields.io/badge/Platform-iOS-green 159 | [OS badge]: https://img.shields.io/badge/iOS-13.0+-green 160 | [SPM badge]: https://img.shields.io/badge/SPM-Compatible-green 161 | [Swift badge]: https://img.shields.io/badge/Swift-5.8-orange 162 | -------------------------------------------------------------------------------- /Sources/SliderControl/Extensions/ClosedRange+Conversion.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClosedRange+Conversion.swift 3 | // SliderControl 4 | // 5 | // Created by Alexander Chekel on 30.04.2024. 6 | // Copyright © 2024 Alexander Chekel. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension ClosedRange where Bound: BinaryFloatingPoint { 12 | func convert(value: Bound, to newRange: ClosedRange) -> Bound { 13 | (((value - lowerBound) * (newRange.upperBound - newRange.lowerBound)) / (upperBound - lowerBound)) + newRange.lowerBound 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/SliderControl/Extensions/NSLayoutConstraint+Multiplier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSLayoutConstraint+Multiplier.swift 3 | // SliderControl 4 | // 5 | // Created by Alexander Chekel on 09.09.2023. 6 | // Copyright © 2023 Alexander Chekel. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension NSLayoutConstraint { 12 | func constraintWithMultiplier(_ newMultiplier: CGFloat) -> NSLayoutConstraint { 13 | let normalizedMultiplier = max(0.0001, min(1, newMultiplier)) 14 | 15 | let shouldActivate = isActive 16 | if shouldActivate { 17 | NSLayoutConstraint.deactivate([self]) 18 | } 19 | 20 | let updatedConstraint = NSLayoutConstraint( 21 | item: firstItem as Any, 22 | attribute: firstAttribute, 23 | relatedBy: relation, 24 | toItem: secondItem, 25 | attribute: secondAttribute, 26 | multiplier: normalizedMultiplier, 27 | constant: constant 28 | ) 29 | updatedConstraint.priority = priority 30 | updatedConstraint.shouldBeArchived = shouldBeArchived 31 | updatedConstraint.identifier = identifier 32 | 33 | if shouldActivate { 34 | NSLayoutConstraint.activate([updatedConstraint]) 35 | } 36 | 37 | return updatedConstraint 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/SliderControl/SliderControl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SliderControl.swift 3 | // SliderControl 4 | // 5 | // Created by Alexander Chekel on 09.09.2023. 6 | // Copyright © 2023 Alexander Chekel. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import UIKit 11 | 12 | /// Implements a slider control similar to one found in Apple Music on iOS 16. 13 | open class SliderControl: UIControl { 14 | /// Indicates whether changes in the slider's value generate continuous update events. 15 | /// Default value of this property is `true`. 16 | public var isContinuous: Bool = true 17 | /// A layout guide that follows track size changes in different states. 18 | public let trackLayoutGuide: UILayoutGuide = .init() 19 | /// Feedback generator used to provide haptic feedback when slider reaches minimum or maximum value. 20 | /// Set this property to `nil` to disable haptic feedback. Default value of this property is 21 | /// `ImpactFeedbackGenerator`. 22 | public var feedbackGenerator: (any SliderFeedbackGenerator)? = ImpactSliderFeedbackGenerator() 23 | /// A color set to track when user is not interacting with the slider. 24 | /// Default value of this property is `secondarySystemFill`. 25 | open var defaultTrackColor: UIColor = .secondarySystemFill { 26 | didSet { 27 | updateColors() 28 | } 29 | } 30 | /// A color set to progress when user is not interacting with the slider. 31 | /// Default value of this property is `.systemFill`. 32 | open var defaultProgressColor: UIColor = .systemFill { 33 | didSet { 34 | updateColors() 35 | } 36 | } 37 | /// A color set to track when user is interacting with the slider. 38 | /// Assigning `nil` to this property disables color changes in interactive state. 39 | /// Default value of this property is `nil`. 40 | open var enlargedTrackColor: UIColor? { 41 | didSet { 42 | updateColors() 43 | } 44 | } 45 | /// A color set to progress when user is interacting with the slider. 46 | /// Assigning `nil` to this property disables color changes in interactive state. 47 | /// Default value of this property is `nil`. 48 | open var enlargedProgressColor: UIColor? { 49 | didSet { 50 | updateColors() 51 | } 52 | } 53 | 54 | /// Range of slider values. Default value is `0...1`. 55 | public var valueRange: ClosedRange = 0...1 56 | 57 | /// The slider's current value in range set by `valueRange` property. 58 | public var value: Float { 59 | get { 60 | let progress = Float(progressView.bounds.width / trackView.bounds.width) 61 | return Self.internalValueRange.convert(value: progress, to: valueRange) 62 | } 63 | set { 64 | guard !isTracking else { return } 65 | 66 | let clampedValue = max(valueRange.lowerBound, min(valueRange.upperBound, newValue)) 67 | let convertedValue = valueRange.convert(value: clampedValue, to: Self.internalValueRange) 68 | progressConstraint = progressConstraint.constraintWithMultiplier(CGFloat(convertedValue)) 69 | } 70 | } 71 | 72 | /// Callback for editing changed event. It is called with `true` parameter 73 | /// when user starts dragging the slider, and then it is called again with 74 | /// `false` parameter when users lets go of the slider. 75 | public var onEditingChanged: ((Bool) -> Void)? 76 | 77 | /// A publisher that emits progress updates when user interacts with the slider. 78 | /// A Combine alternative to adding action for `UIControl.Event.valueChanged`. 79 | public var valuePublisher: AnyPublisher { 80 | valueSubject.eraseToAnyPublisher() 81 | } 82 | 83 | public override var intrinsicContentSize: CGSize { 84 | return CGSize(width: UIView.noIntrinsicMetric, height: Self.intrinsicHeight) 85 | } 86 | 87 | private static let intrinsicHeight: CGFloat = 24 88 | private static let defaultTrackHeight: CGFloat = 7 89 | private static let enlargedTrackHeight: CGFloat = 12 90 | private static let internalValueRange: ClosedRange = 0...1 91 | 92 | private let trackView: UIView = .init() 93 | private let progressView: UIView = .init() 94 | 95 | private let valueSubject: PassthroughSubject = .init() 96 | 97 | private var heightConstraint: NSLayoutConstraint = .init() 98 | private var progressConstraint: NSLayoutConstraint = .init() 99 | private var hasPreviousSessionChangedProgress: Bool = false 100 | 101 | override public init(frame: CGRect) { 102 | super.init(frame: frame) 103 | 104 | setup() 105 | } 106 | 107 | required public init?(coder: NSCoder) { 108 | super.init(coder: coder) 109 | 110 | setup() 111 | } 112 | 113 | public override func layoutSubviews() { 114 | super.layoutSubviews() 115 | 116 | trackView.layer.cornerRadius = trackView.bounds.height / 2 117 | } 118 | 119 | public override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { 120 | onEditingChanged?(true) 121 | enlargeTrack() 122 | feedbackGenerator?.preapre() 123 | 124 | return true 125 | } 126 | 127 | public override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { 128 | let previousLocation = touch.previousLocation(in: self) 129 | let location = touch.location(in: self) 130 | let translationX = location.x - previousLocation.x 131 | let newWidth = effectiveUserInterfaceLayoutDirection == .leftToRight 132 | ? progressView.bounds.width + translationX 133 | : progressView.bounds.width - translationX 134 | 135 | let progress = progressView.bounds.width / trackView.bounds.width 136 | let newProgress = max(0, min(1, newWidth / trackView.bounds.width)) 137 | 138 | if newProgress != progress { 139 | switch newProgress { 140 | case 0: 141 | feedbackGenerator?.generateMinimumValueFeedback() 142 | case 1: 143 | feedbackGenerator?.generateMaximumValueFeedback() 144 | default: 145 | break 146 | } 147 | } 148 | 149 | progressConstraint = progressConstraint.constraintWithMultiplier(newProgress) 150 | 151 | hasPreviousSessionChangedProgress = true 152 | if isContinuous { 153 | sendActions(for: .valueChanged) 154 | } 155 | 156 | return true 157 | } 158 | 159 | public override func endTracking(_ touch: UITouch?, with event: UIEvent?) { 160 | reduceTrack() 161 | 162 | if hasPreviousSessionChangedProgress { 163 | hasPreviousSessionChangedProgress = false 164 | sendActions(for: .valueChanged) 165 | } 166 | 167 | onEditingChanged?(false) 168 | } 169 | 170 | public override func cancelTracking(with event: UIEvent?) { 171 | reduceTrack() 172 | 173 | if hasPreviousSessionChangedProgress { 174 | hasPreviousSessionChangedProgress = false 175 | sendActions(for: .valueChanged) 176 | } 177 | 178 | onEditingChanged?(false) 179 | } 180 | 181 | private func setup() { 182 | isMultipleTouchEnabled = false 183 | backgroundColor = .clear 184 | 185 | trackView.isUserInteractionEnabled = false 186 | trackView.clipsToBounds = true 187 | trackView.backgroundColor = defaultTrackColor 188 | trackView.translatesAutoresizingMaskIntoConstraints = false 189 | addSubview(trackView) 190 | 191 | addLayoutGuide(trackLayoutGuide) 192 | 193 | progressView.isUserInteractionEnabled = false 194 | progressView.clipsToBounds = true 195 | progressView.backgroundColor = defaultProgressColor 196 | progressView.translatesAutoresizingMaskIntoConstraints = false 197 | trackView.addSubview(progressView) 198 | 199 | heightConstraint = trackView.heightAnchor.constraint(equalToConstant: Self.defaultTrackHeight) 200 | progressConstraint = progressView.widthAnchor.constraint(equalTo: trackView.widthAnchor, multiplier: 0.5) 201 | 202 | NSLayoutConstraint.activate([ 203 | heightConstraint, 204 | trackView.centerYAnchor.constraint(equalTo: centerYAnchor), 205 | trackView.leadingAnchor.constraint(equalTo: leadingAnchor), 206 | trackView.trailingAnchor.constraint(equalTo: trailingAnchor), 207 | 208 | trackLayoutGuide.topAnchor.constraint(equalTo: trackView.topAnchor), 209 | trackLayoutGuide.leadingAnchor.constraint(equalTo: trackView.leadingAnchor), 210 | trackView.bottomAnchor.constraint(equalTo: trackLayoutGuide.bottomAnchor), 211 | trackView.trailingAnchor.constraint(equalTo: trackLayoutGuide.trailingAnchor), 212 | 213 | progressConstraint, 214 | progressView.topAnchor.constraint(equalTo: trackView.topAnchor), 215 | progressView.leadingAnchor.constraint(equalTo: trackView.leadingAnchor), 216 | progressView.bottomAnchor.constraint(equalTo: trackView.bottomAnchor) 217 | ]) 218 | 219 | addTarget(self, action: #selector(broadcastValueChange), for: .valueChanged) 220 | } 221 | 222 | private func enlargeTrack() { 223 | heightConstraint.constant = Self.enlargedTrackHeight 224 | setNeedsLayout() 225 | 226 | UIView.animate( 227 | withDuration: 0.25, 228 | delay: 0, 229 | usingSpringWithDamping: 1, 230 | initialSpringVelocity: 0, 231 | options: [.curveEaseIn, .allowAnimatedContent, .allowUserInteraction] 232 | ) { [unowned self] in 233 | enlargedTrackColor.map { trackView.backgroundColor = $0 } 234 | enlargedProgressColor.map { progressView.backgroundColor = $0 } 235 | 236 | layoutIfNeeded() 237 | } 238 | } 239 | 240 | private func reduceTrack() { 241 | heightConstraint.constant = Self.defaultTrackHeight 242 | setNeedsLayout() 243 | 244 | UIView.animate( 245 | withDuration: 0.5, 246 | delay: 0, 247 | usingSpringWithDamping: 1, 248 | initialSpringVelocity: 1, 249 | options: [.curveEaseOut, .allowAnimatedContent, .allowUserInteraction] 250 | ) { [unowned self] in 251 | trackView.backgroundColor = defaultTrackColor 252 | progressView.backgroundColor = defaultProgressColor 253 | 254 | layoutIfNeeded() 255 | } 256 | } 257 | 258 | private func updateColors() { 259 | if heightConstraint.constant == Self.defaultTrackHeight { 260 | trackView.backgroundColor = defaultTrackColor 261 | progressView.backgroundColor = defaultProgressColor 262 | } else { 263 | enlargedTrackColor.map { trackView.backgroundColor = $0 } 264 | enlargedProgressColor.map { progressView.backgroundColor = $0 } 265 | } 266 | } 267 | 268 | @objc private func broadcastValueChange() { 269 | valueSubject.send(value) 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /Sources/SliderControl/SliderControlView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SliderControlView.swift 3 | // SliderControl 4 | // 5 | // Created by Alexander Chekel on 21.04.2024. 6 | // Copyright © 2024 Alexander Chekel. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | public struct SliderControlView: UIViewRepresentable { 12 | public typealias UIViewType = SliderControl 13 | 14 | public class Coordinator: NSObject { 15 | @Binding private var value: U 16 | 17 | init(value: Binding) { 18 | _value = value 19 | } 20 | 21 | func setup(with control: SliderControl) { 22 | control.addTarget(self, action: #selector(handleValueChange(control:)), for: .valueChanged) 23 | } 24 | 25 | @objc func handleValueChange(control: SliderControl) { 26 | value = U(control.value) 27 | } 28 | } 29 | 30 | @Binding var value: T 31 | 32 | var isContinuous: Bool 33 | var feedbackGenerator: (any SliderFeedbackGenerator)? 34 | var defaultTrackColor: UIColor 35 | var defaultProgressColor: UIColor 36 | var enlargedTrackColor: UIColor? 37 | var enlargedProgressColor: UIColor? 38 | var onEditingChanged: ((Bool) -> Void)? 39 | var valueRange: ClosedRange 40 | 41 | /// Creates a slider similar to the track slider found in Apple Music on iOS 16. 42 | /// - parameters: 43 | /// - value: Selected slider value binding. 44 | /// - valueRange: A range of valid slider values. 45 | /// - onEditingChanged: A callback for when editing begins or ends. 46 | public init( 47 | value: Binding, 48 | in valueRange: ClosedRange = 0...1, 49 | onEditingChanged: ((Bool) -> Void)? = nil 50 | ) { 51 | self._value = value 52 | self.isContinuous = true 53 | self.feedbackGenerator = ImpactSliderFeedbackGenerator() 54 | self.defaultTrackColor = .secondarySystemFill 55 | self.defaultProgressColor = .systemFill 56 | self.enlargedTrackColor = nil 57 | self.enlargedProgressColor = nil 58 | self.onEditingChanged = onEditingChanged 59 | self.valueRange = valueRange 60 | } 61 | 62 | public func makeUIView(context: Context) -> SliderControl { 63 | let coordinator = context.coordinator 64 | let control = SliderControl() 65 | control.isContinuous = isContinuous 66 | control.feedbackGenerator = feedbackGenerator 67 | control.defaultTrackColor = defaultTrackColor 68 | control.defaultProgressColor = defaultProgressColor 69 | control.enlargedTrackColor = enlargedTrackColor 70 | control.enlargedProgressColor = enlargedProgressColor 71 | control.onEditingChanged = onEditingChanged 72 | control.valueRange = Float(valueRange.lowerBound)...Float(valueRange.upperBound) 73 | control.setContentHuggingPriority(.defaultHigh, for: .vertical) 74 | coordinator.setup(with: control) 75 | return control 76 | } 77 | 78 | public func makeCoordinator() -> Coordinator { 79 | Coordinator(value: $value) 80 | } 81 | 82 | public func updateUIView(_ uiView: SliderControl, context: Context) { 83 | let floatValue = Float(value) 84 | 85 | if floatValue != uiView.value { 86 | // When the UIView updates the binding, SwiftUI calls the 87 | // updateUIView(_:context:) method again, which may cause 88 | // a CPU usage spike, or, as in case of this view, 89 | // a drawing issue. 90 | uiView.value = floatValue 91 | } 92 | 93 | uiView.isContinuous = isContinuous 94 | uiView.feedbackGenerator = feedbackGenerator 95 | uiView.defaultTrackColor = defaultTrackColor 96 | uiView.defaultProgressColor = defaultProgressColor 97 | uiView.enlargedTrackColor = enlargedTrackColor 98 | uiView.enlargedProgressColor = enlargedProgressColor 99 | } 100 | } 101 | 102 | public extension SliderControlView { 103 | /// Sets track colors. 104 | /// - parameters: 105 | /// - defaultTrackColorName: A name of asset color for the track. 106 | /// - enlargedTrackColorName: A name of asset color for the track in interaction state. 107 | @available(iOS, deprecated: 14, message: "Use .trackColor(_:enlargedTrackColor:) modifier instead.") 108 | func trackColor( 109 | named defaultTrackColorName: String, 110 | enlargedTrackColorNamed enlargedTrackColorName: String? = nil 111 | ) -> SliderControlView { 112 | var view = self 113 | if let defaultTrackColor = UIColor(named: defaultTrackColorName) { 114 | view.defaultTrackColor = defaultTrackColor 115 | } 116 | view.enlargedTrackColor = enlargedTrackColorName.flatMap(UIColor.init(named:)) 117 | return view 118 | } 119 | 120 | /// Sets track colors. 121 | /// - parameters: 122 | /// - defaultTrackColor: A color for the track. 123 | /// - enlargedTrackColor: A color for the track in interaction state. 124 | @available(iOS 14, *) 125 | func trackColor(_ defaultTrackColor: Color, enlarged enlargedTrackColor: Color? = nil) -> SliderControlView { 126 | var view = self 127 | view.defaultTrackColor = UIColor(defaultTrackColor) 128 | view.enlargedTrackColor = enlargedTrackColor.map(UIColor.init) 129 | return view 130 | } 131 | 132 | /// Sets progress fill colors. 133 | /// - parameters: 134 | /// - defaultProgressColorName: A name of asset color for the progress fill. 135 | /// - enlargedProgressColorName: A name of asset color for the progress fill in interaction state. 136 | @available(iOS, deprecated: 14, message: "Use .progressColor(_:enlargedProgressColor:) modifier instead.") 137 | func progressColor( 138 | named defaultProgressColorName: String, 139 | enlargedProgressColorNamed enlargedProgressColorName: String? = nil 140 | ) -> SliderControlView { 141 | var view = self 142 | if let defaultProgressColor = UIColor(named: defaultProgressColorName) { 143 | view.defaultProgressColor = defaultProgressColor 144 | } 145 | view.enlargedProgressColor = enlargedProgressColorName.flatMap(UIColor.init(named:)) 146 | return view 147 | } 148 | 149 | /// Sets progress fill colors. 150 | /// - parameters: 151 | /// - defaultProgressColor: A color for the progress fill. 152 | /// - enlargedProgressColor: A color for the progress fill in interaction state. 153 | @available(iOS 14, *) 154 | func progressColor(_ defaultProgressColor: Color, enlarged enlargedProgressColor: Color? = nil) -> SliderControlView { 155 | var view = self 156 | view.defaultProgressColor = UIColor(defaultProgressColor) 157 | view.enlargedProgressColor = enlargedProgressColor.map(UIColor.init) 158 | return view 159 | } 160 | 161 | /// Specifies whether slider should continuously update the value binding. 162 | /// If set to `true`, the slider will update the value binding as user drags across. 163 | /// If set to `false`, the value binding will be updated when user lets go of the slider. 164 | func continuouslyUpdatesValue(_ isContinuous: Bool) -> SliderControlView { 165 | var view = self 166 | view.isContinuous = isContinuous 167 | return view 168 | } 169 | 170 | /// Sets slider's feedback generator. Pass `nil` to disable haptic feedback. 171 | func feedbackGenerator(_ generator: (any SliderFeedbackGenerator)?) -> SliderControlView { 172 | var view = self 173 | view.feedbackGenerator = generator 174 | return view 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /Sources/SliderControl/SliderFeedbackGenerator/ImpactSliderFeedbackGenerator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImpactSliderFeedbackGenerator.swift 3 | // SliderControl 4 | // 5 | // Created by Alexander Chekel on 23.04.2024. 6 | // Copyright © 2024 Alexander Chekel. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | /// Default implementation of `SliderFeedbackGenerator` that uses `UIImpactFeedbackGenerator`. 12 | public class ImpactSliderFeedbackGenerator: SliderFeedbackGenerator { 13 | private let feedbackGenerator: UIImpactFeedbackGenerator = .init(style: .light) 14 | 15 | public init() {} 16 | 17 | public func preapre() { 18 | feedbackGenerator.prepare() 19 | } 20 | 21 | public func generateMinimumValueFeedback() { 22 | feedbackGenerator.impactOccurred(intensity: 0.75) 23 | } 24 | 25 | public func generateMaximumValueFeedback() { 26 | feedbackGenerator.impactOccurred(intensity: 1) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/SliderControl/SliderFeedbackGenerator/SliderFeedbackGenerator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SliderFeedbackGenerator.swift 3 | // SliderControl 4 | // 5 | // Created by Alexander Chekel on 23.04.2024. 6 | // Copyright © 2024 Alexander Chekel. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | /// A protocol for haptic feedback generator implementations. 12 | public protocol SliderFeedbackGenerator { 13 | /// This method is called when user starts interacting with the slider 14 | /// in anticipation of it reaching minimum or maximum value. If your 15 | /// implementation uses a subclass of `UIFeedbackGenerator` to generate 16 | /// haptic feedback, use this method to prepare the generator for possible 17 | /// activations. 18 | func preapre() 19 | /// This method is called when slider reaches its minimum value. Use this 20 | /// method to generate haptic feedback that gives user a feeling of lower bound. 21 | func generateMinimumValueFeedback() 22 | /// This method is called when slider reaches its maximum value. Use this 23 | /// method to generate haptic feedback that gives user a feeling of upper bound. 24 | func generateMaximumValueFeedback() 25 | } 26 | -------------------------------------------------------------------------------- /Tests/SliderControlExtensionsTests/ClosedRangeExtensionsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClosedRangeExtensionsTests.swift 3 | // SliderControl 4 | // 5 | // Created by Alexander Chekel on 30.04.2024. 6 | // Copyright © 2024 Alexander Chekel. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | @testable import SliderControl 12 | 13 | class ClosedRangeExtensionsTests: XCTestCase { 14 | func testPositiveDoubleValue() { 15 | let value: Double = 0.5 16 | let range: ClosedRange = 0...1 17 | let targetRange: ClosedRange = 100...1100 18 | let convertedValue = range.convert(value: value, to: targetRange) 19 | XCTAssertEqual(convertedValue, 600) 20 | } 21 | 22 | func testNegativeDoubleValue() { 23 | let value: Double = 0.5 24 | let range: ClosedRange = 0...1 25 | let targetRange: ClosedRange = -1100 ... -100 26 | let convertedValue = range.convert(value: value, to: targetRange) 27 | XCTAssertEqual(convertedValue, -600) 28 | } 29 | 30 | func testPositiveFloatValue() { 31 | let value: Float = 0.5 32 | let range: ClosedRange = 0...1 33 | let targetRange: ClosedRange = 100...1100 34 | let convertedValue = range.convert(value: value, to: targetRange) 35 | XCTAssertEqual(convertedValue, 600) 36 | } 37 | 38 | func testNegativeFloatValue() { 39 | let value: Float = 0.5 40 | let range: ClosedRange = 0...1 41 | let targetRange: ClosedRange = -1100 ... -100 42 | let convertedValue = range.convert(value: value, to: targetRange) 43 | XCTAssertEqual(convertedValue, -600) 44 | } 45 | } 46 | --------------------------------------------------------------------------------