2 // HADiscreteSlider.swift
5 // Created by Heberti Almeida on 12/02/15.
6 // Copyright (c) 2015 Folio Reader. All rights reserved.
11 enum ComponentStyle: Int {
19 let iOSThumbShadowRadius: CGFloat = 4.0
20 let iosThumbShadowOffset = CGSize(width: 0, height: 3)
22 class HADiscreteSlider : UIControl {
24 func ticksDistanceChanged(_ ticksDistance: CGFloat, sender: AnyObject) { }
25 func valueChanged(_ value: CGFloat) { }
29 var tickStyle: ComponentStyle = ComponentStyle.rectangular {
30 didSet { self.layoutTrack() }
33 var tickSize: CGSize = CGSize(width: 1.0, height: 4.0) {
35 self.tickSize.width = max(0, value.width)
36 self.tickSize.height = max(0, value.height)
41 var tickCount: Int = 11 {
43 self.tickCount = max(2, value)
48 var ticksDistance: CGFloat {
50 assert(self.tickCount > 1, "2 ticks minimum \(self.tickCount)")
51 let segments = CGFloat(max(1, self.tickCount-1))
52 return (self.trackRectangle!.size.width/segments)
56 var tickImage: String? {
57 didSet { self.layoutTrack() }
60 var trackStyle: ComponentStyle = ComponentStyle.ios {
61 didSet { self.layoutTrack() }
64 var trackThickness: CGFloat = 2.0 {
66 self.trackThickness = max(0, value)
71 var trackImage: String? {
72 didSet { self.layoutTrack() }
75 var thumbStyle: ComponentStyle = ComponentStyle.ios {
76 didSet { self.layoutTrack() }
79 var thumbSize: CGSize = CGSize(width: 10.0, height: 10.0) {
81 self.thumbSize.width = max(1, value.width)
82 self.thumbSize.height = max(1, value.height)
87 var thumbShadowRadius: CGFloat = 0.0 {
88 didSet { self.layoutTrack() }
91 var thumbImage: String? {
93 self.thumbImage = value
94 // Associate image to layer
95 if let imageName = value {
96 let image: UIImage = UIImage(named: imageName)!
97 self.thumbLayer!.contents = image.cgImage
103 // AKA: UISlider value (as CGFloat for compatibility with UISlider API, but expected to contain integers)
104 var minimumValue: CGFloat {
105 get { return CGFloat(self._intMinimumValue!) } // calculated property, with a float-to-int adapter
107 _intMinimumValue = Int(value);
113 get { return CGFloat(self._intValue!) }
115 let rootValue = ((value - self.minimumValue) / self.incrementValue)
116 _intValue = Int(self.minimumValue+(rootValue * self.incrementValue))
121 var incrementValue: CGFloat = 1 {
123 self.incrementValue = value
124 if 0 == incrementValue {
125 self.incrementValue = 1 // nonZeroIncrement
131 var thumbColor: UIColor?
132 var thumbShadowOffset: CGSize?
134 var _intMinimumValue: Int?
135 var ticksAbscisses = [CGPoint]()
136 var thumbAbscisse: CGFloat?
137 var thumbLayer: CALayer?
138 var colorTrackLayer: CALayer?
139 var trackRectangle: CGRect!
141 // When bounds change, recalculate layout
142 // func setBounds(bounds: CGRect) {
143 // super.bounds = bounds
144 // self.layoutTrack()
145 // self.setNeedsDisplay()
148 override init(frame: CGRect) {
149 super.init(frame: frame)
150 self.initProperties()
153 required init?(coder aDecoder: NSCoder) {
154 fatalError("init(coder:) has not been implemented")
157 override func draw(_ rect: CGRect) {
162 func sendActionsForControlEvents() {
163 self.sendActions(for: UIControlEvents.valueChanged)
166 // MARK: HADiscreteSlider
168 func initProperties() {
169 self.thumbColor = UIColor.lightGray
170 self.thumbShadowOffset = CGSize.zero
171 _intMinimumValue = -5
173 self.thumbAbscisse = 0.0
174 self.trackRectangle = CGRect.zero
175 // In case we need a colored track, initialize it now
176 // There may be a more elegant way to do this than with a CALayer,
177 // but then again CALayer brings free animation and will animate along the thumb
178 self.colorTrackLayer = CALayer()
179 self.colorTrackLayer!.backgroundColor = UIColor(hue: 211.0/360.0, saturation: 1, brightness: 1, alpha: 1).cgColor
180 self.colorTrackLayer!.cornerRadius = 2.0
181 self.layer.addSublayer(self.colorTrackLayer!)
182 // The thumb is its own CALayer, which brings in free animation
183 self.thumbLayer = CALayer()
184 self.layer.addSublayer(self.thumbLayer!)
185 self.isMultipleTouchEnabled = false
190 let ctx = UIGraphicsGetCurrentContext()
192 switch self.trackStyle {
194 ctx!.addRect(self.trackRectangle)
198 // Draw image if exists
199 if let imageName = self.trackImage {
200 let image = UIImage(named:imageName)!
201 let centered = CGRect(x: (self.frame.size.width/2)-(image.size.width/2), y: (self.frame.size.height/2)-(image.size.height/2), width: image.size.width, height: image.size.height)
202 ctx!.draw(image.cgImage!, in: centered)
206 case .invisible, .rounded, .ios:
207 let path: UIBezierPath = UIBezierPath(roundedRect: self.trackRectangle, cornerRadius: self.trackRectangle.size.height/2)
208 ctx!.addPath(path.cgPath)
213 if .ios != self.tickStyle {
214 for originValue in self.ticksAbscisses {
215 let originPoint = originValue
216 let rectangle = CGRect(x: originPoint.x-(self.tickSize.width/2), y: originPoint.y-(self.tickSize.height/2), width: self.tickSize.width, height: self.tickSize.height)
217 switch self.tickStyle {
219 let path = UIBezierPath(roundedRect: rectangle, cornerRadius: rectangle.size.height/2)
220 ctx!.addPath(path.cgPath)
223 ctx!.addRect(rectangle)
226 // Draw image if exists
228 if let imageName = self.tickImage {
229 let image = UIImage(named: imageName)!
230 let centered = CGRect(x: rectangle.origin.x+(rectangle.size.width/2)-(image.size.width/2), y: rectangle.origin.y+(rectangle.size.height/2)-(image.size.height/2), width: image.size.width, height: image.size.height)
231 ctx!.draw(image.cgImage!, in: centered)
235 case .invisible: break
241 // iOS UISlider aka .IOS does not have ticks
242 ctx!.setFillColor(self.tintColor.cgColor)
244 // For colored track, we overlay a CALayer, which will animate along with the cursor
245 if .ios == self.trackStyle {
246 var frame = self.trackRectangle
247 frame?.size.width = self.thumbAbscisse!-self.trackRectangle.minX
248 self.colorTrackLayer!.frame = frame!
250 self.colorTrackLayer!.frame = CGRect.zero
255 if self.value >= self.minimumValue {
256 // Feature: hide the thumb when below range
257 let thumbSizeForStyle = self.thumbSizeIncludingShadow()
258 let thumbWidth = thumbSizeForStyle.width
259 let thumbHeight = thumbSizeForStyle.height
260 let rectangle = CGRect(x: self.thumbAbscisse!-(thumbWidth/2), y: (self.frame.size.height-thumbHeight)/2, width: thumbWidth, height: thumbHeight)
261 let shadowRadius = ((self.thumbStyle == .ios) ? iOSThumbShadowRadius : self.thumbShadowRadius)
262 let shadowOffset = ((self.thumbStyle == .ios) ? iosThumbShadowOffset : self.thumbShadowOffset)
263 // Ignore offset if there is no shadow
264 self.thumbLayer!.frame = ((shadowRadius != 0.0) ? rectangle.insetBy(dx: shadowRadius+shadowOffset!.width, dy: shadowRadius+shadowOffset!.height) : rectangle.insetBy(dx: shadowRadius, dy: shadowRadius))
265 switch self.thumbStyle {
267 // A rounded thumb is circular
268 self.thumbLayer!.backgroundColor = self.thumbColor!.cgColor
269 self.thumbLayer!.borderColor = UIColor.clear.cgColor
270 self.thumbLayer!.borderWidth = 0.0
271 self.thumbLayer!.cornerRadius = self.thumbLayer!.frame.size.width/2
272 self.thumbLayer!.allowsEdgeAntialiasing = true
276 // image is set using layer.contents
277 self.thumbLayer!.backgroundColor = UIColor.clear.cgColor
278 self.thumbLayer!.borderColor = UIColor.clear.cgColor
279 self.thumbLayer!.borderWidth = 0.0
280 self.thumbLayer!.cornerRadius = 0.0
281 self.thumbLayer!.allowsEdgeAntialiasing = false
285 self.thumbLayer!.backgroundColor = self.thumbColor!.cgColor
286 self.thumbLayer!.borderColor = UIColor.clear.cgColor
287 self.thumbLayer!.borderWidth = 0.0
288 self.thumbLayer!.cornerRadius = 0.0
289 self.thumbLayer!.allowsEdgeAntialiasing = false
293 self.thumbLayer!.backgroundColor = UIColor.clear.cgColor
294 self.thumbLayer!.cornerRadius = 0.0
298 self.thumbLayer!.backgroundColor = UIColor.white.cgColor
299 self.thumbLayer!.borderColor = UIColor(hue: 0, saturation: 0, brightness: 0.8, alpha: 1).cgColor
300 self.thumbLayer!.borderWidth = 0.5
301 self.thumbLayer!.cornerRadius = self.thumbLayer!.frame.size.width/2
302 self.thumbLayer!.allowsEdgeAntialiasing = true
308 if shadowRadius != 0.0 {
309 self.thumbLayer!.shadowOffset = shadowOffset!
310 self.thumbLayer!.shadowRadius = shadowRadius
311 self.thumbLayer!.shadowColor = UIColor.black.cgColor
312 self.thumbLayer!.shadowOpacity = 0.15
314 self.thumbLayer!.shadowRadius = 0.0
315 self.thumbLayer!.shadowOffset = CGSize.zero
316 self.thumbLayer!.shadowColor = UIColor.clear.cgColor
317 self.thumbLayer!.shadowOpacity = 0.0
323 assert(self.tickCount > 1, "2 ticks minimum \(self.tickCount)")
324 let segments = max(1, self.tickCount-1)
325 let thumbWidth = self.thumbSizeIncludingShadow().width
327 // Calculate the track ticks positions
328 let trackHeight = ((.ios == self.trackStyle) ? 2.0 : self.trackThickness)
329 var trackSize = CGSize(width: self.frame.size.width-thumbWidth, height: trackHeight)
330 if .image == self.trackStyle {
331 if let imageName = self.trackImage {
332 let image = UIImage(named: imageName)!
333 trackSize.width = image.size.width-thumbWidth
336 self.trackRectangle = CGRect(x: (self.frame.size.width-trackSize.width)/2, y: (self.frame.size.height-trackSize.height)/2, width: trackSize.width, height: trackSize.height)
337 let trackY = self.frame.size.height/2
339 self.ticksAbscisses.removeAll()
341 for i in 0...segments {
342 let ratio = Double(i) / Double(segments)
343 let originX = self.trackRectangle.origin.x+(trackSize.width * CGFloat(ratio))
344 let point = CGPoint(x:originX, y:trackY)
345 self.ticksAbscisses.append(point)
352 assert(self.tickCount > 1, "2 ticks minimum \(self.tickCount)")
353 let segments = max(1, self.tickCount-1)
354 // Calculate the thumb position
355 var thumbRatio = (self.value-self.minimumValue) / CGFloat(segments) * self.incrementValue
356 thumbRatio = max(0.0, min(thumbRatio, 1.0))
358 self.thumbAbscisse = self.trackRectangle.origin.x+(self.trackRectangle.size.width*thumbRatio)
361 func thumbSizeIncludingShadow() -> CGSize {
362 switch self.thumbStyle {
363 case .invisible: break
364 case .rectangular: break
366 return ((self.thumbShadowRadius != 0.0) ? CGSize(width: self.thumbSize.width+(self.thumbShadowRadius*2)+(self.thumbShadowOffset!.width*2), height: self.thumbSize.height+(self.thumbShadowRadius*2)+(self.thumbShadowOffset!.height*2)) : self.thumbSize)
368 return CGSize(width: 33.0+(iOSThumbShadowRadius*2)+(iosThumbShadowOffset.width*2), height: 33.0+(iOSThumbShadowRadius*2)+(iosThumbShadowOffset.height*2))
370 if let imageName = self.thumbImage {
371 return UIImage(named: imageName)!.size
374 return CGSize(width: 33.0, height: 33.0)
379 override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
380 self.touchDown(touches, duration: 0.1)
383 override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
384 self.touchDown(touches, duration: 0.0)
387 override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
388 self.touchUp(touches)
391 override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
392 self.touchUp(touches)
395 func touchDown(_ touches: Set<UITouch>, duration: TimeInterval) {
396 guard let touch = touches.first else { return }
397 let location = touch.location(in: touch.view)
398 self.moveThumbTo(location.x, duration: duration)
401 func touchUp(_ touches: Set<UITouch>) {
402 guard let touch = touches.first else { return }
403 let location = touch.location(in: touch.view)
404 let tick = self.pickTickFromSliderPosition(location.x)
405 self.moveThumbToTick(tick)
408 // MARK: Notifications
410 func moveThumbToTick(_ tick: Int) {
411 let intValue = Int(self.minimumValue)+(tick * Int(self.incrementValue))
412 if intValue != _intValue {
414 self.sendActionsForControlEvents()
417 self.setNeedsDisplay()
420 func moveThumbTo(_ abscisse: CGFloat, duration: CFTimeInterval) {
421 let leftMost = self.trackRectangle.minX
422 let rightMost = self.trackRectangle.maxX
423 self.thumbAbscisse = max(leftMost, min(abscisse, rightMost))
424 CATransaction.setAnimationDuration(duration)
425 let tick = self.pickTickFromSliderPosition(self.thumbAbscisse!)
426 let intValue = Int(self.minimumValue)+(tick * Int(self.incrementValue))
427 if intValue != _intValue {
429 self.sendActionsForControlEvents()
431 self.setNeedsDisplay()
434 func pickTickFromSliderPosition(_ abscisse: CGFloat) -> Int {
435 let leftMost = self.trackRectangle.minX
436 let rightMost = self.trackRectangle.maxX
437 let clampedAbscisse = max(leftMost, min(abscisse, rightMost))
438 let ratio = Double(clampedAbscisse-leftMost) / Double(rightMost-leftMost)
439 let segments = Double(max(1, self.tickCount-1))
440 return Int(round(segments*ratio))