MultiSlider.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. //
  2. // MultiSlider.swift
  3. // UISlider clone with multiple thumbs and values, and optional snap intervals.
  4. //
  5. // Created by Yonat Sharon on 14.11.2016.
  6. // Copyright © 2016 Yonat Sharon. All rights reserved.
  7. //
  8. import SweeterSwift
  9. import UIKit
  10. @IBDesignable
  11. open class MultiSlider: UIControl {
  12. @objc open var value: [CGFloat] = [] {
  13. didSet {
  14. if isSettingValue { return }
  15. value.sort()
  16. adjustThumbCountToValueCount()
  17. adjustValuesToStepAndLimits()
  18. updateAllValueLabels()
  19. accessibilityValue = value.description
  20. }
  21. }
  22. @IBInspectable open dynamic var minimumValue: CGFloat = 0 { didSet { adjustValuesToStepAndLimits() } }
  23. @IBInspectable open dynamic var maximumValue: CGFloat = 1 { didSet { adjustValuesToStepAndLimits() } }
  24. @IBInspectable open dynamic var isContinuous: Bool = true
  25. // MARK: - Multiple Thumbs
  26. @objc public internal(set) var draggedThumbIndex: Int = -1
  27. @IBInspectable open dynamic var thumbCount: Int {
  28. get {
  29. return thumbViews.count
  30. }
  31. set {
  32. guard newValue > 0 else { return }
  33. updateValueCount(newValue)
  34. adjustThumbCountToValueCount()
  35. }
  36. }
  37. /// make specific thumbs fixed (and grayed)
  38. @objc open var disabledThumbIndices: Set<Int> = [] {
  39. didSet {
  40. for i in 0 ..< thumbCount {
  41. thumbViews[i].blur(disabledThumbIndices.contains(i))
  42. }
  43. }
  44. }
  45. /// minimal distance to keep between thumbs (half a thumb by default)
  46. @IBInspectable public dynamic var distanceBetweenThumbs: CGFloat = -1
  47. @IBInspectable public dynamic var keepsDistanceBetweenThumbs: Bool {
  48. get { return distanceBetweenThumbs != 0 }
  49. set {
  50. if keepsDistanceBetweenThumbs != newValue {
  51. distanceBetweenThumbs = newValue ? -1 : 0
  52. }
  53. }
  54. }
  55. // MARK: - Snap to Discrete Values
  56. /// snap thumbs to specific values, evenly spaced. (default = 0: allow any value)
  57. @IBInspectable open dynamic var snapStepSize: CGFloat {
  58. get {
  59. switch snap {
  60. case let .stepSize(stepSize): return stepSize
  61. default: return 0
  62. }
  63. }
  64. set {
  65. snap = newValue.isNormal ? .stepSize(newValue) : .never
  66. }
  67. }
  68. /// snap thumbs to specific values. changes `minimumValue` and `maximumValue`. (default = []: allow any value)
  69. @objc open dynamic var snapValues: [CGFloat] {
  70. get {
  71. switch snap {
  72. case .never:
  73. return []
  74. case let .stepSize(stepSize):
  75. return Array(stride(from: minimumValue, to: maximumValue, by: stepSize)) + [maximumValue]
  76. case let .values(values):
  77. return values
  78. }
  79. }
  80. set {
  81. snap = .values(newValue)
  82. }
  83. }
  84. /// image to show at each snap value
  85. @IBInspectable open dynamic var snapImage: UIImage? {
  86. didSet {
  87. setupTrackLayoutMargins()
  88. guard snapValues.count > 2 else { return }
  89. if let snapImage = snapImage {
  90. if nil != oldValue {
  91. snapViews.forEach { $0.image = snapImage }
  92. } else {
  93. snapValues.forEach { addSnapView(at: $0) }
  94. }
  95. } else {
  96. snapViews.removeAllViews()
  97. }
  98. }
  99. }
  100. /// Snapping behavior: How should the slider snap thumbs to discrete values
  101. public enum Snap: Equatable {
  102. /// No snapping, slider continuously.
  103. case never
  104. /// Snap to values separated by a constant step, starting from `minimumValue`. Equivalent to setting `snapStepSize`.
  105. case stepSize(CGFloat)
  106. /// Snap to the specified values. Equivalent to setting `snapValues`.
  107. case values([CGFloat])
  108. }
  109. /// Snapping behavior: How should the slider snap thumbs to discrete values
  110. open dynamic var snap: Snap = .never {
  111. didSet {
  112. if case let .values(values) = snap {
  113. if values.isEmpty {
  114. snap = .never
  115. } else {
  116. var sorted = values.sorted()
  117. if minimumValue > values.first! {
  118. minimumValue = sorted.first!
  119. } else if minimumValue < sorted.first! {
  120. sorted.insert(minimumValue, at: 0)
  121. }
  122. if maximumValue < values.last! {
  123. maximumValue = sorted.last!
  124. } else if maximumValue > sorted.last! {
  125. sorted.append(maximumValue)
  126. }
  127. snap = .values(sorted)
  128. }
  129. }
  130. adjustValuesToStepAndLimits()
  131. }
  132. }
  133. /// generate haptic feedback when hitting snap steps
  134. @IBInspectable open dynamic var isHapticSnap: Bool {
  135. get {
  136. selectionFeedbackGenerator != nil
  137. }
  138. set {
  139. selectionFeedbackGenerator = newValue ? UISelectionFeedbackGenerator() : nil
  140. selectionFeedbackGenerator?.prepare()
  141. }
  142. }
  143. // MARK: - Value Labels
  144. /// value label shows difference from previous thumb value (true) or absolute value (false = default)
  145. @IBInspectable open dynamic var isValueLabelRelative: Bool = false {
  146. didSet {
  147. updateAllValueLabels()
  148. }
  149. }
  150. /// show value labels next to thumbs. (default: show no label)
  151. @objc open dynamic var valueLabelPosition: NSLayoutConstraint.Attribute = .notAnAttribute {
  152. didSet {
  153. updateValueLabelPosition()
  154. }
  155. }
  156. /// show every other value label opposite of the value label position.
  157. /// e.g., If you set `valueLabelPosition` to `.top`, the second value label position would be `.bottom`.
  158. @IBInspectable open dynamic var valueLabelAlternatePosition: Bool = false {
  159. didSet {
  160. updateValueLabelPosition()
  161. }
  162. }
  163. @IBInspectable open dynamic var valueLabelColor: UIColor? {
  164. didSet {
  165. valueLabels.forEach { $0.textColor = valueLabelColor }
  166. }
  167. }
  168. open dynamic var valueLabelFont: UIFont? {
  169. didSet {
  170. valueLabels.forEach { $0.font = valueLabelFont }
  171. }
  172. }
  173. @objc open dynamic var valueLabelFormatter: NumberFormatter = {
  174. let formatter = NumberFormatter()
  175. formatter.maximumFractionDigits = 2
  176. formatter.minimumIntegerDigits = 1
  177. formatter.roundingMode = .halfEven
  178. return formatter
  179. }() {
  180. didSet {
  181. updateAllValueLabels()
  182. if #available(iOS 11.0, *) {
  183. oldValue.removeObserverForAllProperties(observer: self)
  184. valueLabelFormatter.addObserverForAllProperties(observer: self)
  185. }
  186. }
  187. }
  188. /// Return value label text for a thumb index and value. If `nil`, then `valueLabelFormatter` will be used instead.
  189. @objc open dynamic var valueLabelTextForThumb: ((Int, CGFloat) -> String?)? {
  190. didSet {
  191. for i in valueLabels.indices {
  192. updateValueLabel(i)
  193. }
  194. }
  195. }
  196. // MARK: - Appearance
  197. @IBInspectable open dynamic var isVertical: Bool {
  198. get { return orientation == .vertical }
  199. set { orientation = newValue ? .vertical : .horizontal }
  200. }
  201. @objc open dynamic var orientation: NSLayoutConstraint.Axis = .vertical {
  202. didSet {
  203. let oldConstraintAttribute: NSLayoutConstraint.Attribute = oldValue == .vertical ? .width : .height
  204. removeFirstConstraint(where: { $0.firstAttribute == oldConstraintAttribute && $0.firstItem === self && $0.secondItem == nil })
  205. setupOrientation()
  206. invalidateIntrinsicContentSize()
  207. repositionThumbViews()
  208. }
  209. }
  210. /// track color before first thumb and after last thumb. `nil` means to use the tintColor, like the rest of the track.
  211. @IBInspectable open dynamic var outerTrackColor: UIColor? {
  212. didSet {
  213. updateOuterTrackViews()
  214. }
  215. }
  216. @IBInspectable public dynamic var thumbTintColor: UIColor? {
  217. didSet {
  218. thumbViews.forEach { $0.applyTint(color: thumbTintColor) }
  219. }
  220. }
  221. @IBInspectable open dynamic var thumbImage: UIImage? {
  222. didSet {
  223. thumbViews.forEach { $0.image = thumbImage }
  224. setupTrackLayoutMargins()
  225. invalidateIntrinsicContentSize()
  226. }
  227. }
  228. /// Respond to dragging beyond thumb image (useful if the image is small)
  229. @IBInspectable open dynamic var thumbTouchExpansionRadius: CGFloat = 0
  230. @IBInspectable public dynamic var showsThumbImageShadow: Bool = true {
  231. didSet {
  232. updateThumbViewShadowVisibility()
  233. }
  234. }
  235. @IBInspectable open dynamic var minimumImage: UIImage? {
  236. get {
  237. return minimumView.image
  238. }
  239. set {
  240. minimumView.image = newValue
  241. minimumView.isHidden = newValue == nil
  242. layoutTrackEdge(
  243. toView: minimumView,
  244. edge: .bottom(in: orientation),
  245. superviewEdge: orientation == .vertical ? .bottomMargin : .leftMargin
  246. )
  247. }
  248. }
  249. @IBInspectable open dynamic var maximumImage: UIImage? {
  250. get {
  251. return maximumView.image
  252. }
  253. set {
  254. maximumView.image = newValue
  255. maximumView.isHidden = newValue == nil
  256. layoutTrackEdge(
  257. toView: maximumView,
  258. edge: .top(in: orientation),
  259. superviewEdge: orientation == .vertical ? .topMargin : .rightMargin
  260. )
  261. }
  262. }
  263. @IBInspectable open dynamic var trackWidth: CGFloat = 2 {
  264. didSet {
  265. let widthAttribute: NSLayoutConstraint.Attribute = orientation == .vertical ? .width : .height
  266. trackView.removeFirstConstraint { $0.firstAttribute == widthAttribute }
  267. trackView.constrain(widthAttribute, to: trackWidth)
  268. updateTrackViewCornerRounding()
  269. }
  270. }
  271. @IBInspectable public dynamic var hasRoundTrackEnds: Bool = true {
  272. didSet {
  273. updateTrackViewCornerRounding()
  274. }
  275. }
  276. /// when thumb value is minimum or maximum, align it's center with the track end instead of its edge.
  277. @IBInspectable public dynamic var centerThumbOnTrackEnd: Bool = false {
  278. didSet {
  279. setupTrackLayoutMargins()
  280. }
  281. }
  282. // MARK: - Subviews
  283. @objc open var thumbViews: [UIImageView] = []
  284. @objc open var valueLabels: [UITextField] = [] // UILabels are a pain to layout, text fields look nice as-is.
  285. @objc open var trackView = UIView()
  286. @objc open var snapViews: [UIImageView] = []
  287. @objc open var outerTrackViews: [UIView] = []
  288. @objc open var minimumView = UIImageView()
  289. @objc open var maximumView = UIImageView()
  290. // MARK: - Internals
  291. let slideView = UIView()
  292. let panGestureView = UIView()
  293. let margin: CGFloat = 32
  294. var isSettingValue = false
  295. lazy var defaultThumbImage: UIImage? = .circle()
  296. var selectionFeedbackGenerator: UISelectionFeedbackGenerator?
  297. // MARK: - Overrides
  298. override open func tintColorDidChange() {
  299. let thumbTint = thumbViews.map { $0.tintColor } // different thumbs may have different tints
  300. super.tintColorDidChange()
  301. let actualColor = actualTintColor
  302. trackView.backgroundColor = actualColor
  303. minimumView.tintColor = actualColor
  304. maximumView.tintColor = actualColor
  305. for (thumbView, tint) in zip(thumbViews, thumbTint) {
  306. thumbView.tintColor = tint
  307. }
  308. }
  309. override open var intrinsicContentSize: CGSize {
  310. let thumbSize = (thumbImage ?? defaultThumbImage)?.size ?? CGSize(width: margin, height: margin)
  311. switch orientation {
  312. case .vertical:
  313. return CGSize(width: thumbSize.width + margin, height: UIView.noIntrinsicMetric)
  314. default:
  315. return CGSize(width: UIView.noIntrinsicMetric, height: thumbSize.height + margin)
  316. }
  317. }
  318. override open func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
  319. if isHidden || alpha == 0 { return nil }
  320. if clipsToBounds { return super.hitTest(point, with: event) }
  321. return panGestureView.hitTest(panGestureView.convert(point, from: self), with: event)
  322. }
  323. // swiftlint:disable:next block_based_kvo
  324. override open func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
  325. if object as? NumberFormatter === valueLabelFormatter {
  326. updateAllValueLabels()
  327. }
  328. }
  329. override public init(frame: CGRect) {
  330. super.init(frame: frame)
  331. setup()
  332. }
  333. public required init?(coder: NSCoder) {
  334. super.init(coder: coder)
  335. setup()
  336. }
  337. deinit {
  338. if #available(iOS 11.0, *) {
  339. valueLabelFormatter.removeObserverForAllProperties(observer: self)
  340. }
  341. }
  342. override open func prepareForInterfaceBuilder() {
  343. super.prepareForInterfaceBuilder()
  344. // make visual editing easier
  345. layer.borderWidth = 0.5
  346. layer.borderColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor
  347. // evenly distribute thumbs
  348. let oldThumbCount = thumbCount
  349. thumbCount = 0
  350. thumbCount = oldThumbCount
  351. }
  352. }