SegmentedGaugeBarView.swift 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. //
  2. // SegmentedGaugeBarView.swift
  3. // LoopKitUI
  4. //
  5. // Created by Michael Pangburn on 3/22/19.
  6. // Copyright © 2019 LoopKit Authors. All rights reserved.
  7. //
  8. import UIKit
  9. public protocol SegmentedGaugeBarViewDelegate: AnyObject {
  10. /// Invoked only when `progress` is updated via gesture.
  11. func segmentedGaugeBarView(_ view: SegmentedGaugeBarView, didUpdateProgressFrom oldValue: Double, to newValue: Double)
  12. }
  13. @IBDesignable
  14. public class SegmentedGaugeBarView: UIView {
  15. @IBInspectable
  16. public var numberOfSegments: Int {
  17. get {
  18. return gaugeLayer.numberOfSegments
  19. }
  20. set {
  21. gaugeLayer.numberOfSegments = newValue
  22. }
  23. }
  24. @IBInspectable
  25. public var startColor: UIColor {
  26. get {
  27. return UIColor(cgColor: gaugeLayer.startColor)
  28. }
  29. set {
  30. gaugeLayer.startColor = newValue.cgColor
  31. }
  32. }
  33. @IBInspectable
  34. public var endColor: UIColor {
  35. get {
  36. return UIColor(cgColor: gaugeLayer.endColor)
  37. }
  38. set {
  39. gaugeLayer.endColor = newValue.cgColor
  40. }
  41. }
  42. @IBInspectable
  43. public var borderWidth: CGFloat {
  44. get {
  45. return gaugeLayer.gaugeBorderWidth
  46. }
  47. set {
  48. gaugeLayer.gaugeBorderWidth = newValue
  49. }
  50. }
  51. @IBInspectable
  52. public var borderColor: UIColor {
  53. get {
  54. return UIColor(cgColor: gaugeLayer.gaugeBorderColor)
  55. }
  56. set {
  57. gaugeLayer.gaugeBorderColor = newValue.cgColor
  58. }
  59. }
  60. @IBInspectable
  61. public var progress: Double {
  62. get {
  63. if displaysThumb {
  64. return Double(fractionThrough(thumb.center.x, in: thumbCenterXRange))
  65. } else {
  66. return visualProgress
  67. }
  68. }
  69. set {
  70. if displaysThumb {
  71. thumb.center.x = interpolatedValue(at: CGFloat(newValue), through: thumbCenterXRange).clamped(to: thumbCenterXRange)
  72. // Push the gauge progress behind the thumb, ensuring the cap rounding is not visible.
  73. let gaugeX = thumb.center.x + 0.25 * thumb.frame.width
  74. visualProgress = newValue == 0 ? 0 : Double(fractionThrough(gaugeX, in: gaugeXRange))
  75. } else {
  76. visualProgress = newValue
  77. }
  78. }
  79. }
  80. private var visualProgress: Double {
  81. get { Double(gaugeLayer.progress) }
  82. set { gaugeLayer.progress = CGFloat(newValue) }
  83. }
  84. public weak var delegate: SegmentedGaugeBarViewDelegate?
  85. override public class var layerClass: AnyClass {
  86. return SegmentedGaugeBarLayer.self
  87. }
  88. private var gaugeLayer: SegmentedGaugeBarLayer {
  89. return layer as! SegmentedGaugeBarLayer
  90. }
  91. override public init(frame: CGRect) {
  92. super.init(frame: frame)
  93. setupPanGestureRecognizer()
  94. }
  95. required init?(coder: NSCoder) {
  96. super.init(coder: coder)
  97. setupPanGestureRecognizer()
  98. }
  99. private lazy var thumb = ThumbView()
  100. public var displaysThumb = false {
  101. didSet {
  102. if displaysThumb {
  103. assert(numberOfSegments == 1, "Thumb only supported for single-segment gauges")
  104. if thumb.superview == nil {
  105. addSubview(thumb)
  106. }
  107. } else {
  108. thumb.removeFromSuperview()
  109. }
  110. }
  111. }
  112. private var panGestureRecognizer: UIPanGestureRecognizer?
  113. private func setupPanGestureRecognizer() {
  114. let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
  115. panGestureRecognizer = pan
  116. addGestureRecognizer(pan)
  117. }
  118. @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) {
  119. switch recognizer.state {
  120. case .began, .changed:
  121. guard recognizer.numberOfTouches == 1 else {
  122. break
  123. }
  124. let location = recognizer.location(ofTouch: 0, in: self)
  125. let gaugeFillFraction = fractionThrough(location.x, in: gaugeXRange)
  126. let oldValue = progress
  127. let newValue = (Double(gaugeFillFraction) * Double(numberOfSegments)).clamped(to: 0...Double(numberOfSegments))
  128. CATransaction.withoutActions {
  129. progress = newValue
  130. }
  131. delegate?.segmentedGaugeBarView(self, didUpdateProgressFrom: oldValue, to: newValue)
  132. case .ended:
  133. uglyWorkaroundToForceRedraw()
  134. default:
  135. break
  136. }
  137. }
  138. private func uglyWorkaroundToForceRedraw() {
  139. // Resolves an issue--most of the time--where dragging _very_ rapidly then releasing
  140. // can cause the gauge layer to fall behind a cycle in rendering.
  141. // - `setNeedsDisplay()` is insufficient.
  142. // - Adding additional executions (delay times) catches the issue with a greater probability,
  143. // with diminishing returns after four executions (by observation).
  144. for (index, delayMS) in [10, 25, 50, 100].enumerated() {
  145. DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(delayMS)) {
  146. CATransaction.withoutActions {
  147. self.progress = index % 2 == 0 ? self.progress.nextUp : self.progress.nextDown
  148. }
  149. }
  150. }
  151. }
  152. override public func layoutSubviews() {
  153. super.layoutSubviews()
  154. layer.cornerRadius = frame.height / 2
  155. updateThumbPosition()
  156. }
  157. private var gaugeXRange: ClosedRange<CGFloat> {
  158. (bounds.minX + 2 * borderWidth)...(bounds.maxX - 2 * borderWidth)
  159. }
  160. private var thumbCenterXRange: ClosedRange<CGFloat> {
  161. let radius = thumb.bounds.width / 2
  162. return (gaugeXRange.lowerBound + radius)...(gaugeXRange.upperBound - radius)
  163. }
  164. private func updateThumbPosition() {
  165. guard displaysThumb else {
  166. return
  167. }
  168. let diameter = bounds.height - 2 * borderWidth
  169. thumb.bounds.size = CGSize(width: diameter, height: diameter)
  170. let xPosition = interpolatedValue(at: CGFloat(progress), through: thumbCenterXRange)
  171. thumb.center = CGPoint(x: xPosition, y: bounds.midY)
  172. }
  173. public func cancelActiveTouches() {
  174. guard panGestureRecognizer?.isEnabled == true else {
  175. return
  176. }
  177. panGestureRecognizer?.isEnabled = false
  178. panGestureRecognizer?.isEnabled = true
  179. }
  180. }
  181. fileprivate extension CATransaction {
  182. static func withoutActions(_ execute: () -> Void) {
  183. begin()
  184. setValue(true, forKey: kCATransactionDisableActions)
  185. execute()
  186. commit()
  187. }
  188. }