CWProgressView.swift 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  1. import Foundation
  2. import UIKit
  3. @objc public enum CWCircularProgressGlowMode: Int {
  4. case forward, reverse, constant, noGlow
  5. }
  6. @IBDesignable
  7. @objcMembers
  8. public class CWProgressView: UIView, CAAnimationDelegate {
  9. private var progressLayer: KDCircularProgressViewLayer {
  10. get {
  11. return layer as! KDCircularProgressViewLayer
  12. }
  13. }
  14. private var radius: CGFloat = 0.0 {
  15. didSet {
  16. progressLayer.radius = radius
  17. }
  18. }
  19. public var progress: Double {
  20. get { return angle.mod(between: 0.0, and: 360.0, byIncrementing: 360.0) / 360.0 }
  21. set { angle = newValue.clamp(lowerBound: 0.0, upperBound: 1.0) * 360.0 }
  22. }
  23. @IBInspectable public var angle: Double = 0.0 {
  24. didSet {
  25. pauseIfAnimating()
  26. progressLayer.angle = angle
  27. }
  28. }
  29. @IBInspectable public var startAngle: Double = 0.0 {
  30. didSet {
  31. startAngle = startAngle.mod(between: 0.0, and: 360.0, byIncrementing: 360.0)
  32. progressLayer.startAngle = startAngle
  33. progressLayer.setNeedsDisplay()
  34. }
  35. }
  36. @IBInspectable public var clockwise: Bool = true {
  37. didSet {
  38. progressLayer.clockwise = clockwise
  39. progressLayer.setNeedsDisplay()
  40. }
  41. }
  42. @IBInspectable public var roundedCorners: Bool = true {
  43. didSet {
  44. progressLayer.roundedCorners = roundedCorners
  45. }
  46. }
  47. @IBInspectable public var lerpColorMode: Bool = false {
  48. didSet {
  49. progressLayer.lerpColorMode = lerpColorMode
  50. }
  51. }
  52. @IBInspectable public var gradientRotateSpeed: CGFloat = 0.0 {
  53. didSet {
  54. progressLayer.gradientRotateSpeed = gradientRotateSpeed
  55. }
  56. }
  57. @IBInspectable public var glowAmount: CGFloat = 1.0 {
  58. didSet {
  59. glowAmount = glowAmount.clamp(lowerBound: 0.0, upperBound: 1.0)
  60. progressLayer.glowAmount = glowAmount
  61. }
  62. }
  63. public var glowMode: CWCircularProgressGlowMode = .forward {
  64. didSet {
  65. progressLayer.glowMode = glowMode
  66. }
  67. }
  68. @IBInspectable public var progressThickness: CGFloat = 0.4 {
  69. didSet {
  70. progressThickness = progressThickness.clamp(lowerBound: 0.0, upperBound: 1.0)
  71. progressLayer.progressThickness = progressThickness / 2.0
  72. }
  73. }
  74. @IBInspectable public var trackThickness: CGFloat = 0.5 {//Between 0 and 1
  75. didSet {
  76. trackThickness = trackThickness.clamp(lowerBound: 0.0, upperBound: 1.0)
  77. progressLayer.trackThickness = trackThickness / 2.0
  78. }
  79. }
  80. @IBInspectable public var trackColor: UIColor = .black {
  81. didSet {
  82. progressLayer.trackColor = trackColor
  83. progressLayer.setNeedsDisplay()
  84. }
  85. }
  86. @IBInspectable public var progressInsideFillColor: UIColor? = nil {
  87. didSet {
  88. progressLayer.progressInsideFillColor = progressInsideFillColor ?? .clear
  89. }
  90. }
  91. public var progressColors: [UIColor] {
  92. get { return progressLayer.colorsArray }
  93. set { set(colors: newValue) }
  94. }
  95. //These are used only from the Interface-Builder. Changing these from code will have no effect.
  96. //Also IB colors are limited to 3, whereas programatically we can have an arbitrary number of them.
  97. @objc @IBInspectable private var IBColor1: UIColor?
  98. @objc @IBInspectable private var IBColor2: UIColor?
  99. @objc @IBInspectable private var IBColor3: UIColor?
  100. private var animationCompletionBlock: ((Bool) -> Void)?
  101. override public init(frame: CGRect) {
  102. super.init(frame: frame)
  103. setInitialValues()
  104. refreshValues()
  105. }
  106. convenience public init(frame:CGRect, colors: UIColor...) {
  107. self.init(frame: frame)
  108. set(colors: colors)
  109. }
  110. required public init?(coder aDecoder: NSCoder) {
  111. super.init(coder: aDecoder)
  112. translatesAutoresizingMaskIntoConstraints = false
  113. setInitialValues()
  114. refreshValues()
  115. }
  116. public override func awakeFromNib() {
  117. checkAndSetIBColors()
  118. }
  119. override public class var layerClass: AnyClass {
  120. return KDCircularProgressViewLayer.self
  121. }
  122. public override func layoutSubviews() {
  123. super.layoutSubviews()
  124. radius = (frame.size.width / 2.0) * 0.8
  125. }
  126. private func setInitialValues() {
  127. radius = (frame.size.width / 2.0) * 0.8 //We always apply a 20% padding, stopping glows from being clipped
  128. backgroundColor = .clear
  129. set(colors: .white, .cyan)
  130. }
  131. private func refreshValues() {
  132. progressLayer.angle = angle
  133. progressLayer.startAngle = startAngle
  134. progressLayer.clockwise = clockwise
  135. progressLayer.roundedCorners = roundedCorners
  136. progressLayer.lerpColorMode = lerpColorMode
  137. progressLayer.gradientRotateSpeed = gradientRotateSpeed
  138. progressLayer.glowAmount = glowAmount
  139. progressLayer.glowMode = glowMode
  140. progressLayer.progressThickness = progressThickness / 2.0
  141. progressLayer.trackColor = trackColor
  142. progressLayer.trackThickness = trackThickness / 2.0
  143. }
  144. private func checkAndSetIBColors() {
  145. let IBColors = [IBColor1, IBColor2, IBColor3].compactMap { $0 }
  146. if IBColors.isEmpty == false {
  147. set(colors: IBColors)
  148. }
  149. }
  150. public func set(colors: UIColor...) {
  151. set(colors: colors)
  152. }
  153. private func set(colors: [UIColor]) {
  154. progressLayer.colorsArray = colors
  155. progressLayer.setNeedsDisplay()
  156. }
  157. public func animate(fromAngle: Double, toAngle: Double, duration: TimeInterval, relativeDuration: Bool = true, completion: ((Bool) -> Void)?) {
  158. pauseIfAnimating()
  159. let animationDuration: TimeInterval
  160. if relativeDuration {
  161. animationDuration = duration
  162. } else {
  163. let traveledAngle = (toAngle - fromAngle).mod(between: 0.0, and: 360.0, byIncrementing: 360.0)
  164. let scaledDuration = TimeInterval(traveledAngle) * duration / 360.0
  165. animationDuration = scaledDuration
  166. }
  167. let animation = CABasicAnimation(keyPath: #keyPath(KDCircularProgressViewLayer.angle))
  168. animation.fromValue = fromAngle
  169. animation.toValue = toAngle
  170. animation.duration = animationDuration
  171. animation.delegate = self
  172. animation.isRemovedOnCompletion = false
  173. angle = toAngle
  174. animationCompletionBlock = completion
  175. progressLayer.add(animation, forKey: "angle")
  176. }
  177. public func animate(toAngle: Double, duration: TimeInterval, relativeDuration: Bool = true, completion: ((Bool) -> Void)?) {
  178. pauseIfAnimating()
  179. animate(fromAngle: angle, toAngle: toAngle, duration: duration, relativeDuration: relativeDuration, completion: completion)
  180. }
  181. public func pauseAnimation() {
  182. guard let presentationLayer = progressLayer.presentation() else { return }
  183. let currentValue = presentationLayer.angle
  184. progressLayer.removeAllAnimations()
  185. angle = currentValue
  186. }
  187. private func pauseIfAnimating() {
  188. if isAnimating() {
  189. pauseAnimation()
  190. }
  191. }
  192. public func stopAnimation() {
  193. progressLayer.removeAllAnimations()
  194. angle = 0
  195. }
  196. public func isAnimating() -> Bool {
  197. return progressLayer.animation(forKey: "angle") != nil
  198. }
  199. public func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
  200. animationCompletionBlock?(flag)
  201. animationCompletionBlock = nil
  202. }
  203. public override func didMoveToWindow() {
  204. window.map { progressLayer.contentsScale = $0.screen.scale }
  205. }
  206. public override func willMove(toSuperview newSuperview: UIView?) {
  207. if newSuperview == nil {
  208. pauseIfAnimating()
  209. }
  210. }
  211. public override func prepareForInterfaceBuilder() {
  212. setInitialValues()
  213. refreshValues()
  214. checkAndSetIBColors()
  215. progressLayer.setNeedsDisplay()
  216. }
  217. private class KDCircularProgressViewLayer: CALayer {
  218. @NSManaged var angle: Double
  219. var radius: CGFloat = 0.0 {
  220. didSet { invalidateGradientCache() }
  221. }
  222. var startAngle: Double = 0.0
  223. var clockwise: Bool = true {
  224. didSet {
  225. if clockwise != oldValue {
  226. invalidateGradientCache()
  227. }
  228. }
  229. }
  230. var roundedCorners: Bool = true
  231. var lerpColorMode: Bool = false
  232. var gradientRotateSpeed: CGFloat = 0.0 {
  233. didSet { invalidateGradientCache() }
  234. }
  235. var glowAmount: CGFloat = 0.0
  236. var glowMode: CWCircularProgressGlowMode = .forward
  237. var progressThickness: CGFloat = 0.5
  238. var trackThickness: CGFloat = 0.5
  239. var trackColor: UIColor = .black
  240. var progressInsideFillColor: UIColor = .clear
  241. var colorsArray: [UIColor] = [] {
  242. didSet { invalidateGradientCache() }
  243. }
  244. private var gradientCache: CGGradient?
  245. private var locationsCache: [CGFloat]?
  246. private enum GlowConstants {
  247. private static let sizeToGlowRatio: CGFloat = 0.00015
  248. static func glowAmount(forAngle angle: Double, glowAmount: CGFloat, glowMode: CWCircularProgressGlowMode, size: CGFloat) -> CGFloat {
  249. switch glowMode {
  250. case .forward:
  251. return CGFloat(angle) * size * sizeToGlowRatio * glowAmount
  252. case .reverse:
  253. return CGFloat(360.0 - angle) * size * sizeToGlowRatio * glowAmount
  254. case .constant:
  255. return 360.0 * size * sizeToGlowRatio * glowAmount
  256. default:
  257. return 0
  258. }
  259. }
  260. }
  261. override class func needsDisplay(forKey key: String) -> Bool {
  262. if key == #keyPath(angle) {
  263. return true
  264. }
  265. return super.needsDisplay(forKey: key)
  266. }
  267. override init(layer: Any) {
  268. super.init(layer: layer)
  269. let progressLayer = layer as! KDCircularProgressViewLayer
  270. radius = progressLayer.radius
  271. angle = progressLayer.angle
  272. startAngle = progressLayer.startAngle
  273. clockwise = progressLayer.clockwise
  274. roundedCorners = progressLayer.roundedCorners
  275. lerpColorMode = progressLayer.lerpColorMode
  276. gradientRotateSpeed = progressLayer.gradientRotateSpeed
  277. glowAmount = progressLayer.glowAmount
  278. glowMode = progressLayer.glowMode
  279. progressThickness = progressLayer.progressThickness
  280. trackThickness = progressLayer.trackThickness
  281. trackColor = progressLayer.trackColor
  282. colorsArray = progressLayer.colorsArray
  283. progressInsideFillColor = progressLayer.progressInsideFillColor
  284. }
  285. override init() {
  286. super.init()
  287. }
  288. required init?(coder aDecoder: NSCoder) {
  289. super.init(coder: aDecoder)
  290. }
  291. override func draw(in ctx: CGContext) {
  292. UIGraphicsPushContext(ctx)
  293. let size = bounds.size
  294. let width = size.width
  295. let height = size.height
  296. let trackLineWidth = radius * trackThickness
  297. let progressLineWidth = radius * progressThickness
  298. let arcRadius = max(radius - trackLineWidth / 2.0, radius - progressLineWidth / 2.0)
  299. ctx.addArc(center: CGPoint(x: width / 2.0, y: height / 2.0),
  300. radius: arcRadius,
  301. startAngle: 0,
  302. endAngle: CGFloat.pi * 2,
  303. clockwise: false)
  304. ctx.setStrokeColor(trackColor.cgColor)
  305. ctx.setFillColor(progressInsideFillColor.cgColor)
  306. ctx.setLineWidth(trackLineWidth)
  307. ctx.setLineCap(CGLineCap.butt)
  308. ctx.drawPath(using: .fillStroke)
  309. UIGraphicsBeginImageContextWithOptions(size, false, 0.0)
  310. let imageCtx = UIGraphicsGetCurrentContext()
  311. let canonicalAngle = angle.mod(between: 0.0, and: 360.0, byIncrementing: 360.0)
  312. let fromAngle = -startAngle.radians
  313. let toAngle: Double
  314. if clockwise {
  315. toAngle = (-canonicalAngle - startAngle).radians
  316. } else {
  317. toAngle = (canonicalAngle - startAngle).radians
  318. }
  319. imageCtx?.addArc(center: CGPoint(x: width / 2.0, y: height / 2.0),
  320. radius: arcRadius,
  321. startAngle: CGFloat(fromAngle),
  322. endAngle: CGFloat(toAngle),
  323. clockwise: clockwise)
  324. let glowValue = GlowConstants.glowAmount(forAngle: canonicalAngle, glowAmount: glowAmount, glowMode: glowMode, size: width)
  325. if glowValue > 0 {
  326. imageCtx?.setShadow(offset: .zero, blur: glowValue, color: UIColor.black.cgColor)
  327. }
  328. let linecap: CGLineCap = roundedCorners ? .round : .butt
  329. imageCtx?.setLineCap(linecap)
  330. imageCtx?.setLineWidth(progressLineWidth)
  331. imageCtx?.drawPath(using: .stroke)
  332. let drawMask: CGImage = UIGraphicsGetCurrentContext()!.makeImage()!
  333. UIGraphicsEndImageContext()
  334. ctx.saveGState()
  335. ctx.clip(to: bounds, mask: drawMask)
  336. if colorsArray.isEmpty {
  337. fillRect(withContext: ctx, color: .white)
  338. } else if colorsArray.count == 1 {
  339. fillRect(withContext: ctx, color: colorsArray[0])
  340. } else if lerpColorMode {
  341. lerp(withContext: ctx, colorsArray: colorsArray)
  342. } else {
  343. drawGradient(withContext: ctx, colorsArray: colorsArray)
  344. }
  345. ctx.restoreGState()
  346. UIGraphicsPopContext()
  347. }
  348. private func lerp(withContext context: CGContext, colorsArray: [UIColor]) {
  349. let canonicalAngle = angle.mod(between: 0.0, and: 360.0, byIncrementing: 360.0)
  350. let percentage = canonicalAngle / 360.0
  351. let steps = colorsArray.count - 1
  352. let step = 1.0 / Double(steps)
  353. for i in 1...steps {
  354. let di = Double(i)
  355. if percentage <= di * step || i == steps {
  356. let colorT = percentage.inverseLerp(min: (di - 1) * step, max: di * step)
  357. let color = colorT.colorLerp(minColor: colorsArray[i - 1], maxColor: colorsArray[i])
  358. fillRect(withContext: context, color: color)
  359. break
  360. }
  361. }
  362. }
  363. private func fillRect(withContext context: CGContext, color: UIColor) {
  364. context.setFillColor(color.cgColor)
  365. context.fill(bounds)
  366. }
  367. private func drawGradient(withContext context: CGContext, colorsArray: [UIColor]) {
  368. let baseSpace = CGColorSpaceCreateDeviceRGB()
  369. let locations = locationsCache ?? gradientLocationsFor(colorCount: colorsArray.count, gradientWidth: bounds.size.width)
  370. let gradient: CGGradient
  371. if let cachedGradient = gradientCache {
  372. gradient = cachedGradient
  373. } else {
  374. guard let newGradient = CGGradient(colorSpace: baseSpace, colorComponents: colorsArray.rgbNormalized.componentsJoined,
  375. locations: locations, count: colorsArray.count) else { return }
  376. gradientCache = newGradient
  377. gradient = newGradient
  378. }
  379. let halfX = bounds.size.width / 2.0
  380. let floatPi = CGFloat.pi
  381. let rotateSpeed = clockwise == true ? gradientRotateSpeed : gradientRotateSpeed * -1.0
  382. let angleInRadians = (rotateSpeed * CGFloat(angle) - 90.0).radians
  383. let oppositeAngle = angleInRadians > floatPi ? angleInRadians - floatPi : angleInRadians + floatPi
  384. let startPoint = CGPoint(x: (cos(angleInRadians) * halfX) + halfX, y: (sin(angleInRadians) * halfX) + halfX)
  385. let endPoint = CGPoint(x: (cos(oppositeAngle) * halfX) + halfX, y: (sin(oppositeAngle) * halfX) + halfX)
  386. context.drawLinearGradient(gradient, start: startPoint, end: endPoint, options: .drawsBeforeStartLocation)
  387. }
  388. private func gradientLocationsFor(colorCount: Int, gradientWidth: CGFloat) -> [CGFloat] {
  389. guard colorCount > 0, gradientWidth > 0 else { return [] }
  390. let progressLineWidth = radius * progressThickness
  391. let firstPoint = gradientWidth / 2.0 - (radius - progressLineWidth / 2.0)
  392. let increment = (gradientWidth - (2.0 * firstPoint)) / CGFloat(colorCount - 1)
  393. let locationsArray = (0..<colorCount).map { firstPoint + (CGFloat($0) * increment) }
  394. let result = locationsArray.map { $0 / gradientWidth }
  395. locationsCache = result
  396. return result
  397. }
  398. private func invalidateGradientCache() {
  399. gradientCache = nil
  400. locationsCache = nil
  401. }
  402. }
  403. }
  404. //Some helper extensions below
  405. private extension Array where Element == UIColor {
  406. // Make sure every color in colors array is in RGB color space
  407. var rgbNormalized: [UIColor] {
  408. return map { color in
  409. guard color.cgColor.numberOfComponents == 2 else {
  410. return color
  411. }
  412. let white: CGFloat = color.cgColor.components![0]
  413. return UIColor(red: white, green: white, blue: white, alpha: 1.0)
  414. }
  415. }
  416. var componentsJoined: [CGFloat] {
  417. return flatMap { $0.cgColor.components ?? [] }
  418. }
  419. }
  420. private extension Comparable {
  421. func clamp(lowerBound: Self, upperBound: Self) -> Self {
  422. return min(max(self, lowerBound), upperBound)
  423. }
  424. }
  425. private extension FloatingPoint {
  426. var radians: Self {
  427. return self * .pi / Self(180)
  428. }
  429. func mod(between left: Self, and right: Self, byIncrementing interval: Self) -> Self {
  430. assert(interval > 0)
  431. assert(interval <= right - left)
  432. assert(right > left)
  433. if self >= left, self <= right {
  434. return self
  435. } else if self < left {
  436. return (self + interval).mod(between: left, and: right, byIncrementing: interval)
  437. } else {
  438. return (self - interval).mod(between: left, and: right, byIncrementing: interval)
  439. }
  440. }
  441. }
  442. private extension BinaryFloatingPoint {
  443. func inverseLerp(min: Self, max: Self) -> Self {
  444. return (self - min) / (max - min)
  445. }
  446. func lerp(min: Self, max: Self) -> Self {
  447. return (max - min) * self + min
  448. }
  449. func colorLerp(minColor: UIColor, maxColor: UIColor) -> UIColor {
  450. let clampedValue = CGFloat(self.clamp(lowerBound: 0.0, upperBound: 1.0))
  451. let zero = CGFloat(0.0)
  452. var (r0, g0, b0, a0) = (zero, zero, zero, zero)
  453. minColor.getRed(&r0, green: &g0, blue: &b0, alpha: &a0)
  454. var (r1, g1, b1, a1) = (zero, zero, zero, zero)
  455. maxColor.getRed(&r1, green: &g1, blue: &b1, alpha: &a1)
  456. return UIColor(red: clampedValue.lerp(min: r0, max: r1),
  457. green: clampedValue.lerp(min: g0, max: g1),
  458. blue: clampedValue.lerp(min: b0, max: b1),
  459. alpha: clampedValue.lerp(min: a0, max: a1))
  460. }
  461. }