SegmentedGaugeBarLayer.swift 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. //
  2. // SegmentedGaugeBarLayer.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. class SegmentedGaugeBarLayer: CALayer {
  10. var numberOfSegments = 1 {
  11. didSet {
  12. setNeedsDisplay()
  13. }
  14. }
  15. var startColor = UIColor.white.cgColor {
  16. didSet {
  17. setNeedsDisplay()
  18. }
  19. }
  20. var endColor = UIColor.black.cgColor {
  21. didSet {
  22. setNeedsDisplay()
  23. }
  24. }
  25. var gaugeBorderWidth: CGFloat = 0 {
  26. didSet {
  27. setNeedsDisplay()
  28. }
  29. }
  30. var gaugeBorderColor = UIColor.black.cgColor {
  31. didSet {
  32. setNeedsDisplay()
  33. }
  34. }
  35. @NSManaged var progress: CGFloat
  36. override class func needsDisplay(forKey key: String) -> Bool {
  37. return key == #keyPath(SegmentedGaugeBarLayer.progress)
  38. || super.needsDisplay(forKey: key)
  39. }
  40. override func action(forKey event: String) -> CAAction? {
  41. if event == #keyPath(progress) {
  42. let animation = CABasicAnimation(keyPath: event)
  43. animation.fromValue = presentation()?.progress
  44. return animation
  45. } else {
  46. return super.action(forKey: event)
  47. }
  48. }
  49. override func display() {
  50. contents = contentImage()
  51. }
  52. private func contentImage() -> CGImage? {
  53. let renderer = UIGraphicsImageRenderer(size: bounds.size)
  54. let uiImage = renderer.image { context in
  55. drawGauge(in: context.cgContext)
  56. }
  57. return uiImage.cgImage
  58. }
  59. private func drawGauge(in context: CGContext) {
  60. var previousSegmentBorder: (path: UIBezierPath, color: CGColor)?
  61. func finishPreviousSegment() {
  62. if let (borderPath, borderColor) = previousSegmentBorder {
  63. drawBorder(borderPath, color: borderColor, in: context)
  64. }
  65. }
  66. let segmentCounts = 1...numberOfSegments
  67. for countFromRight in segmentCounts {
  68. let isRightmostSegment = countFromRight == segmentCounts.lowerBound
  69. let isLeftmostSegment = countFromRight == segmentCounts.upperBound
  70. let fillFraction = (presentationProgress - CGFloat(numberOfSegments - countFromRight)).clamped(to: 0...1)
  71. let (segmentSize, roundedCorners): (CGSize, UIRectCorner) = {
  72. if isLeftmostSegment {
  73. return (leftmostSegmentSize, .allCorners)
  74. } else {
  75. return (normalSegmentSize, [.topRight, .bottomRight])
  76. }
  77. }()
  78. var originX = bounds.width - gaugeBorderWidth / 2 - CGFloat(countFromRight) * leftmostSegmentSize.width
  79. if !isLeftmostSegment {
  80. originX -= segmentOverlap
  81. }
  82. let segmentOrigin = CGPoint(x: originX, y: bounds.minY + gaugeBorderWidth / 2)
  83. let segmentRect = CGRect(origin: segmentOrigin, size: segmentSize)
  84. if !isRightmostSegment {
  85. drawOverlapInset(for: segmentRect, in: context)
  86. finishPreviousSegment()
  87. }
  88. let borderPath = UIBezierPath(roundedRect: segmentRect, byRoundingCorners: roundedCorners, cornerRadii: CGSize(width: cornerRadius, height: cornerRadius))
  89. let borderColor = fillFraction > 0
  90. ? gaugeBorderColor
  91. : UIColor(cgColor: gaugeBorderColor).withAlphaComponent(0.5).cgColor
  92. clearSegmentArea(tracedBy: borderPath, in: context)
  93. previousSegmentBorder = (path: borderPath, color: borderColor)
  94. guard fillFraction > 0 else {
  95. continue
  96. }
  97. var segmentFillRect = CGRect(origin: segmentOrigin, size: leftmostSegmentSize).insetBy(dx: fillInset, dy: fillInset)
  98. segmentFillRect.size.width *= fillFraction
  99. if !isLeftmostSegment {
  100. segmentFillRect.size.width += segmentOverlap
  101. }
  102. drawFilledGradient(over: segmentFillRect, roundingCorners: roundedCorners, in: context)
  103. }
  104. finishPreviousSegment()
  105. }
  106. private var fillInset: CGFloat {
  107. return 1.5 * gaugeBorderWidth
  108. }
  109. private var segmentOverlap: CGFloat {
  110. return cornerRadius
  111. }
  112. private var presentationProgress: CGFloat {
  113. return presentation()?.progress ?? self.progress
  114. }
  115. private var leftmostSegmentSize: CGSize {
  116. return CGSize(
  117. width: (bounds.width - gaugeBorderWidth) / CGFloat(numberOfSegments),
  118. height: bounds.height - gaugeBorderWidth
  119. )
  120. }
  121. private var normalSegmentSize: CGSize {
  122. return CGSize(
  123. width: leftmostSegmentSize.width + segmentOverlap,
  124. height: leftmostSegmentSize.height
  125. )
  126. }
  127. private func clearSegmentArea(tracedBy path: UIBezierPath, in context: CGContext) {
  128. context.addPath(path.cgPath)
  129. context.setFillColor(backgroundColor ?? UIColor.white.cgColor)
  130. context.fillPath()
  131. }
  132. private func drawFilledGradient(over rect: CGRect, roundingCorners roundedCorners: UIRectCorner, in context: CGContext) {
  133. context.saveGState()
  134. defer { context.restoreGState() }
  135. let path = UIBezierPath(roundedRect: rect, byRoundingCorners: roundedCorners, cornerRadii: CGSize(width: cornerRadius, height: cornerRadius))
  136. context.addPath(path.cgPath)
  137. context.clip()
  138. let pathBounds = path.bounds
  139. let gradient = CGGradient(
  140. colorsSpace: CGColorSpaceCreateDeviceRGB(),
  141. colors: [gradientColor(atX: pathBounds.minX),
  142. gradientColor(atX: pathBounds.maxX)] as CFArray,
  143. locations: [0, 1]
  144. )!
  145. context.drawLinearGradient(
  146. gradient,
  147. start: CGPoint(x: pathBounds.minX, y: pathBounds.midY),
  148. end: CGPoint(x: pathBounds.maxX, y: pathBounds.midY),
  149. options: []
  150. )
  151. }
  152. private func drawOverlapInset(for segmentRect: CGRect, in context: CGContext) {
  153. var overlapInsetRect = segmentRect
  154. overlapInsetRect.size.width += gaugeBorderWidth
  155. let path = UIBezierPath(roundedRect: overlapInsetRect, cornerRadius: cornerRadius)
  156. context.setStrokeColor(backgroundColor ?? UIColor.white.cgColor)
  157. context.setLineWidth(gaugeBorderWidth)
  158. context.setFillColor(backgroundColor ?? UIColor.white.cgColor)
  159. context.addPath(path.cgPath)
  160. context.drawPath(using: .fillStroke)
  161. }
  162. private func drawBorder(_ path: UIBezierPath, color: CGColor, in context: CGContext) {
  163. context.addPath(path.cgPath)
  164. context.setLineWidth(gaugeBorderWidth)
  165. context.setStrokeColor(color)
  166. context.strokePath()
  167. }
  168. private func gradientColor(atX x: CGFloat) -> CGColor {
  169. return UIColor.interpolatingBetween(
  170. UIColor(cgColor: startColor),
  171. UIColor(cgColor: endColor),
  172. biasTowardSecondColor: fractionThrough(x, in: bounds.minX...bounds.maxX)
  173. ).cgColor
  174. }
  175. }