@@ -9,50 +9,81 @@
99import UIKit
1010
1111@IBDesignable public class GMStepper: UIControl {
12− public var value = 0 {
12+
13+ /// Current value of the stepper. Defaults to 0.
14+ @objc @IBInspectable public var value: Double = 0 {
1315 didSet {
1416 value = min(maximumValue, max(minimumValue, value))
15− label.text = String(value)
17+
18+ let isInteger = floor(value) == value
19+
20+ //
21+ // If we have items, we will display them as steps
22+ //
1623
24+ if isInteger && stepValue == 1.0 && items.count > 0 {
25+ label.text = items[Int(value)]
26+ }
27+ else if showIntegerIfDoubleIsInteger && isInteger {
28+ label.text = String(stringInterpolationSegment: Int(value))
29+ } else {
30+ label.text = String(stringInterpolationSegment: value)
31+ }
32+
1733 if oldValue != value {
18− sendActionsForControlEvents(.ValueChanged)
34+ sendActions(for: .valueChanged)
1935 }
2036 }
2137 }
22−
23− @IBInspectable public var minimumValue: Int = 0 {
38+
39+ /// Minimum value. Must be less than maximumValue. Defaults to 0.
40+ @objc @IBInspectable public var minimumValue: Double = 0 {
2441 didSet {
25− value = 0
42+ value = min(maximumValue, max(minimumValue, value))
2643 }
2744 }
28−
29− @IBInspectable public var maximumValue: Int = 10 {
45+
46+ /// Maximum value. Must be more than minimumValue. Defaults to 100.
47+ @objc @IBInspectable public var maximumValue: Double = 100 {
3048 didSet {
31− value = 0
49+ value = min(maximumValue, max(minimumValue, value))
3250 }
3351 }
34−
35− @IBInspectable public var leftButtonText: String = "-" {
52+
53+ /// Step/Increment value as in UIStepper. Defaults to 1.
54+ @objc @IBInspectable public var stepValue: Double = 1
55+
56+ /// The same as UIStepper's autorepeat. If true, holding on the buttons or keeping the pan gesture alters the value repeatedly. Defaults to true.
57+ @objc @IBInspectable public var autorepeat: Bool = true
58+
59+ /// If the value is integer, it is shown without floating point.
60+ @objc @IBInspectable public var showIntegerIfDoubleIsInteger: Bool = true
61+
62+ /// Text on the left button. Be sure that it fits in the button. Defaults to "−".
63+ @objc @IBInspectable public var leftButtonText: String = "−" {
3664 didSet {
37− leftButton.setTitle(leftButtonText, forState: .Normal)
65+ leftButton.setTitle(leftButtonText, for: .normal)
3866 }
3967 }
40−
41− @IBInspectable public var rightButtonText: String = "+" {
68+
69+ /// Text on the right button. Be sure that it fits in the button. Defaults to "+".
70+ @objc @IBInspectable public var rightButtonText: String = "+" {
4271 didSet {
43− rightButton.setTitle(rightButtonText, forState: .Normal)
72+ rightButton.setTitle(rightButtonText, for: .normal)
4473 }
4574 }
46−
47− @IBInspectable public var buttonsTextColor: UIColor = UIColor.whiteColor() {
75+
76+ /// Text color of the buttons. Defaults to white.
77+ @objc @IBInspectable public var buttonsTextColor: UIColor = UIColor.white {
4878 didSet {
4979 for button in [leftButton, rightButton] {
50− button.setTitleColor(buttonsTextColor, forState: .Normal)
80+ button.setTitleColor(buttonsTextColor, for: .normal)
5181 }
5282 }
5383 }
54−
55− @IBInspectable public var buttonsBackgroundColor: UIColor = UIColor(red:0.21, green:0.5, blue:0.74, alpha:1) {
84+
85+ /// Background color of the buttons. Defaults to dark blue.
86+ @objc @IBInspectable public var buttonsBackgroundColor: UIColor = UIColor(red:0.21, green:0.5, blue:0.74, alpha:1) {
5687 didSet {
5788 for button in [leftButton, rightButton] {
5889 button.backgroundColor = buttonsBackgroundColor
@@ -60,146 +91,431 @@ import UIKit
6091 backgroundColor = buttonsBackgroundColor
6192 }
6293 }
63−
64− public var buttonsFont = UIFont(name: "AvenirNext-Bold", size: 20.0)! {
94+
95+ /// Font of the buttons. Defaults to AvenirNext-Bold, 20.0 points in size.
96+ @objc public var buttonsFont = UIFont(name: "AvenirNext-Bold", size: 20.0)! {
6597 didSet {
6698 for button in [leftButton, rightButton] {
6799 button.titleLabel?.font = buttonsFont
68100 }
69101 }
70102 }
71−
72− @IBInspectable public var labelTextColor: UIColor = UIColor.whiteColor() {
103+
104+ /// Text color of the middle label. Defaults to white.
105+ @objc @IBInspectable public var labelTextColor: UIColor = UIColor.white {
73106 didSet {
74107 label.textColor = labelTextColor
75108 }
76109 }
77−
78− @IBInspectable public var labelBackgroundColor: UIColor = UIColor(red:0.26, green:0.6, blue:0.87, alpha:1) {
110+
111+ /// Text color of the middle label. Defaults to lighter blue.
112+ @objc @IBInspectable public var labelBackgroundColor: UIColor = UIColor(red:0.26, green:0.6, blue:0.87, alpha:1) {
79113 didSet {
80114 label.backgroundColor = labelBackgroundColor
81115 }
82116 }
83117
84− public var labelFont = UIFont(name: "AvenirNext-Bold", size: 25.0)! {
118+ /// Font of the middle label. Defaults to AvenirNext-Bold, 25.0 points in size.
119+ @objc public var labelFont = UIFont(name: "AvenirNext-Bold", size: 25.0)! {
85120 didSet {
86121 label.font = labelFont
87122 }
88123 }
89−
90− @IBInspectable public var cornerRadius: CGFloat = 4.0 {
124+ /// Corner radius of the middle label. Defaults to 0.
125+ @objc @IBInspectable public var labelCornerRadius: CGFloat = 0 {
126+ didSet {
127+ label.layer.cornerRadius = labelCornerRadius
128+
129+ }
130+ }
131+
132+ /// Corner radius of the stepper's layer. Defaults to 4.0.
133+ @objc @IBInspectable public var cornerRadius: CGFloat = 4.0 {
91134 didSet {
92135 layer.cornerRadius = cornerRadius
93136 clipsToBounds = true
94137 }
95138 }
96139
97− @IBInspectable public var labelWidthWeight: CGFloat = 0.5 {
140+ /// Border width of the stepper and middle label's layer. Defaults to 0.0.
141+ @objc @IBInspectable public var borderWidth: CGFloat = 0.0 {
142+ didSet {
143+ layer.borderWidth = borderWidth
144+ label.layer.borderWidth = borderWidth
145+ }
146+ }
147+
148+ /// Color of the border of the stepper and middle label's layer. Defaults to clear color.
149+ @objc @IBInspectable public var borderColor: UIColor = UIColor.clear {
150+ didSet {
151+ layer.borderColor = borderColor.cgColor
152+ label.layer.borderColor = borderColor.cgColor
153+ }
154+ }
155+
156+ /// Percentage of the middle label's width. Must be between 0 and 1. Defaults to 0.5. Be sure that it is wide enough to show the value.
157+ @objc @IBInspectable public var labelWidthWeight: CGFloat = 0.5 {
98158 didSet {
99159 labelWidthWeight = min(1, max(0, labelWidthWeight))
100160 setNeedsLayout()
101161 }
102162 }
163+
164+ /// Color of the flashing animation on the buttons in case the value hit the limit.
165+ @objc @IBInspectable public var limitHitAnimationColor: UIColor = UIColor(red:0.26, green:0.6, blue:0.87, alpha:1)
166+
167+ /**
168+ Width of the sliding animation. When buttons clicked, the middle label does a slide animation towards to the clicked button. Defaults to 5.
169+ */
170+ let labelSlideLength: CGFloat = 5
171+
172+ /// Duration of the sliding animation
173+ let labelSlideDuration = TimeInterval(0.1)
174+
175+ /// Duration of the animation when the value hits the limit.
176+ let limitHitAnimationDuration = TimeInterval(0.1)
177+
178+ lazy var leftButton: UIButton = {
179+ let button = UIButton()
180+ button.setTitle(self.leftButtonText, for: .normal)
181+ button.setTitleColor(self.buttonsTextColor, for: .normal)
182+ button.backgroundColor = self.buttonsBackgroundColor
183+ button.titleLabel?.font = self.buttonsFont
184+ button.addTarget(self, action: #selector(GMStepper.leftButtonTouchDown), for: .touchDown)
185+ button.addTarget(self, action: #selector(GMStepper.buttonTouchUp), for: .touchUpInside)
186+ button.addTarget(self, action: #selector(GMStepper.buttonTouchUp), for: .touchUpOutside)
187+ button.addTarget(self, action: #selector(GMStepper.buttonTouchUp), for: .touchCancel)
188+ return button
189+ }()
190+
191+ lazy var rightButton: UIButton = {
192+ let button = UIButton()
193+ button.setTitle(self.rightButtonText, for: .normal)
194+ button.setTitleColor(self.buttonsTextColor, for: .normal)
195+ button.backgroundColor = self.buttonsBackgroundColor
196+ button.titleLabel?.font = self.buttonsFont
197+ button.addTarget(self, action: #selector(GMStepper.rightButtonTouchDown), for: .touchDown)
198+ button.addTarget(self, action: #selector(GMStepper.buttonTouchUp), for: .touchUpInside)
199+ button.addTarget(self, action: #selector(GMStepper.buttonTouchUp), for: .touchUpOutside)
200+ button.addTarget(self, action: #selector(GMStepper.buttonTouchUp), for: .touchCancel)
201+ return button
202+ }()
203+
204+ lazy var label: UILabel = {
205+ let label = UILabel()
206+ label.textAlignment = .center
207+ if self.showIntegerIfDoubleIsInteger && floor(self.value) == self.value {
208+ label.text = String(stringInterpolationSegment: Int(self.value))
209+ } else {
210+ label.text = String(stringInterpolationSegment: self.value)
211+ }
212+ label.textColor = self.labelTextColor
213+ label.backgroundColor = self.labelBackgroundColor
214+ label.font = self.labelFont
215+ label.layer.cornerRadius = self.labelCornerRadius
216+ label.layer.masksToBounds = true
217+ label.isUserInteractionEnabled = true
218+ let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(GMStepper.handlePan))
219+ panRecognizer.maximumNumberOfTouches = 1
220+ label.addGestureRecognizer(panRecognizer)
221+ return label
222+ }()
223+
224+ var labelOriginalCenter: CGPoint!
225+ var labelMaximumCenterX: CGFloat!
226+ var labelMinimumCenterX: CGFloat!
227+
228+ enum LabelPanState {
229+ case Stable, HitRightEdge, HitLeftEdge
230+ }
231+ var panState = LabelPanState.Stable
232+
233+ enum StepperState {
234+ case Stable, ShouldIncrease, ShouldDecrease
235+ }
236+ var stepperState = StepperState.Stable {
237+ didSet {
238+ if stepperState != .Stable {
239+ updateValue()
240+ if autorepeat {
241+ scheduleTimer()
242+ }
243+ }
244+ }
245+ }
103246
104− private let labelSlideLength: CGFloat = 5
105− private let labelSlideDuration: NSTimeInterval = 0.1
106−
107− private let limitHitAnimationDuration: NSTimeInterval = 0.1
108− @IBInspectable public var limitHitAnimationColor: UIColor = UIColor(red:0.26, green:0.6, blue:0.87, alpha:1)
109−
110− private let leftButton = UIButton()
111− private let rightButton = UIButton()
112− private let label = UILabel()
113247
114− required public init(coder aDecoder: NSCoder) {
248+ @objc public var items : [String] = [] {
249+ didSet {
250+ let isInteger = floor(value) == value
251+
252+ //
253+ // If we have items, we will display them as steps
254+ //
255+
256+ if isInteger && stepValue == 1.0 && items.count > 0 {
257+
258+ var value = Int(self.value)
259+
260+ if value >= items.count {
261+ value = items.count - 1
262+ self.value = Double(value)
263+ }
264+ else {
265+ label.text = items[value]
266+ }
267+ }
268+ }
269+ }
270+
271+ /// Timer used for autorepeat option
272+ var timer: Timer?
273+
274+ /** When UIStepper reaches its top speed, it alters the value with a time interval of ~0.05 sec.
275+ The user pressing and holding on the stepper repeatedly:
276+ - First 2.5 sec, the stepper changes the value every 0.5 sec.
277+ - For the next 1.5 sec, it changes the value every 0.1 sec.
278+ - Then, every 0.05 sec.
279+ */
280+ let timerInterval = TimeInterval(0.05)
281+
282+ /// Check the handleTimerFire: function. While it is counting the number of fires, it decreases the mod value so that the value is altered more frequently.
283+ var timerFireCount = 0
284+ var timerFireCountModulo: Int {
285+ if timerFireCount > 80 {
286+ return 1 // 0.05 sec * 1 = 0.05 sec
287+ } else if timerFireCount > 50 {
288+ return 2 // 0.05 sec * 2 = 0.1 sec
289+ } else {
290+ return 10 // 0.05 sec * 10 = 0.5 sec
291+ }
292+ }
293+
294+ @objc required public init?(coder aDecoder: NSCoder) {
115295 super.init(coder: aDecoder)
116296 setup()
117297 }
118−
119− public override init(frame: CGRect) {
298+
299+ @objc public override init(frame: CGRect) {
120300 super.init(frame: frame)
121301 setup()
122302 }
123−
124− private func setup() {
125− leftButton.setTitle(leftButtonText, forState: .Normal)
126− leftButton.setTitleColor(buttonsTextColor, forState: .Normal)
127− leftButton.backgroundColor = buttonsBackgroundColor
128− leftButton.titleLabel?.font = buttonsFont
129− leftButton.addTarget(self, action: "leftButtonTapped:", forControlEvents: .TouchDown)
303+
304+ func setup() {
130305 addSubview(leftButton)
131−
132− rightButton.setTitle(rightButtonText, forState: .Normal)
133− rightButton.setTitleColor(buttonsTextColor, forState: .Normal)
134− rightButton.backgroundColor = buttonsBackgroundColor
135− rightButton.titleLabel?.font = buttonsFont
136− rightButton.addTarget(self, action: "rightButtonTapped:", forControlEvents: .TouchDown)
137306 addSubview(rightButton)
138−
139− label.textAlignment = .Center
140− label.text = String(value)
141− label.textColor = labelTextColor
142− label.backgroundColor = labelBackgroundColor
143− label.font = labelFont
144307 addSubview(label)
145−
308+
146309 backgroundColor = buttonsBackgroundColor
147310 layer.cornerRadius = cornerRadius
148311 clipsToBounds = true
312+ labelOriginalCenter = label.center
313+
314+ NotificationCenter.default.addObserver(self, selector: #selector(GMStepper.reset), name: NSNotification.Name.UIApplicationWillResignActive, object: nil)
149315 }
150−
316+
151317 public override func layoutSubviews() {
152318 let buttonWidth = bounds.size.width * ((1 - labelWidthWeight) / 2)
153319 let labelWidth = bounds.size.width * labelWidthWeight
154−
320+
155321 leftButton.frame = CGRect(x: 0, y: 0, width: buttonWidth, height: bounds.size.height)
156322 label.frame = CGRect(x: buttonWidth, y: 0, width: labelWidth, height: bounds.size.height)
157323 rightButton.frame = CGRect(x: labelWidth + buttonWidth, y: 0, width: buttonWidth, height: bounds.size.height)
324+
325+ labelMaximumCenterX = label.center.x + labelSlideLength
326+ labelMinimumCenterX = label.center.x - labelSlideLength
327+ labelOriginalCenter = label.center
158328 }
159−
160− @objc private func leftButtonTapped(button: UIButton) {
329+
330+ func updateValue() {
331+ if stepperState == .ShouldIncrease {
332+ value += stepValue
333+ } else if stepperState == .ShouldDecrease {
334+ value -= stepValue
335+ }
336+ }
337+
338+ deinit {
339+ resetTimer()
340+ NotificationCenter.default.removeObserver(self)
341+ }
342+
343+ /// Useful closure for logging the timer interval. You can call this in the timer handler to test the autorepeat option. Not used in the current implementation.
344+// lazy var printTimerGaps: () -> () = {
345+// var prevTime: CFAbsoluteTime?
346+//
347+// return { _ in
348+// var now = CFAbsoluteTimeGetCurrent()
349+// if let prevTime = prevTime {
350+// print(now - prevTime)
351+// }
352+// prevTime = now
353+// }
354+// }()
355+}
356+
357+// MARK: Pan Gesture
358+extension GMStepper {
359+ @objc func handlePan(gesture: UIPanGestureRecognizer) {
360+ switch gesture.state {
361+ case .began:
362+ leftButton.isEnabled = false
363+ rightButton.isEnabled = false
364+ case .changed:
365+ let translation = gesture.translation(in: label)
366+ gesture.setTranslation(CGPoint.zero, in: label)
367+
368+ let slidingRight = gesture.velocity(in: label).x > 0
369+ let slidingLeft = gesture.velocity(in: label).x < 0
370+
371+ // Move the label with pan
372+ if slidingRight {
373+ label.center.x = min(labelMaximumCenterX, label.center.x + translation.x)
374+ } else if slidingLeft {
375+ label.center.x = max(labelMinimumCenterX, label.center.x + translation.x)
376+ }
377+
378+ // When the label hits the edges, increase/decrease value and change button backgrounds
379+ if label.center.x == labelMaximumCenterX {
380+ // If not hit the right edge before, increase the value and start the timer. If already hit the edge, do nothing. Timer will handle it.
381+ if panState != .HitRightEdge {
382+ stepperState = .ShouldIncrease
383+ panState = .HitRightEdge
384+ }
385+
386+ animateLimitHitIfNeeded()
387+ } else if label.center.x == labelMinimumCenterX {
388+ if panState != .HitLeftEdge {
389+ stepperState = .ShouldDecrease
390+ panState = .HitLeftEdge
391+ }
392+
393+ animateLimitHitIfNeeded()
394+ } else {
395+ panState = .Stable
396+ stepperState = .Stable
397+ resetTimer()
398+
399+ self.rightButton.backgroundColor = self.buttonsBackgroundColor
400+ self.leftButton.backgroundColor = self.buttonsBackgroundColor
401+ }
402+ case .ended, .cancelled, .failed:
403+ reset()
404+ default:
405+ break
406+ }
407+ }
408+
409+ @objc func reset() {
410+ panState = .Stable
411+ stepperState = .Stable
412+ resetTimer()
413+
414+ leftButton.isEnabled = true
415+ rightButton.isEnabled = true
416+ label.isUserInteractionEnabled = true
417+
418+ UIView.animate(withDuration: self.labelSlideDuration, animations: {
419+ self.label.center = self.labelOriginalCenter
420+ self.rightButton.backgroundColor = self.buttonsBackgroundColor
421+ self.leftButton.backgroundColor = self.buttonsBackgroundColor
422+ })
423+ }
424+}
425+
426+// MARK: Button Events
427+extension GMStepper {
428+ @objc func leftButtonTouchDown(button: UIButton) {
429+ rightButton.isEnabled = false
430+ label.isUserInteractionEnabled = false
431+ resetTimer()
432+
161433 if value == minimumValue {
162− animateLimitHitForButton(leftButton)
434+ animateLimitHitIfNeeded()
163435 } else {
164− value -= 1
436+ stepperState = .ShouldDecrease
165437 animateSlideLeft()
166438 }
439+
167440 }
168441
169− @objc private func rightButtonTapped(button: UIButton) {
442+ @objc func rightButtonTouchDown(button: UIButton) {
443+ leftButton.isEnabled = false
444+ label.isUserInteractionEnabled = false
445+ resetTimer()
446+
170447 if value == maximumValue {
171− animateLimitHitForButton(rightButton)
448+ animateLimitHitIfNeeded()
172449 } else {
173− value += 1
450+ stepperState = .ShouldIncrease
174451 animateSlideRight()
175452 }
176453 }
177−
178− private func animateSlideLeft() {
179− UIView.animateWithDuration(labelSlideDuration, animations: {
454+
455+ @objc func buttonTouchUp(button: UIButton) {
456+ reset()
457+ }
458+}
459+
460+// MARK: Animations
461+extension GMStepper {
462+
463+ func animateSlideLeft() {
464+ UIView.animate(withDuration: labelSlideDuration) {
180465 self.label.center.x -= self.labelSlideLength
181− }, completion: { _ in
182− UIView.animateWithDuration(self.labelSlideDuration, animations: {
183− self.label.center.x += self.labelSlideLength
184− })
185− })
466+ }
186467 }
187−
188− private func animateSlideRight() {
189− UIView.animateWithDuration(labelSlideDuration, animations: {
468+
469+ func animateSlideRight() {
470+ UIView.animate(withDuration: labelSlideDuration) {
190471 self.label.center.x += self.labelSlideLength
191− }, completion: { _ in
192− UIView.animateWithDuration(self.labelSlideDuration, animations: {
193− self.label.center.x -= self.labelSlideLength
194− })
195− })
472+ }
196473 }
197−
198− private func animateLimitHitForButton(button: UIButton){
199− UIView.animateWithDuration(limitHitAnimationDuration, animations: {
474+
475+ func animateToOriginalPosition() {
476+ if self.label.center != self.labelOriginalCenter {
477+ UIView.animate(withDuration: labelSlideDuration) {
478+ self.label.center = self.labelOriginalCenter
479+ }
480+ }
481+ }
482+
483+ func animateLimitHitIfNeeded() {
484+ if value == minimumValue {
485+ animateLimitHitForButton(button: leftButton)
486+ } else if value == maximumValue {
487+ animateLimitHitForButton(button: rightButton)
488+ }
489+ }
490+
491+ func animateLimitHitForButton(button: UIButton){
492+ UIView.animate(withDuration: limitHitAnimationDuration) {
200493 button.backgroundColor = self.limitHitAnimationColor
201− }, completion: { _ in
202− button.backgroundColor = self.buttonsBackgroundColor
203− })
494+ }
204495 }
205496}
497+
498+// MARK: Timer
499+extension GMStepper {
500+ @objc func handleTimerFire(timer: Timer) {
501+ timerFireCount += 1
502+
503+ if timerFireCount % timerFireCountModulo == 0 {
504+ updateValue()
505+ }
506+ }
507+
508+ func scheduleTimer() {
509+ timer = Timer.scheduledTimer(timeInterval: timerInterval, target: self, selector: #selector(GMStepper.handleTimerFire), userInfo: nil, repeats: true)
510+ }
511+
512+ func resetTimer() {
513+ if let timer = timer {
514+ timer.invalidate()
515+ self.timer = nil
516+ timerFireCount = 0
517+ }
518+ }
519+
520+
521+}