diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c18291..ef4eb7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,24 @@ The changelog for `MSCircularSlider`. Summarized release notes can be found in t ------------------------ +## 1.2.0 - 02-02-2018 +#### Added + - A handle image property to assign a `UIImage` to the handle(s) + - Documentation for all exposed methods and members + +#### Changed + - Major `MSCircularSliderHandle` structural enhancement + +#### Fixed + - An issue that caused the `.doubleCircle` handle type to not highlight upon touch + + ## 1.1.1 - 28-12-2017 #### Fixed - Access control levels causing IB to crash - Podspec file configuration + ## 1.1.0 - 27-12-2017 #### Added - Support for using `snapToLabels` concurrently with `snapToMarkers` (snaps to the nearest label or marker) @@ -29,3 +42,4 @@ The changelog for `MSCircularSlider`. Summarized release notes can be found in t ## 0.X.X All prerelease changes are not logged + diff --git a/MSCircularSlider.podspec b/MSCircularSlider.podspec index bd2d6ee..dcf98b3 100755 --- a/MSCircularSlider.podspec +++ b/MSCircularSlider.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'MSCircularSlider' - s.version = '1.1.1' + s.version = '1.2.0' s.license = { :type => 'MIT', :file => 'LICENSE' } s.authors = { 'ThunderStruct' => 'mohamedshahawy@aucegypt.edu' } s.summary = 'A full-featured circular slider for iOS applications' @@ -8,7 +8,7 @@ Pod::Spec.new do |s| # Source Info s.platform = :ios, '9.3' - s.source = { :git => 'https://github.com/ThunderStruct/MSCircularSlider.git', :branch => "master", :tag => "1.1.1" } + s.source = { :git => 'https://github.com/ThunderStruct/MSCircularSlider.git', :branch => "master", :tag => "1.2.0" } s.source_files = 'MSCircularSlider/*.{swift}' s.requires_arc = true diff --git a/MSCircularSlider/MSCircularSlider+IB.swift b/MSCircularSlider/MSCircularSlider+IB.swift index 299d554..00a0207 100644 --- a/MSCircularSlider/MSCircularSlider+IB.swift +++ b/MSCircularSlider/MSCircularSlider+IB.swift @@ -113,6 +113,15 @@ extension MSCircularSlider { handleColor = newValue } } + + @IBInspectable public var _handleImage: UIImage { + get { + return handleImage ?? UIImage() + } + set { + handleImage = newValue + } + } @IBInspectable public var _handleEnlargementPoints: Int { get { @@ -228,4 +237,3 @@ extension MSCircularSlider { - diff --git a/MSCircularSlider/MSCircularSlider.swift b/MSCircularSlider/MSCircularSlider.swift index faa45e1..baac791 100755 --- a/MSCircularSlider/MSCircularSlider.swift +++ b/MSCircularSlider/MSCircularSlider.swift @@ -34,7 +34,11 @@ public class MSCircularSlider: UIControl { //================================================================================ // DELEGATE + + /** The slider's main delegate */ weak public var delegate: MSCircularSliderProtocol? = nil + + /** A middle ground for casting the delegate */ private weak var castDelegate: MSCircularSliderDelegate? { get { return delegate as? MSCircularSliderDelegate @@ -45,34 +49,39 @@ public class MSCircularSlider: UIControl { } // VALUE/ANGLE MEMBERS + + /** The slider's least possible value - *default: 0.0* */ public var minimumValue: Double = 0.0 { didSet { setNeedsDisplay() } } + /** The slider's value at maximumAngle - *default: 100.0* */ public var maximumValue: Double = 100.0 { didSet { setNeedsDisplay() } } + /** The handle's current value - *default: minimumValue* */ public var currentValue: Double { set { let val = min(max(minimumValue, newValue), maximumValue) + handle.currentValue = val angle = angleFrom(value: val) castDelegate?.circularSlider(self, valueChangedTo: val, fromUser: false) sendActions(for: UIControlEvents.valueChanged) - setNeedsDisplay() } get { - return valueFrom(angle: angle) + return valueFrom(angle: handle.angle) } } + /** The slider's circular angle - *default: 360.0 (full circle)* */ public var maximumAngle: CGFloat = 360.0 { // Full circle by default didSet { if maximumAngle > 360.0 { @@ -90,12 +99,7 @@ public class MSCircularSlider: UIControl { } } - public var angle: CGFloat = 0 { - didSet { - angle = max(0, angle).truncatingRemainder(dividingBy: maximumAngle + 1) - } - } - + /** The slider layer's rotation - *default: nil / pointing north* */ public var rotationAngle: CGFloat? = nil { didSet { setNeedsUpdateConstraints() @@ -103,6 +107,7 @@ public class MSCircularSlider: UIControl { } } + /** The slider's radius - *default: computed* */ private var radius: CGFloat = -1.0 { didSet { setNeedsUpdateConstraints() @@ -111,6 +116,8 @@ public class MSCircularSlider: UIControl { } // LINE MEMBERS + + /** The slider's line width - *default: 5* */ public var lineWidth: Int = 5 { didSet { setNeedsUpdateConstraints() @@ -119,26 +126,28 @@ public class MSCircularSlider: UIControl { } } - + /** The color of the filled part of the slider - *default: .darkGray* */ public var filledColor: UIColor = .darkGray { didSet { setNeedsDisplay() } } - + /** The color of the unfilled part of the slider - *default: .lightGray* */ public var unfilledColor: UIColor = .lightGray { didSet { setNeedsDisplay() } } + /** The slider's ending line cap - *default: .round* */ public var unfilledLineCap: CGLineCap = .round { didSet { setNeedsDisplay() } } + /** The slider's beginning line cap - *default: .round* */ public var filledLineCap: CGLineCap = .round { didSet { setNeedsDisplay() @@ -146,36 +155,80 @@ public class MSCircularSlider: UIControl { } // HANDLE MEMBERS + + /** The slider's handle layer */ let handle = MSCircularSliderHandle() - public var handleColor: UIColor = .darkGray { - didSet { - setNeedsDisplay() + /** The handle's current angle from north - *default: 0.0 * */ + public var angle: CGFloat { + set { + handle.angle = max(0, newValue).truncatingRemainder(dividingBy: maximumAngle + 1) + } + get { + return handle.angle } } - public var handleType: MSCircularSliderHandleType = .largeCircle { - didSet { - setNeedsUpdateConstraints() + /** The handle's color - *default: .darkGray* */ + public var handleColor: UIColor { + set { + handle.color = newValue setNeedsDisplay() } + get { + return handle.color + } } - public var handleEnlargementPoints: Int = 10 { - didSet { - setNeedsUpdateConstraints() + /** The handle's type - *default: .largeCircle* */ + public var handleType: MSCircularSliderHandleType { + set { + handle.handleType = newValue setNeedsDisplay() } + get { + return handle.handleType + } } - public var handleHighlightable: Bool = true { - didSet { - handle.isHighlightable = handleHighlightable - setNeedsDisplay() + /** The handle's enlargement point from default size - *default: 10* */ + public var handleEnlargementPoints: Int { + set { + handle.enlargementPoints = newValue + } + get { + return handle.enlargementPoints + } + } + + /** Specifies whether the handle should highlight upon touchdown or not - *default: true* */ + public var handleHighlightable: Bool { + set { + handle.isHighlightable = newValue + } + get { + return handle.isHighlightable } } + /** The handle's image (overrides the handle color and type) - *default: nil* */ + public var handleImage: UIImage? { + set { + handle.image = newValue + } + get { + return handle.image + } + } + + /** The calculated handle's diameter based on its type */ + public var handleDiameter: CGFloat { + return handle.diameter + } + // LABEL MEMBERS + + /** The slider's labels array (laid down counter-clockwise) */ public var labels: [String] = [] { // All labels are evenly spaced didSet { setNeedsUpdateConstraints() @@ -183,6 +236,7 @@ public class MSCircularSlider: UIControl { } } + /** Specifies whether or not the handle should snap to the nearest label upon touch release - *default: false* */ public var snapToLabels: Bool = false { // The 'snap' occurs on touchUp didSet { setNeedsUpdateConstraints() @@ -190,18 +244,21 @@ public class MSCircularSlider: UIControl { } } + /** The labels' font - *default: .systemFont(ofSize: 12.0)* */ public var labelFont: UIFont = .systemFont(ofSize: 12.0) { didSet { setNeedsDisplay() } } + /** The labels' color - *default: .black* */ public var labelColor: UIColor = .black { didSet { setNeedsDisplay() } } + /** The labels' offset from center (negative values push inwards) - *default: 0* */ public var labelOffset: CGFloat = 0 { // Negative values move the labels closer to the center didSet { setNeedsUpdateConstraints() @@ -209,11 +266,14 @@ public class MSCircularSlider: UIControl { } } + /** The labels' distance from center */ private var labelInwardsDistance: CGFloat { return 0.1 * -(radius) - 0.5 * CGFloat(lineWidth) - 0.5 * labelFont.pointSize } // MARKER MEMBERS + + /** The number of markers to be displayed - *default: 0* */ public var markerCount: Int = 0 { // All markers are evenly spaced didSet { markerCount = max(markerCount, 0) @@ -222,12 +282,14 @@ public class MSCircularSlider: UIControl { } } + /** The markers' color - *default: .darkGray* */ public var markerColor: UIColor = .darkGray { didSet { setNeedsDisplay() } } + /** The markers' bezier path (takes precendence over `markerImage`)- *default: nil / circle shape will be drawn* */ public var markerPath: UIBezierPath? = nil { // Takes precedence over markerImage didSet { setNeedsUpdateConstraints() @@ -235,6 +297,7 @@ public class MSCircularSlider: UIControl { } } + /** The markers' image - *default: nil* */ public var markerImage: UIImage? = nil { // Mutually-exclusive with markerPath didSet { setNeedsUpdateConstraints() @@ -242,6 +305,7 @@ public class MSCircularSlider: UIControl { } } + /** Specifies whether or not the handle should snap to the nearest marker upon touch release - *default: false* */ public var snapToMarkers: Bool = false { // The 'snap' occurs on touchUp didSet { setNeedsUpdateConstraints() @@ -250,6 +314,8 @@ public class MSCircularSlider: UIControl { } // CALCULATED MEMBERS + + /** The slider's calculated radius based on the components' sizes */ public var calculatedRadius: CGFloat { if (radius == -1.0) { let minimumSize = min(bounds.size.height, bounds.size.width) @@ -260,30 +326,21 @@ public class MSCircularSlider: UIControl { return radius } + /** The slider's center point */ internal var centerPoint: CGPoint { return CGPoint(x: bounds.size.width * 0.5, y: bounds.size.height * 0.5) } + /** A read-only property that indicates whether or not the slider is a full circle */ public var fullCircle: Bool { return maximumAngle == 360.0 } - internal var handleDiameter: CGFloat { - switch handleType { - case .smallCircle: - return CGFloat(Double(lineWidth) / 2.0) - case .mediumCircle: - return CGFloat(lineWidth) - case .largeCircle, .doubleCircle: - return CGFloat(lineWidth + handleEnlargementPoints) - - } - } - //================================================================================ // SETTER METHODS //================================================================================ + /** Appends a new label to the `labels` array */ public func addLabel(_ string: String) { labels.append(string) @@ -291,6 +348,7 @@ public class MSCircularSlider: UIControl { setNeedsDisplay() } + /** Replaces the label at a certain index with the given string */ public func changeLabel(at index: Int, string: String) { assert(labels.count > index && index >= 0, "label index out of bounds") labels[index] = string @@ -299,6 +357,7 @@ public class MSCircularSlider: UIControl { setNeedsDisplay() } + /** Removes a label at a given index */ public func removeLabel(at index: Int) { assert(labels.count > index && index >= 0, "label index out of bounds") labels.remove(at: index) @@ -308,17 +367,27 @@ public class MSCircularSlider: UIControl { } //================================================================================ - // VIRTUAL METHODS + // INIT AND VIRTUAL METHODS //================================================================================ + func initHandle() { + handle.delegate = self + handle.center = { + return self.pointOnCircleAt(angle: self.angle) + } + } + override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear + initHandle() + } required public init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) backgroundColor = .clear + initHandle() } override public var intrinsicContentSize: CGSize { @@ -341,8 +410,7 @@ public class MSCircularSlider: UIControl { drawMarkings(ctx: ctx!) // Draw handle - let handleCenter = pointOnCircleAt(angle: angle) - handle.frame = drawHandle(ctx: ctx!, atPoint: handleCenter, handle: handle) + handle.draw(in: ctx!) // Draw labels drawLabels(ctx: ctx!) @@ -360,7 +428,7 @@ public class MSCircularSlider: UIControl { return false } - if pointInsideHandle(point, handleCenter: pointOnCircleAt(angle: angle)) { + if handle.contains(point) { return true } @@ -372,7 +440,7 @@ public class MSCircularSlider: UIControl { override public func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { let location = touch.location(in: self) - if pointInsideHandle(location, handleCenter: pointOnCircleAt(angle: angle)) { + if handle.contains(location) { handle.isPressed = true castDelegate?.circularSlider(self, startedTrackingWith: currentValue) setNeedsDisplay() @@ -406,10 +474,12 @@ public class MSCircularSlider: UIControl { setNeedsDisplay() } + //================================================================================ // DRAWING METHODS //================================================================================ + /** Draws a circular line in the given context */ internal func drawLine(ctx: CGContext) { unfilledColor.set() // Draw unfilled circle @@ -420,33 +490,7 @@ public class MSCircularSlider: UIControl { drawArc(ctx: ctx, center: centerPoint, radius: calculatedRadius, lineWidth: CGFloat(lineWidth), fromAngle: 0, toAngle: CGFloat(angle), lineCap: filledLineCap) } - internal func drawHandle(ctx: CGContext, atPoint handleCenter: CGPoint, handle: MSCircularSliderHandle) -> CGRect { - ctx.saveGState() - var frame: CGRect! - - // Highlight == 0.9 alpha - let calculatedHandleColor = handle.isHighlightable && handle.isPressed ? handleColor.withAlphaComponent(0.9) : handleColor - - // Handle color calculation - if handleType == .doubleCircle { - calculatedHandleColor.set() - drawFilledCircle(ctx: ctx, center: handleCenter, radius: 0.25 * handleDiameter) - - calculatedHandleColor.withAlphaComponent(0.7).set() - - frame = drawFilledCircle(ctx: ctx, center: handleCenter, radius: 0.5 * handleDiameter) - } - else { - calculatedHandleColor.set() - - frame = drawFilledCircle(ctx: ctx, center: handleCenter, radius: 0.5 * handleDiameter) - } - - - ctx.saveGState() - return frame - } - + /** Draws the slider's labels (if any exist) in the given context */ private func drawLabels(ctx: CGContext) { if labels.count > 0 { let attributes = [NSAttributedStringKey.font: labelFont, NSAttributedStringKey.foregroundColor: labelColor] as [NSAttributedStringKey : Any] @@ -472,6 +516,7 @@ public class MSCircularSlider: UIControl { } } + /** Draws the slider's markers (if any exist) in the given context */ private func drawMarkings(ctx: CGContext) { for i in 0 ..< markerCount { let markFrame = frameForMarkingAt(i) @@ -501,6 +546,7 @@ public class MSCircularSlider: UIControl { } } + /** Draws a filled circle in context */ @discardableResult internal func drawFilledCircle(ctx: CGContext, center: CGPoint, radius: CGFloat) -> CGRect { let frame = CGRect(x: center.x - radius, y: center.y - radius, width: 2 * radius, height: 2 * radius) @@ -508,11 +554,13 @@ public class MSCircularSlider: UIControl { return frame } + /** Draws an unfilled circle in context */ internal func drawUnfilledCircle(ctx: CGContext, center: CGPoint, radius: CGFloat, lineWidth: CGFloat, maximumAngle: CGFloat, lineCap: CGLineCap) { drawArc(ctx: ctx, center: center, radius: radius, lineWidth: lineWidth, fromAngle: 0, toAngle: maximumAngle, lineCap: lineCap) } + /** Draws an arc in context */ internal func drawArc(ctx: CGContext, center: CGPoint, radius: CGFloat, lineWidth: CGFloat, fromAngle: CGFloat, toAngle: CGFloat, lineCap: CGLineCap) { let cartesianFromAngle = toCartesian(toRad(Double(fromAngle))) let cartesianToAngle = toCartesian(toRad(Double(toAngle))) @@ -528,6 +576,7 @@ public class MSCircularSlider: UIControl { // CALCULATION METHODS //================================================================================ + /** Calculates the angle between two points on a circle */ internal func calculateAngle(from: CGPoint, to: CGPoint) -> CGFloat { var vector = CGPoint(x: to.x - from.x, y: to.y - from.y) let magnitude = CGFloat(sqrt(square(Double(vector.x)) + square(Double(vector.y)))) @@ -545,6 +594,7 @@ public class MSCircularSlider: UIControl { return CGFloat(toDeg(compassRad)) } + /** Returns a `CGPoint` on a circle given its radius and an angle */ private func pointOn(radius: CGFloat, angle: CGFloat) -> CGPoint { var result = CGPoint() @@ -555,11 +605,13 @@ public class MSCircularSlider: UIControl { return result } + /** Returns a `CGPoint` on the slider's circle given an angle */ internal func pointOnCircleAt(angle: CGFloat) -> CGPoint { let offset = pointOn(radius: calculatedRadius, angle: angle) return CGPoint(x: centerPoint.x + offset.x, y: centerPoint.y + offset.y) } + /** Calculates the bounds of a marker's frame given its index */ private func frameForMarkingAt(_ index: Int) -> CGRect { var percentageAlongCircle: CGFloat! @@ -583,6 +635,7 @@ public class MSCircularSlider: UIControl { height: markSize.height) } + /** Calculates the bounds of a label's frame given its index */ private func frameForLabelAt(_ index: Int) -> CGRect { let label = labels[index] var percentageAlongCircle: CGFloat! @@ -603,6 +656,7 @@ public class MSCircularSlider: UIControl { height: labelSize.height) } + /** Calculates the labels' offset so it would not intersect with the slider's line */ private func offsetForLabelAt(index: Int, withSize labelSize: CGSize) -> CGPoint { let percentageAlongCircle = fullCircle ? ((100.0 / CGFloat(labels.count)) * CGFloat(index)) / 100.0 : ((100.0 / CGFloat(labels.count - 1)) * CGFloat(index)) / 100.0 let labelDegrees = percentageAlongCircle * maximumAngle @@ -613,6 +667,7 @@ public class MSCircularSlider: UIControl { return CGPoint(x: -labelSize.width * 0.5 + inwardOffset.x, y: -labelSize.height * 0.5 + inwardOffset.y) } + /** Calculates the angle of a certain arc */ private func degreesFor(arcLength: CGFloat, onCircleWithRadius radius: CGFloat, withMaximumAngle degrees: CGFloat) -> CGFloat { let totalCircumference = CGFloat(2 * Double.pi) * radius @@ -621,6 +676,7 @@ public class MSCircularSlider: UIControl { return degrees * arcRatioToCircumference } + /** Checks whether or not a point lies within the slider's circle */ private func pointInsideCircle(_ point: CGPoint) -> Bool { let p1 = centerPoint let p2 = point @@ -630,16 +686,34 @@ public class MSCircularSlider: UIControl { return distance < calculatedRadius + CGFloat(lineWidth) * 0.5 } - internal func pointInsideHandle(_ point: CGPoint, handleCenter: CGPoint) -> Bool { - let handleRadius = max(handleDiameter, 44.0) * 0.5 // 44 points as per Apple's design guidelines - - return point.x >= handleCenter.x - handleRadius && point.x <= handleCenter.x + handleRadius && point.y >= handleCenter.y - handleRadius && point.y <= handleCenter.y + handleRadius - } + //================================================================================ // CONTROL METHODS //================================================================================ + /** Sets the `currentValue` with optional animation + public func setValue(_ newValue: Double, withAnimation animated: Bool = false, animationDuration duration: Double = 0.75, completionBlock: (() -> Void)? = nil) { + if !animated { + currentValue = newValue + return + } + + // Animate + let newVal = min(max(minimumValue, newValue), maximumValue) + + let anim = CABasicAnimation(keyPath: "currentValue") + anim.duration = duration + anim.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut) + anim.fromValue = currentValue + anim.toValue = newVal + anim.isRemovedOnCompletion = true + + handle.add(anim, forKey: "currentValue") + handle.currentValue = newVal + }*/ + + /** Moves the handle to `newAngle` */ private func moveHandle(newAngle: CGFloat) { if newAngle > maximumAngle { // for incomplete circles if newAngle > maximumAngle + (360 - maximumAngle) / 2.0 { @@ -657,9 +731,9 @@ public class MSCircularSlider: UIControl { setNeedsDisplay() } + /** Snaps the handle to the nearest label/marker depending on the settings */ private func snapHandle() { // Snapping calculation - // TODO: eliminate mutual-exclusion - use same minDist for both labels and markings to snap to nearest label or marking var fixedAngle = 0.0 as CGFloat if angle < 0 { @@ -709,39 +783,48 @@ public class MSCircularSlider: UIControl { // SUPPORT METHODS //================================================================================ + /** Calculates the angle from north given a value */ internal func angleFrom(value: Double) -> CGFloat { return (CGFloat(value) * maximumAngle) / CGFloat(maximumValue - minimumValue) } + /** Calculates the value given an angle from north */ internal func valueFrom(angle: CGFloat) -> Double { return (maximumValue - minimumValue) * Double(angle) / Double(maximumAngle) } + /** Converts degrees to radians */ private func toRad(_ degrees: Double) -> Double { return ((Double.pi * degrees) / 180.0) } + /** Converts radians to degrees */ private func toDeg(_ radians: Double) -> Double { return ((180.0 * radians) / Double.pi) } + /** Squares a given Double value */ internal func square(_ value: Double) -> Double { return value * value } + /** Converts cartesian radians to compass radians */ private func toCompass(_ cartesianRad: Double) -> Double { return cartesianRad + (Double.pi / 2) } + /** Converts compass radians to cartesian radians */ private func toCartesian(_ compassRad: Double) -> Double { return compassRad - (Double.pi / 2) } + /** Calculates the size of a label given the string and its font */ private func sizeOf(string: String, withFont font: UIFont) -> CGSize { let attributes = [NSAttributedStringKey.font: font] return NSAttributedString(string: string, attributes: attributes).size() } + /** Calculates the entire layer's rotation (used to cancel out any rotation affecting custom subviews) */ public func getRotationalTransform() -> CGAffineTransform { if fullCircle { // No rotation required @@ -760,8 +843,6 @@ public class MSCircularSlider: UIControl { } } - - } diff --git a/MSCircularSlider/MSCircularSliderHandle.swift b/MSCircularSlider/MSCircularSliderHandle.swift index 66fb0fc..4dfb570 100755 --- a/MSCircularSlider/MSCircularSliderHandle.swift +++ b/MSCircularSlider/MSCircularSliderHandle.swift @@ -21,16 +21,145 @@ public class MSCircularSliderHandle: CALayer { // MEMBERS //================================================================================ + /** A reference to the parent slider */ + private var slider: MSCircularSlider { + return delegate as! MSCircularSlider + } + + /** The handle's current angle form north */ + @NSManaged public var angle: CGFloat + + /** The handle's current value - *default: minimumValue* */ + @NSManaged public var currentValue: Double + + /** Specifies whether or not the handle is touched */ internal var isPressed: Bool = false { didSet { superlayer?.needsDisplay() } } + /** Specifies whether or not the handle should highlight upon touchdown */ internal var isHighlightable: Bool = true { didSet { superlayer?.needsDisplay() } } + /** The handle's color - *default: .darkGray* */ + internal var color: UIColor = .darkGray { + didSet { + setNeedsDisplay() + } + } + + /** The handle's type - *default: .largeCircle* */ + internal var handleType: MSCircularSliderHandleType = .largeCircle { + didSet { + setNeedsDisplay() + } + } + + /** The handle's image (overrides the handle color and type) - *default: nil* */ + internal var image: UIImage? = nil { + didSet { + setNeedsDisplay() + } + } + + /** The handle's enlargement point from default size - *default: 10* */ + internal var enlargementPoints: Int = 10 { + didSet { + setNeedsDisplay() + } + } + + /** The handle's center point */ + internal var center: (() -> CGPoint)! + + /** The calculated handle diameter based on its type */ + internal var diameter: CGFloat { + switch handleType { + case .smallCircle: + return CGFloat(Double(slider.lineWidth) / 2.0) + case .mediumCircle: + return CGFloat(slider.lineWidth) + case .largeCircle, .doubleCircle: + return CGFloat(slider.lineWidth + enlargementPoints) + + } + } + + //================================================================================ + // SETTERS AND GETTERS + //================================================================================ + + internal func setAngle(_ newAngle: CGFloat) { + angle = max(0, newAngle).truncatingRemainder(dividingBy: slider.maximumAngle + 1) + } + + //================================================================================ + // VIRTUAL METHODS + //================================================================================ + + override class public func needsDisplay(forKey key: String) -> Bool { + if key == "angle" || key == "currentValue" { + return true + } + return super.needsDisplay(forKey: key) + } + + //================================================================================ + // DRAWING + //================================================================================ + + internal func drawHandle(ctx: CGContext) { + UIGraphicsPushContext(ctx) + ctx.saveGState() + + // Highlight == 0.9 alpha + let calculatedHandleColor = isHighlightable && isPressed ? color.withAlphaComponent(0.9) : color + + // Handle drawing + if image != nil { + frame = CGRect(x: center().x - diameter * 0.5, + y: center().y - diameter * 0.5, + width: diameter, + height: diameter) + image?.draw(in: frame) + + } + else if handleType == .doubleCircle { + calculatedHandleColor.withAlphaComponent(isHighlightable && isPressed ? 0.9 : 1.0).set() + slider.drawFilledCircle(ctx: ctx, center: center(), radius: 0.25 * diameter) + + calculatedHandleColor.withAlphaComponent(isHighlightable && isPressed ? 0.6 : 0.7).set() + + frame = slider.drawFilledCircle(ctx: ctx, center: center(), radius: 0.5 * diameter) + } + else { + calculatedHandleColor.set() + + frame = slider.drawFilledCircle(ctx: ctx, center: center(), radius: 0.5 * diameter) + } + + ctx.saveGState() + UIGraphicsPopContext() + } + + public override func draw(in ctx: CGContext) { + drawHandle(ctx: ctx) + } + + //================================================================================ + // SUPPORT METHODS + //================================================================================ + + /** Checks whether or not a point lies within the handle's circle */ + override public func contains(_ point: CGPoint) -> Bool { + let handleRadius = max(diameter, 44.0) * 0.5 // 44 points as per Apple's design guidelines + + return point.x >= center().x - handleRadius && point.x <= center().x + handleRadius && point.y >= center().y - handleRadius && point.y <= center().y + handleRadius + } + } diff --git a/MSCircularSlider/MSDoubleHandleCircularSlider+IB.swift b/MSCircularSlider/MSDoubleHandleCircularSlider+IB.swift index 65fdd34..ad58644 100644 --- a/MSCircularSlider/MSDoubleHandleCircularSlider+IB.swift +++ b/MSCircularSlider/MSDoubleHandleCircularSlider+IB.swift @@ -56,6 +56,15 @@ extension MSDoubleHandleCircularSlider { } } + @IBInspectable public var _secondHandleImage: UIImage { + get { + return secondHandleImage ?? UIImage() + } + set { + secondHandleImage = newValue + } + } + @IBInspectable public var _secondHandleEnlargementPoints: Int { get { return secondHandleEnlargementPoints diff --git a/MSCircularSlider/MSDoubleHandleCircularSlider.swift b/MSCircularSlider/MSDoubleHandleCircularSlider.swift index 3492afd..ad82863 100755 --- a/MSCircularSlider/MSDoubleHandleCircularSlider.swift +++ b/MSCircularSlider/MSDoubleHandleCircularSlider.swift @@ -27,6 +27,7 @@ public class MSDoubleHandleCircularSlider: MSCircularSlider { //================================================================================ // DELEGATE + /** A middle ground for casting the delegate */ private weak var castDelegate: MSDoubleHandleCircularSliderDelegate? { get { return delegate as? MSDoubleHandleCircularSliderDelegate @@ -38,6 +39,27 @@ public class MSDoubleHandleCircularSlider: MSCircularSlider { // DOUBLE HANDLE SLIDER PROPERTIES + + /** The slider's second handle's current value - *default: `valueFrom(angle: 60.0)`* */ + public var secondCurrentValue: Double { + set { + let val = min(max(minimumValue, newValue), maximumValue) + secondHandle.currentValue = val + + // Update second angle + secondAngle = angleFrom(value: val) + + castDelegate?.circularSlider(self, valueChangedTo: currentValue, secondValue: val, isFirstHandle: nil, fromUser: false) + + sendActions(for: UIControlEvents.valueChanged) + setNeedsDisplay() + } + get { + return valueFrom(angle: secondHandle.angle) + } + } + + /** The minimum distance between the two handles - *default: 10* */ public var minimumHandlesDistance: CGFloat = 10 { // distance between handles didSet { let maxValue = CGFloat.pi * calculatedRadius * maximumAngle / 360.0 @@ -54,71 +76,79 @@ public class MSDoubleHandleCircularSlider: MSCircularSlider { } // SECOND HANDLE'S PROPERTIES + + /** The slider's second handle layer */ let secondHandle = MSCircularSliderHandle() - public var secondCurrentValue: Double { // second handle's value + /** The second handle's current angle from north - *default: 60.0 * */ + public var secondAngle: CGFloat { set { - let val = min(max(minimumValue, newValue), maximumValue) - - // Update second angle - secondAngle = angleFrom(value: val) - - castDelegate?.circularSlider(self, valueChangedTo: currentValue, secondValue: val, isFirstHandle: nil, fromUser: false) - - sendActions(for: UIControlEvents.valueChanged) + secondHandle.angle = max(0, newValue).truncatingRemainder(dividingBy: maximumAngle + 1) } get { - return valueFrom(angle: secondAngle) + return secondHandle.angle } } - - public var secondAngle: CGFloat = 60 { - didSet { - secondAngle = max(0.0, secondAngle).truncatingRemainder(dividingBy: maximumAngle + 1) + /** The second handle's color - *default: .darkGray* */ + public var secondHandleColor: UIColor { + set { + secondHandle.color = newValue + } + get { + return secondHandle.color } } - public var secondHandleColor: UIColor = .darkGray { - didSet { - setNeedsDisplay() + /** The second handle's image (overrides the handle color and type) - *default: nil* */ + public var secondHandleImage: UIImage? { + set { + secondHandle.image = newValue + } + get { + return secondHandle.image } } - public var secondHandleType: MSCircularSliderHandleType = .largeCircle { - didSet { - setNeedsUpdateConstraints() - setNeedsDisplay() + /** The second handle's type - *default: .largeCircle* */ + public var secondHandleType: MSCircularSliderHandleType { + set { + secondHandle.handleType = newValue + } + get { + return secondHandle.handleType } } - public var secondHandleEnlargementPoints: Int = 10 { - didSet { - setNeedsUpdateConstraints() - setNeedsDisplay() + /** The second handle's enlargement point from default size - *default: 10* */ + public var secondHandleEnlargementPoints: Int { + set { + secondHandle.enlargementPoints = newValue + } + get { + return secondHandle.enlargementPoints } } - public var secondHandleHighlightable: Bool = true { - didSet { - secondHandle.isHighlightable = secondHandleHighlightable - setNeedsDisplay() + /** Specifies whether the second handle should highlight upon touchdown or not - *default: true* */ + public var secondHandleHighlightable: Bool { + set { + secondHandle.isHighlightable = newValue + } + get { + return secondHandle.isHighlightable } } // CALCULATED MEMBERS - internal var secondHandleDiameter: CGFloat { - switch handleType { - case .smallCircle: - return CGFloat(Double(lineWidth) / 2.0) - case .mediumCircle: - return CGFloat(lineWidth) - case .largeCircle, .doubleCircle: - return CGFloat(lineWidth + secondHandleEnlargementPoints) - - } + + /** The calculated second handle's diameter based on its type */ + public var secondHandleDiameter: CGFloat { + return secondHandle.diameter } // OVERRIDDEN MEMBERS + + /** The slider's circular angle - *default: 360.0 (full circle)* */ override public var maximumAngle: CGFloat { didSet { // to account for dynamic maximumAngle changes @@ -150,13 +180,29 @@ public class MSDoubleHandleCircularSlider: MSCircularSlider { // VIRTUAL METHODS //================================================================================ + override func initHandle() { + super.initHandle() + secondHandle.delegate = self + secondHandle.center = { + return self.pointOnCircleAt(angle: self.secondAngle) + } + secondHandle.setAngle(60) + } + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required public init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + override public func draw(_ rect: CGRect) { super.draw(rect) let ctx = UIGraphicsGetCurrentContext() // Draw the second handle - let handleCenter = super.pointOnCircleAt(angle: secondAngle) - secondHandle.frame = self.drawSecondHandle(ctx: ctx!, atPoint: handleCenter, handle: secondHandle) + secondHandle.draw(in: ctx!) } override func drawLine(ctx: CGContext) { @@ -172,18 +218,17 @@ public class MSDoubleHandleCircularSlider: MSCircularSlider { override public func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { let location = touch.location(in: self) - let handleCenter = pointOnCircleAt(angle: angle) - let secondHandleCenter = pointOnCircleAt(angle: secondAngle) - if pointInsideHandle(location, handleCenter: handleCenter) { + + if handle.contains(location) { handle.isPressed = true } - if pointInsideHandle(location, handleCenter: secondHandleCenter) { + if secondHandle.contains(location) { secondHandle.isPressed = true } if handle.isPressed && secondHandle.isPressed { // determine closer handle - if (hypotf(Float(handleCenter.x - location.x), Float(handleCenter.y - location.y)) < hypotf(Float(secondHandleCenter.x - location.x), Float(secondHandleCenter.y - location.y))) { + if (hypotf(Float(handle.center().x - location.x), Float(handle.center().y - location.y)) < hypotf(Float(secondHandle.center().x - location.x), Float(secondHandle.center().y - location.y))) { // first handle is closer secondHandle.isPressed = false } @@ -228,48 +273,14 @@ public class MSDoubleHandleCircularSlider: MSCircularSlider { setNeedsDisplay() - // TODO: // Snap To Labels/Markings future feature } - //================================================================================ - // DRAWING METHODS - //================================================================================ - - internal func drawSecondHandle(ctx: CGContext, atPoint handleCenter: CGPoint, handle: MSCircularSliderHandle) -> CGRect { - // Comment out the call to the super class and customize the second handle here - // Must set calculatedColor for secondHandle in this case to set the handle's "highlight" if needed - // TODO: add separate secondHandleDiameter, secondHandleColor, and secondHandleType properties - - ctx.saveGState() - var frame: CGRect! - - // Highlight == 0.9 alpha - let calculatedHandleColor = handle.isHighlightable && handle.isPressed ? secondHandleColor.withAlphaComponent(0.9) : secondHandleColor - - // Handle color calculation - if secondHandleType == .doubleCircle { - calculatedHandleColor.set() - drawFilledCircle(ctx: ctx, center: handleCenter, radius: 0.25 * secondHandleDiameter) - - calculatedHandleColor.withAlphaComponent(0.7).set() - - frame = drawFilledCircle(ctx: ctx, center: handleCenter, radius: 0.5 * secondHandleDiameter) - } - else { - calculatedHandleColor.set() - - frame = drawFilledCircle(ctx: ctx, center: handleCenter, radius: 0.5 * secondHandleDiameter) - } - - - ctx.saveGState() - return frame - } - //================================================================================ // HANDLE-MOVING METHODS //================================================================================ + + /** Calculates the current distance between the two handles */ private func distanceBetweenHandles(_ firstHandle: CGRect, _ secondHandle: CGRect) -> CGFloat { let vector = CGPoint(x: firstHandle.minX - secondHandle.minX, y: firstHandle.minY - secondHandle.minY) let straightDistance = CGFloat(sqrt(square(Double(vector.x)) + square(Double(vector.y)))) @@ -278,9 +289,10 @@ public class MSDoubleHandleCircularSlider: MSCircularSlider { return circularDistance } + /** Moves the first handle to a given angle */ private func moveFirstHandleTo(_ newAngle: CGFloat) { let center = pointOnCircleAt(angle: newAngle) - let radius = handleDiameter / 2.0 + let radius = handle.diameter / 2.0 let newHandleFrame = CGRect(x: center.x - radius, y: center.y - radius, width: 2 * radius, height: 2 * radius) if !fullCircle && newAngle > secondAngle { @@ -288,7 +300,7 @@ public class MSDoubleHandleCircularSlider: MSCircularSlider { return } - if distanceBetweenHandles(newHandleFrame, secondHandle.frame) < minimumHandlesDistance + handleDiameter { + if distanceBetweenHandles(newHandleFrame, secondHandle.frame) < minimumHandlesDistance + secondHandle.diameter { // will cross the minimumHandlesDistance - no changes return } @@ -297,17 +309,18 @@ public class MSDoubleHandleCircularSlider: MSCircularSlider { setNeedsDisplay() } + /** Moves the second handle to a given angle */ private func moveSecondHandleTo(_ newAngle: CGFloat) { let center = pointOnCircleAt(angle: newAngle) - let radius = handleDiameter / 2.0 + let radius = secondHandle.diameter / 2.0 let newHandleFrame = CGRect(x: center.x - radius, y: center.y - radius, width: 2 * radius, height: 2 * radius) - if !fullCircle && newAngle > maximumAngle { + if !fullCircle && (newAngle > maximumAngle || newAngle < angle) { // will cross over the open part of the arc return } - if distanceBetweenHandles(newHandleFrame, handle.frame) < minimumHandlesDistance + handleDiameter { + if distanceBetweenHandles(newHandleFrame, handle.frame) < minimumHandlesDistance + secondHandle.diameter { // will cross the minimumHandlesDistance - no changes return } diff --git a/MSCircularSlider/MSGradientCircularSlider.swift b/MSCircularSlider/MSGradientCircularSlider.swift index e847327..477a736 100755 --- a/MSCircularSlider/MSGradientCircularSlider.swift +++ b/MSCircularSlider/MSGradientCircularSlider.swift @@ -11,12 +11,15 @@ import UIKit public class MSGradientCircularSlider: MSCircularSlider { // Gradient colors array + + /** The slider's gradient colors array - Note: use the provided methods to apply changes */ public var gradientColors: [UIColor] = [.lightGray, .blue, .darkGray] { didSet { setNeedsDisplay() } } + /** The slider's current angle */ override public var angle: CGFloat { didSet { let anglePercentage = Double(angle) * 100.0 / Double(maximumAngle) @@ -30,6 +33,7 @@ public class MSGradientCircularSlider: MSCircularSlider { // SETTER METHODS //================================================================================ + /** Appends a new color to the `gradientColors` array */ public func addColor(_ color: UIColor) { gradientColors.append(color) @@ -38,6 +42,7 @@ public class MSGradientCircularSlider: MSCircularSlider { setNeedsDisplay() } + /** Replaces the color at a certain index with the given new color */ public func changeColor(at index: Int, newColor: UIColor) { assert(gradientColors.count > index && index >= 0, "gradient color index out of bounds") gradientColors[index] = newColor @@ -47,6 +52,7 @@ public class MSGradientCircularSlider: MSCircularSlider { setNeedsDisplay() } + /** Removes a gradientColors at a given index */ public func removeColor(at index: Int) { assert(gradientColors.count > index && index >= 0, "gradient color index out of bounds") assert(gradientColors.count <= 2, "gradient colors array must contain at least 2 elements") @@ -61,6 +67,7 @@ public class MSGradientCircularSlider: MSCircularSlider { // SUPPORT METHODS //================================================================================ + /** Calculates the color blend at a certain filled percentage */ private func blend(from: UIColor, to: UIColor, percentage: Double) -> UIColor { var fromR: CGFloat = 0.0 var fromG: CGFloat = 0.0 @@ -87,6 +94,7 @@ public class MSGradientCircularSlider: MSCircularSlider { return UIColor(red: rR, green: rG, blue: rB, alpha: rA) } + /** Calculates the color for the current angle */ private func colorFor(percentage: Double) -> UIColor { let colorPercentageInterval = 100.0 / Double(gradientColors.count - 1) diff --git a/MSCircularSliderExample/MSCircularSlider/MSCircularSlider+IB.swift b/MSCircularSliderExample/MSCircularSlider/MSCircularSlider+IB.swift index 4d4b2b4..00a0207 100644 --- a/MSCircularSliderExample/MSCircularSlider/MSCircularSlider+IB.swift +++ b/MSCircularSliderExample/MSCircularSlider/MSCircularSlider+IB.swift @@ -113,6 +113,15 @@ extension MSCircularSlider { handleColor = newValue } } + + @IBInspectable public var _handleImage: UIImage { + get { + return handleImage ?? UIImage() + } + set { + handleImage = newValue + } + } @IBInspectable public var _handleEnlargementPoints: Int { get { diff --git a/MSCircularSliderExample/MSCircularSlider/MSCircularSlider.swift b/MSCircularSliderExample/MSCircularSlider/MSCircularSlider.swift index faa45e1..baac791 100755 --- a/MSCircularSliderExample/MSCircularSlider/MSCircularSlider.swift +++ b/MSCircularSliderExample/MSCircularSlider/MSCircularSlider.swift @@ -34,7 +34,11 @@ public class MSCircularSlider: UIControl { //================================================================================ // DELEGATE + + /** The slider's main delegate */ weak public var delegate: MSCircularSliderProtocol? = nil + + /** A middle ground for casting the delegate */ private weak var castDelegate: MSCircularSliderDelegate? { get { return delegate as? MSCircularSliderDelegate @@ -45,34 +49,39 @@ public class MSCircularSlider: UIControl { } // VALUE/ANGLE MEMBERS + + /** The slider's least possible value - *default: 0.0* */ public var minimumValue: Double = 0.0 { didSet { setNeedsDisplay() } } + /** The slider's value at maximumAngle - *default: 100.0* */ public var maximumValue: Double = 100.0 { didSet { setNeedsDisplay() } } + /** The handle's current value - *default: minimumValue* */ public var currentValue: Double { set { let val = min(max(minimumValue, newValue), maximumValue) + handle.currentValue = val angle = angleFrom(value: val) castDelegate?.circularSlider(self, valueChangedTo: val, fromUser: false) sendActions(for: UIControlEvents.valueChanged) - setNeedsDisplay() } get { - return valueFrom(angle: angle) + return valueFrom(angle: handle.angle) } } + /** The slider's circular angle - *default: 360.0 (full circle)* */ public var maximumAngle: CGFloat = 360.0 { // Full circle by default didSet { if maximumAngle > 360.0 { @@ -90,12 +99,7 @@ public class MSCircularSlider: UIControl { } } - public var angle: CGFloat = 0 { - didSet { - angle = max(0, angle).truncatingRemainder(dividingBy: maximumAngle + 1) - } - } - + /** The slider layer's rotation - *default: nil / pointing north* */ public var rotationAngle: CGFloat? = nil { didSet { setNeedsUpdateConstraints() @@ -103,6 +107,7 @@ public class MSCircularSlider: UIControl { } } + /** The slider's radius - *default: computed* */ private var radius: CGFloat = -1.0 { didSet { setNeedsUpdateConstraints() @@ -111,6 +116,8 @@ public class MSCircularSlider: UIControl { } // LINE MEMBERS + + /** The slider's line width - *default: 5* */ public var lineWidth: Int = 5 { didSet { setNeedsUpdateConstraints() @@ -119,26 +126,28 @@ public class MSCircularSlider: UIControl { } } - + /** The color of the filled part of the slider - *default: .darkGray* */ public var filledColor: UIColor = .darkGray { didSet { setNeedsDisplay() } } - + /** The color of the unfilled part of the slider - *default: .lightGray* */ public var unfilledColor: UIColor = .lightGray { didSet { setNeedsDisplay() } } + /** The slider's ending line cap - *default: .round* */ public var unfilledLineCap: CGLineCap = .round { didSet { setNeedsDisplay() } } + /** The slider's beginning line cap - *default: .round* */ public var filledLineCap: CGLineCap = .round { didSet { setNeedsDisplay() @@ -146,36 +155,80 @@ public class MSCircularSlider: UIControl { } // HANDLE MEMBERS + + /** The slider's handle layer */ let handle = MSCircularSliderHandle() - public var handleColor: UIColor = .darkGray { - didSet { - setNeedsDisplay() + /** The handle's current angle from north - *default: 0.0 * */ + public var angle: CGFloat { + set { + handle.angle = max(0, newValue).truncatingRemainder(dividingBy: maximumAngle + 1) + } + get { + return handle.angle } } - public var handleType: MSCircularSliderHandleType = .largeCircle { - didSet { - setNeedsUpdateConstraints() + /** The handle's color - *default: .darkGray* */ + public var handleColor: UIColor { + set { + handle.color = newValue setNeedsDisplay() } + get { + return handle.color + } } - public var handleEnlargementPoints: Int = 10 { - didSet { - setNeedsUpdateConstraints() + /** The handle's type - *default: .largeCircle* */ + public var handleType: MSCircularSliderHandleType { + set { + handle.handleType = newValue setNeedsDisplay() } + get { + return handle.handleType + } } - public var handleHighlightable: Bool = true { - didSet { - handle.isHighlightable = handleHighlightable - setNeedsDisplay() + /** The handle's enlargement point from default size - *default: 10* */ + public var handleEnlargementPoints: Int { + set { + handle.enlargementPoints = newValue + } + get { + return handle.enlargementPoints + } + } + + /** Specifies whether the handle should highlight upon touchdown or not - *default: true* */ + public var handleHighlightable: Bool { + set { + handle.isHighlightable = newValue + } + get { + return handle.isHighlightable } } + /** The handle's image (overrides the handle color and type) - *default: nil* */ + public var handleImage: UIImage? { + set { + handle.image = newValue + } + get { + return handle.image + } + } + + /** The calculated handle's diameter based on its type */ + public var handleDiameter: CGFloat { + return handle.diameter + } + // LABEL MEMBERS + + /** The slider's labels array (laid down counter-clockwise) */ public var labels: [String] = [] { // All labels are evenly spaced didSet { setNeedsUpdateConstraints() @@ -183,6 +236,7 @@ public class MSCircularSlider: UIControl { } } + /** Specifies whether or not the handle should snap to the nearest label upon touch release - *default: false* */ public var snapToLabels: Bool = false { // The 'snap' occurs on touchUp didSet { setNeedsUpdateConstraints() @@ -190,18 +244,21 @@ public class MSCircularSlider: UIControl { } } + /** The labels' font - *default: .systemFont(ofSize: 12.0)* */ public var labelFont: UIFont = .systemFont(ofSize: 12.0) { didSet { setNeedsDisplay() } } + /** The labels' color - *default: .black* */ public var labelColor: UIColor = .black { didSet { setNeedsDisplay() } } + /** The labels' offset from center (negative values push inwards) - *default: 0* */ public var labelOffset: CGFloat = 0 { // Negative values move the labels closer to the center didSet { setNeedsUpdateConstraints() @@ -209,11 +266,14 @@ public class MSCircularSlider: UIControl { } } + /** The labels' distance from center */ private var labelInwardsDistance: CGFloat { return 0.1 * -(radius) - 0.5 * CGFloat(lineWidth) - 0.5 * labelFont.pointSize } // MARKER MEMBERS + + /** The number of markers to be displayed - *default: 0* */ public var markerCount: Int = 0 { // All markers are evenly spaced didSet { markerCount = max(markerCount, 0) @@ -222,12 +282,14 @@ public class MSCircularSlider: UIControl { } } + /** The markers' color - *default: .darkGray* */ public var markerColor: UIColor = .darkGray { didSet { setNeedsDisplay() } } + /** The markers' bezier path (takes precendence over `markerImage`)- *default: nil / circle shape will be drawn* */ public var markerPath: UIBezierPath? = nil { // Takes precedence over markerImage didSet { setNeedsUpdateConstraints() @@ -235,6 +297,7 @@ public class MSCircularSlider: UIControl { } } + /** The markers' image - *default: nil* */ public var markerImage: UIImage? = nil { // Mutually-exclusive with markerPath didSet { setNeedsUpdateConstraints() @@ -242,6 +305,7 @@ public class MSCircularSlider: UIControl { } } + /** Specifies whether or not the handle should snap to the nearest marker upon touch release - *default: false* */ public var snapToMarkers: Bool = false { // The 'snap' occurs on touchUp didSet { setNeedsUpdateConstraints() @@ -250,6 +314,8 @@ public class MSCircularSlider: UIControl { } // CALCULATED MEMBERS + + /** The slider's calculated radius based on the components' sizes */ public var calculatedRadius: CGFloat { if (radius == -1.0) { let minimumSize = min(bounds.size.height, bounds.size.width) @@ -260,30 +326,21 @@ public class MSCircularSlider: UIControl { return radius } + /** The slider's center point */ internal var centerPoint: CGPoint { return CGPoint(x: bounds.size.width * 0.5, y: bounds.size.height * 0.5) } + /** A read-only property that indicates whether or not the slider is a full circle */ public var fullCircle: Bool { return maximumAngle == 360.0 } - internal var handleDiameter: CGFloat { - switch handleType { - case .smallCircle: - return CGFloat(Double(lineWidth) / 2.0) - case .mediumCircle: - return CGFloat(lineWidth) - case .largeCircle, .doubleCircle: - return CGFloat(lineWidth + handleEnlargementPoints) - - } - } - //================================================================================ // SETTER METHODS //================================================================================ + /** Appends a new label to the `labels` array */ public func addLabel(_ string: String) { labels.append(string) @@ -291,6 +348,7 @@ public class MSCircularSlider: UIControl { setNeedsDisplay() } + /** Replaces the label at a certain index with the given string */ public func changeLabel(at index: Int, string: String) { assert(labels.count > index && index >= 0, "label index out of bounds") labels[index] = string @@ -299,6 +357,7 @@ public class MSCircularSlider: UIControl { setNeedsDisplay() } + /** Removes a label at a given index */ public func removeLabel(at index: Int) { assert(labels.count > index && index >= 0, "label index out of bounds") labels.remove(at: index) @@ -308,17 +367,27 @@ public class MSCircularSlider: UIControl { } //================================================================================ - // VIRTUAL METHODS + // INIT AND VIRTUAL METHODS //================================================================================ + func initHandle() { + handle.delegate = self + handle.center = { + return self.pointOnCircleAt(angle: self.angle) + } + } + override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear + initHandle() + } required public init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) backgroundColor = .clear + initHandle() } override public var intrinsicContentSize: CGSize { @@ -341,8 +410,7 @@ public class MSCircularSlider: UIControl { drawMarkings(ctx: ctx!) // Draw handle - let handleCenter = pointOnCircleAt(angle: angle) - handle.frame = drawHandle(ctx: ctx!, atPoint: handleCenter, handle: handle) + handle.draw(in: ctx!) // Draw labels drawLabels(ctx: ctx!) @@ -360,7 +428,7 @@ public class MSCircularSlider: UIControl { return false } - if pointInsideHandle(point, handleCenter: pointOnCircleAt(angle: angle)) { + if handle.contains(point) { return true } @@ -372,7 +440,7 @@ public class MSCircularSlider: UIControl { override public func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { let location = touch.location(in: self) - if pointInsideHandle(location, handleCenter: pointOnCircleAt(angle: angle)) { + if handle.contains(location) { handle.isPressed = true castDelegate?.circularSlider(self, startedTrackingWith: currentValue) setNeedsDisplay() @@ -406,10 +474,12 @@ public class MSCircularSlider: UIControl { setNeedsDisplay() } + //================================================================================ // DRAWING METHODS //================================================================================ + /** Draws a circular line in the given context */ internal func drawLine(ctx: CGContext) { unfilledColor.set() // Draw unfilled circle @@ -420,33 +490,7 @@ public class MSCircularSlider: UIControl { drawArc(ctx: ctx, center: centerPoint, radius: calculatedRadius, lineWidth: CGFloat(lineWidth), fromAngle: 0, toAngle: CGFloat(angle), lineCap: filledLineCap) } - internal func drawHandle(ctx: CGContext, atPoint handleCenter: CGPoint, handle: MSCircularSliderHandle) -> CGRect { - ctx.saveGState() - var frame: CGRect! - - // Highlight == 0.9 alpha - let calculatedHandleColor = handle.isHighlightable && handle.isPressed ? handleColor.withAlphaComponent(0.9) : handleColor - - // Handle color calculation - if handleType == .doubleCircle { - calculatedHandleColor.set() - drawFilledCircle(ctx: ctx, center: handleCenter, radius: 0.25 * handleDiameter) - - calculatedHandleColor.withAlphaComponent(0.7).set() - - frame = drawFilledCircle(ctx: ctx, center: handleCenter, radius: 0.5 * handleDiameter) - } - else { - calculatedHandleColor.set() - - frame = drawFilledCircle(ctx: ctx, center: handleCenter, radius: 0.5 * handleDiameter) - } - - - ctx.saveGState() - return frame - } - + /** Draws the slider's labels (if any exist) in the given context */ private func drawLabels(ctx: CGContext) { if labels.count > 0 { let attributes = [NSAttributedStringKey.font: labelFont, NSAttributedStringKey.foregroundColor: labelColor] as [NSAttributedStringKey : Any] @@ -472,6 +516,7 @@ public class MSCircularSlider: UIControl { } } + /** Draws the slider's markers (if any exist) in the given context */ private func drawMarkings(ctx: CGContext) { for i in 0 ..< markerCount { let markFrame = frameForMarkingAt(i) @@ -501,6 +546,7 @@ public class MSCircularSlider: UIControl { } } + /** Draws a filled circle in context */ @discardableResult internal func drawFilledCircle(ctx: CGContext, center: CGPoint, radius: CGFloat) -> CGRect { let frame = CGRect(x: center.x - radius, y: center.y - radius, width: 2 * radius, height: 2 * radius) @@ -508,11 +554,13 @@ public class MSCircularSlider: UIControl { return frame } + /** Draws an unfilled circle in context */ internal func drawUnfilledCircle(ctx: CGContext, center: CGPoint, radius: CGFloat, lineWidth: CGFloat, maximumAngle: CGFloat, lineCap: CGLineCap) { drawArc(ctx: ctx, center: center, radius: radius, lineWidth: lineWidth, fromAngle: 0, toAngle: maximumAngle, lineCap: lineCap) } + /** Draws an arc in context */ internal func drawArc(ctx: CGContext, center: CGPoint, radius: CGFloat, lineWidth: CGFloat, fromAngle: CGFloat, toAngle: CGFloat, lineCap: CGLineCap) { let cartesianFromAngle = toCartesian(toRad(Double(fromAngle))) let cartesianToAngle = toCartesian(toRad(Double(toAngle))) @@ -528,6 +576,7 @@ public class MSCircularSlider: UIControl { // CALCULATION METHODS //================================================================================ + /** Calculates the angle between two points on a circle */ internal func calculateAngle(from: CGPoint, to: CGPoint) -> CGFloat { var vector = CGPoint(x: to.x - from.x, y: to.y - from.y) let magnitude = CGFloat(sqrt(square(Double(vector.x)) + square(Double(vector.y)))) @@ -545,6 +594,7 @@ public class MSCircularSlider: UIControl { return CGFloat(toDeg(compassRad)) } + /** Returns a `CGPoint` on a circle given its radius and an angle */ private func pointOn(radius: CGFloat, angle: CGFloat) -> CGPoint { var result = CGPoint() @@ -555,11 +605,13 @@ public class MSCircularSlider: UIControl { return result } + /** Returns a `CGPoint` on the slider's circle given an angle */ internal func pointOnCircleAt(angle: CGFloat) -> CGPoint { let offset = pointOn(radius: calculatedRadius, angle: angle) return CGPoint(x: centerPoint.x + offset.x, y: centerPoint.y + offset.y) } + /** Calculates the bounds of a marker's frame given its index */ private func frameForMarkingAt(_ index: Int) -> CGRect { var percentageAlongCircle: CGFloat! @@ -583,6 +635,7 @@ public class MSCircularSlider: UIControl { height: markSize.height) } + /** Calculates the bounds of a label's frame given its index */ private func frameForLabelAt(_ index: Int) -> CGRect { let label = labels[index] var percentageAlongCircle: CGFloat! @@ -603,6 +656,7 @@ public class MSCircularSlider: UIControl { height: labelSize.height) } + /** Calculates the labels' offset so it would not intersect with the slider's line */ private func offsetForLabelAt(index: Int, withSize labelSize: CGSize) -> CGPoint { let percentageAlongCircle = fullCircle ? ((100.0 / CGFloat(labels.count)) * CGFloat(index)) / 100.0 : ((100.0 / CGFloat(labels.count - 1)) * CGFloat(index)) / 100.0 let labelDegrees = percentageAlongCircle * maximumAngle @@ -613,6 +667,7 @@ public class MSCircularSlider: UIControl { return CGPoint(x: -labelSize.width * 0.5 + inwardOffset.x, y: -labelSize.height * 0.5 + inwardOffset.y) } + /** Calculates the angle of a certain arc */ private func degreesFor(arcLength: CGFloat, onCircleWithRadius radius: CGFloat, withMaximumAngle degrees: CGFloat) -> CGFloat { let totalCircumference = CGFloat(2 * Double.pi) * radius @@ -621,6 +676,7 @@ public class MSCircularSlider: UIControl { return degrees * arcRatioToCircumference } + /** Checks whether or not a point lies within the slider's circle */ private func pointInsideCircle(_ point: CGPoint) -> Bool { let p1 = centerPoint let p2 = point @@ -630,16 +686,34 @@ public class MSCircularSlider: UIControl { return distance < calculatedRadius + CGFloat(lineWidth) * 0.5 } - internal func pointInsideHandle(_ point: CGPoint, handleCenter: CGPoint) -> Bool { - let handleRadius = max(handleDiameter, 44.0) * 0.5 // 44 points as per Apple's design guidelines - - return point.x >= handleCenter.x - handleRadius && point.x <= handleCenter.x + handleRadius && point.y >= handleCenter.y - handleRadius && point.y <= handleCenter.y + handleRadius - } + //================================================================================ // CONTROL METHODS //================================================================================ + /** Sets the `currentValue` with optional animation + public func setValue(_ newValue: Double, withAnimation animated: Bool = false, animationDuration duration: Double = 0.75, completionBlock: (() -> Void)? = nil) { + if !animated { + currentValue = newValue + return + } + + // Animate + let newVal = min(max(minimumValue, newValue), maximumValue) + + let anim = CABasicAnimation(keyPath: "currentValue") + anim.duration = duration + anim.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut) + anim.fromValue = currentValue + anim.toValue = newVal + anim.isRemovedOnCompletion = true + + handle.add(anim, forKey: "currentValue") + handle.currentValue = newVal + }*/ + + /** Moves the handle to `newAngle` */ private func moveHandle(newAngle: CGFloat) { if newAngle > maximumAngle { // for incomplete circles if newAngle > maximumAngle + (360 - maximumAngle) / 2.0 { @@ -657,9 +731,9 @@ public class MSCircularSlider: UIControl { setNeedsDisplay() } + /** Snaps the handle to the nearest label/marker depending on the settings */ private func snapHandle() { // Snapping calculation - // TODO: eliminate mutual-exclusion - use same minDist for both labels and markings to snap to nearest label or marking var fixedAngle = 0.0 as CGFloat if angle < 0 { @@ -709,39 +783,48 @@ public class MSCircularSlider: UIControl { // SUPPORT METHODS //================================================================================ + /** Calculates the angle from north given a value */ internal func angleFrom(value: Double) -> CGFloat { return (CGFloat(value) * maximumAngle) / CGFloat(maximumValue - minimumValue) } + /** Calculates the value given an angle from north */ internal func valueFrom(angle: CGFloat) -> Double { return (maximumValue - minimumValue) * Double(angle) / Double(maximumAngle) } + /** Converts degrees to radians */ private func toRad(_ degrees: Double) -> Double { return ((Double.pi * degrees) / 180.0) } + /** Converts radians to degrees */ private func toDeg(_ radians: Double) -> Double { return ((180.0 * radians) / Double.pi) } + /** Squares a given Double value */ internal func square(_ value: Double) -> Double { return value * value } + /** Converts cartesian radians to compass radians */ private func toCompass(_ cartesianRad: Double) -> Double { return cartesianRad + (Double.pi / 2) } + /** Converts compass radians to cartesian radians */ private func toCartesian(_ compassRad: Double) -> Double { return compassRad - (Double.pi / 2) } + /** Calculates the size of a label given the string and its font */ private func sizeOf(string: String, withFont font: UIFont) -> CGSize { let attributes = [NSAttributedStringKey.font: font] return NSAttributedString(string: string, attributes: attributes).size() } + /** Calculates the entire layer's rotation (used to cancel out any rotation affecting custom subviews) */ public func getRotationalTransform() -> CGAffineTransform { if fullCircle { // No rotation required @@ -760,8 +843,6 @@ public class MSCircularSlider: UIControl { } } - - } diff --git a/MSCircularSliderExample/MSCircularSlider/MSCircularSliderHandle.swift b/MSCircularSliderExample/MSCircularSlider/MSCircularSliderHandle.swift index 66fb0fc..4dfb570 100755 --- a/MSCircularSliderExample/MSCircularSlider/MSCircularSliderHandle.swift +++ b/MSCircularSliderExample/MSCircularSlider/MSCircularSliderHandle.swift @@ -21,16 +21,145 @@ public class MSCircularSliderHandle: CALayer { // MEMBERS //================================================================================ + /** A reference to the parent slider */ + private var slider: MSCircularSlider { + return delegate as! MSCircularSlider + } + + /** The handle's current angle form north */ + @NSManaged public var angle: CGFloat + + /** The handle's current value - *default: minimumValue* */ + @NSManaged public var currentValue: Double + + /** Specifies whether or not the handle is touched */ internal var isPressed: Bool = false { didSet { superlayer?.needsDisplay() } } + /** Specifies whether or not the handle should highlight upon touchdown */ internal var isHighlightable: Bool = true { didSet { superlayer?.needsDisplay() } } + /** The handle's color - *default: .darkGray* */ + internal var color: UIColor = .darkGray { + didSet { + setNeedsDisplay() + } + } + + /** The handle's type - *default: .largeCircle* */ + internal var handleType: MSCircularSliderHandleType = .largeCircle { + didSet { + setNeedsDisplay() + } + } + + /** The handle's image (overrides the handle color and type) - *default: nil* */ + internal var image: UIImage? = nil { + didSet { + setNeedsDisplay() + } + } + + /** The handle's enlargement point from default size - *default: 10* */ + internal var enlargementPoints: Int = 10 { + didSet { + setNeedsDisplay() + } + } + + /** The handle's center point */ + internal var center: (() -> CGPoint)! + + /** The calculated handle diameter based on its type */ + internal var diameter: CGFloat { + switch handleType { + case .smallCircle: + return CGFloat(Double(slider.lineWidth) / 2.0) + case .mediumCircle: + return CGFloat(slider.lineWidth) + case .largeCircle, .doubleCircle: + return CGFloat(slider.lineWidth + enlargementPoints) + + } + } + + //================================================================================ + // SETTERS AND GETTERS + //================================================================================ + + internal func setAngle(_ newAngle: CGFloat) { + angle = max(0, newAngle).truncatingRemainder(dividingBy: slider.maximumAngle + 1) + } + + //================================================================================ + // VIRTUAL METHODS + //================================================================================ + + override class public func needsDisplay(forKey key: String) -> Bool { + if key == "angle" || key == "currentValue" { + return true + } + return super.needsDisplay(forKey: key) + } + + //================================================================================ + // DRAWING + //================================================================================ + + internal func drawHandle(ctx: CGContext) { + UIGraphicsPushContext(ctx) + ctx.saveGState() + + // Highlight == 0.9 alpha + let calculatedHandleColor = isHighlightable && isPressed ? color.withAlphaComponent(0.9) : color + + // Handle drawing + if image != nil { + frame = CGRect(x: center().x - diameter * 0.5, + y: center().y - diameter * 0.5, + width: diameter, + height: diameter) + image?.draw(in: frame) + + } + else if handleType == .doubleCircle { + calculatedHandleColor.withAlphaComponent(isHighlightable && isPressed ? 0.9 : 1.0).set() + slider.drawFilledCircle(ctx: ctx, center: center(), radius: 0.25 * diameter) + + calculatedHandleColor.withAlphaComponent(isHighlightable && isPressed ? 0.6 : 0.7).set() + + frame = slider.drawFilledCircle(ctx: ctx, center: center(), radius: 0.5 * diameter) + } + else { + calculatedHandleColor.set() + + frame = slider.drawFilledCircle(ctx: ctx, center: center(), radius: 0.5 * diameter) + } + + ctx.saveGState() + UIGraphicsPopContext() + } + + public override func draw(in ctx: CGContext) { + drawHandle(ctx: ctx) + } + + //================================================================================ + // SUPPORT METHODS + //================================================================================ + + /** Checks whether or not a point lies within the handle's circle */ + override public func contains(_ point: CGPoint) -> Bool { + let handleRadius = max(diameter, 44.0) * 0.5 // 44 points as per Apple's design guidelines + + return point.x >= center().x - handleRadius && point.x <= center().x + handleRadius && point.y >= center().y - handleRadius && point.y <= center().y + handleRadius + } + } diff --git a/MSCircularSliderExample/MSCircularSlider/MSDoubleHandleCircularSlider+IB.swift b/MSCircularSliderExample/MSCircularSlider/MSDoubleHandleCircularSlider+IB.swift index 65fdd34..ad58644 100644 --- a/MSCircularSliderExample/MSCircularSlider/MSDoubleHandleCircularSlider+IB.swift +++ b/MSCircularSliderExample/MSCircularSlider/MSDoubleHandleCircularSlider+IB.swift @@ -56,6 +56,15 @@ extension MSDoubleHandleCircularSlider { } } + @IBInspectable public var _secondHandleImage: UIImage { + get { + return secondHandleImage ?? UIImage() + } + set { + secondHandleImage = newValue + } + } + @IBInspectable public var _secondHandleEnlargementPoints: Int { get { return secondHandleEnlargementPoints diff --git a/MSCircularSliderExample/MSCircularSlider/MSDoubleHandleCircularSlider.swift b/MSCircularSliderExample/MSCircularSlider/MSDoubleHandleCircularSlider.swift index 3492afd..ad82863 100755 --- a/MSCircularSliderExample/MSCircularSlider/MSDoubleHandleCircularSlider.swift +++ b/MSCircularSliderExample/MSCircularSlider/MSDoubleHandleCircularSlider.swift @@ -27,6 +27,7 @@ public class MSDoubleHandleCircularSlider: MSCircularSlider { //================================================================================ // DELEGATE + /** A middle ground for casting the delegate */ private weak var castDelegate: MSDoubleHandleCircularSliderDelegate? { get { return delegate as? MSDoubleHandleCircularSliderDelegate @@ -38,6 +39,27 @@ public class MSDoubleHandleCircularSlider: MSCircularSlider { // DOUBLE HANDLE SLIDER PROPERTIES + + /** The slider's second handle's current value - *default: `valueFrom(angle: 60.0)`* */ + public var secondCurrentValue: Double { + set { + let val = min(max(minimumValue, newValue), maximumValue) + secondHandle.currentValue = val + + // Update second angle + secondAngle = angleFrom(value: val) + + castDelegate?.circularSlider(self, valueChangedTo: currentValue, secondValue: val, isFirstHandle: nil, fromUser: false) + + sendActions(for: UIControlEvents.valueChanged) + setNeedsDisplay() + } + get { + return valueFrom(angle: secondHandle.angle) + } + } + + /** The minimum distance between the two handles - *default: 10* */ public var minimumHandlesDistance: CGFloat = 10 { // distance between handles didSet { let maxValue = CGFloat.pi * calculatedRadius * maximumAngle / 360.0 @@ -54,71 +76,79 @@ public class MSDoubleHandleCircularSlider: MSCircularSlider { } // SECOND HANDLE'S PROPERTIES + + /** The slider's second handle layer */ let secondHandle = MSCircularSliderHandle() - public var secondCurrentValue: Double { // second handle's value + /** The second handle's current angle from north - *default: 60.0 * */ + public var secondAngle: CGFloat { set { - let val = min(max(minimumValue, newValue), maximumValue) - - // Update second angle - secondAngle = angleFrom(value: val) - - castDelegate?.circularSlider(self, valueChangedTo: currentValue, secondValue: val, isFirstHandle: nil, fromUser: false) - - sendActions(for: UIControlEvents.valueChanged) + secondHandle.angle = max(0, newValue).truncatingRemainder(dividingBy: maximumAngle + 1) } get { - return valueFrom(angle: secondAngle) + return secondHandle.angle } } - - public var secondAngle: CGFloat = 60 { - didSet { - secondAngle = max(0.0, secondAngle).truncatingRemainder(dividingBy: maximumAngle + 1) + /** The second handle's color - *default: .darkGray* */ + public var secondHandleColor: UIColor { + set { + secondHandle.color = newValue + } + get { + return secondHandle.color } } - public var secondHandleColor: UIColor = .darkGray { - didSet { - setNeedsDisplay() + /** The second handle's image (overrides the handle color and type) - *default: nil* */ + public var secondHandleImage: UIImage? { + set { + secondHandle.image = newValue + } + get { + return secondHandle.image } } - public var secondHandleType: MSCircularSliderHandleType = .largeCircle { - didSet { - setNeedsUpdateConstraints() - setNeedsDisplay() + /** The second handle's type - *default: .largeCircle* */ + public var secondHandleType: MSCircularSliderHandleType { + set { + secondHandle.handleType = newValue + } + get { + return secondHandle.handleType } } - public var secondHandleEnlargementPoints: Int = 10 { - didSet { - setNeedsUpdateConstraints() - setNeedsDisplay() + /** The second handle's enlargement point from default size - *default: 10* */ + public var secondHandleEnlargementPoints: Int { + set { + secondHandle.enlargementPoints = newValue + } + get { + return secondHandle.enlargementPoints } } - public var secondHandleHighlightable: Bool = true { - didSet { - secondHandle.isHighlightable = secondHandleHighlightable - setNeedsDisplay() + /** Specifies whether the second handle should highlight upon touchdown or not - *default: true* */ + public var secondHandleHighlightable: Bool { + set { + secondHandle.isHighlightable = newValue + } + get { + return secondHandle.isHighlightable } } // CALCULATED MEMBERS - internal var secondHandleDiameter: CGFloat { - switch handleType { - case .smallCircle: - return CGFloat(Double(lineWidth) / 2.0) - case .mediumCircle: - return CGFloat(lineWidth) - case .largeCircle, .doubleCircle: - return CGFloat(lineWidth + secondHandleEnlargementPoints) - - } + + /** The calculated second handle's diameter based on its type */ + public var secondHandleDiameter: CGFloat { + return secondHandle.diameter } // OVERRIDDEN MEMBERS + + /** The slider's circular angle - *default: 360.0 (full circle)* */ override public var maximumAngle: CGFloat { didSet { // to account for dynamic maximumAngle changes @@ -150,13 +180,29 @@ public class MSDoubleHandleCircularSlider: MSCircularSlider { // VIRTUAL METHODS //================================================================================ + override func initHandle() { + super.initHandle() + secondHandle.delegate = self + secondHandle.center = { + return self.pointOnCircleAt(angle: self.secondAngle) + } + secondHandle.setAngle(60) + } + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required public init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + override public func draw(_ rect: CGRect) { super.draw(rect) let ctx = UIGraphicsGetCurrentContext() // Draw the second handle - let handleCenter = super.pointOnCircleAt(angle: secondAngle) - secondHandle.frame = self.drawSecondHandle(ctx: ctx!, atPoint: handleCenter, handle: secondHandle) + secondHandle.draw(in: ctx!) } override func drawLine(ctx: CGContext) { @@ -172,18 +218,17 @@ public class MSDoubleHandleCircularSlider: MSCircularSlider { override public func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { let location = touch.location(in: self) - let handleCenter = pointOnCircleAt(angle: angle) - let secondHandleCenter = pointOnCircleAt(angle: secondAngle) - if pointInsideHandle(location, handleCenter: handleCenter) { + + if handle.contains(location) { handle.isPressed = true } - if pointInsideHandle(location, handleCenter: secondHandleCenter) { + if secondHandle.contains(location) { secondHandle.isPressed = true } if handle.isPressed && secondHandle.isPressed { // determine closer handle - if (hypotf(Float(handleCenter.x - location.x), Float(handleCenter.y - location.y)) < hypotf(Float(secondHandleCenter.x - location.x), Float(secondHandleCenter.y - location.y))) { + if (hypotf(Float(handle.center().x - location.x), Float(handle.center().y - location.y)) < hypotf(Float(secondHandle.center().x - location.x), Float(secondHandle.center().y - location.y))) { // first handle is closer secondHandle.isPressed = false } @@ -228,48 +273,14 @@ public class MSDoubleHandleCircularSlider: MSCircularSlider { setNeedsDisplay() - // TODO: // Snap To Labels/Markings future feature } - //================================================================================ - // DRAWING METHODS - //================================================================================ - - internal func drawSecondHandle(ctx: CGContext, atPoint handleCenter: CGPoint, handle: MSCircularSliderHandle) -> CGRect { - // Comment out the call to the super class and customize the second handle here - // Must set calculatedColor for secondHandle in this case to set the handle's "highlight" if needed - // TODO: add separate secondHandleDiameter, secondHandleColor, and secondHandleType properties - - ctx.saveGState() - var frame: CGRect! - - // Highlight == 0.9 alpha - let calculatedHandleColor = handle.isHighlightable && handle.isPressed ? secondHandleColor.withAlphaComponent(0.9) : secondHandleColor - - // Handle color calculation - if secondHandleType == .doubleCircle { - calculatedHandleColor.set() - drawFilledCircle(ctx: ctx, center: handleCenter, radius: 0.25 * secondHandleDiameter) - - calculatedHandleColor.withAlphaComponent(0.7).set() - - frame = drawFilledCircle(ctx: ctx, center: handleCenter, radius: 0.5 * secondHandleDiameter) - } - else { - calculatedHandleColor.set() - - frame = drawFilledCircle(ctx: ctx, center: handleCenter, radius: 0.5 * secondHandleDiameter) - } - - - ctx.saveGState() - return frame - } - //================================================================================ // HANDLE-MOVING METHODS //================================================================================ + + /** Calculates the current distance between the two handles */ private func distanceBetweenHandles(_ firstHandle: CGRect, _ secondHandle: CGRect) -> CGFloat { let vector = CGPoint(x: firstHandle.minX - secondHandle.minX, y: firstHandle.minY - secondHandle.minY) let straightDistance = CGFloat(sqrt(square(Double(vector.x)) + square(Double(vector.y)))) @@ -278,9 +289,10 @@ public class MSDoubleHandleCircularSlider: MSCircularSlider { return circularDistance } + /** Moves the first handle to a given angle */ private func moveFirstHandleTo(_ newAngle: CGFloat) { let center = pointOnCircleAt(angle: newAngle) - let radius = handleDiameter / 2.0 + let radius = handle.diameter / 2.0 let newHandleFrame = CGRect(x: center.x - radius, y: center.y - radius, width: 2 * radius, height: 2 * radius) if !fullCircle && newAngle > secondAngle { @@ -288,7 +300,7 @@ public class MSDoubleHandleCircularSlider: MSCircularSlider { return } - if distanceBetweenHandles(newHandleFrame, secondHandle.frame) < minimumHandlesDistance + handleDiameter { + if distanceBetweenHandles(newHandleFrame, secondHandle.frame) < minimumHandlesDistance + secondHandle.diameter { // will cross the minimumHandlesDistance - no changes return } @@ -297,17 +309,18 @@ public class MSDoubleHandleCircularSlider: MSCircularSlider { setNeedsDisplay() } + /** Moves the second handle to a given angle */ private func moveSecondHandleTo(_ newAngle: CGFloat) { let center = pointOnCircleAt(angle: newAngle) - let radius = handleDiameter / 2.0 + let radius = secondHandle.diameter / 2.0 let newHandleFrame = CGRect(x: center.x - radius, y: center.y - radius, width: 2 * radius, height: 2 * radius) - if !fullCircle && newAngle > maximumAngle { + if !fullCircle && (newAngle > maximumAngle || newAngle < angle) { // will cross over the open part of the arc return } - if distanceBetweenHandles(newHandleFrame, handle.frame) < minimumHandlesDistance + handleDiameter { + if distanceBetweenHandles(newHandleFrame, handle.frame) < minimumHandlesDistance + secondHandle.diameter { // will cross the minimumHandlesDistance - no changes return } diff --git a/MSCircularSliderExample/MSCircularSlider/MSGradientCircularSlider.swift b/MSCircularSliderExample/MSCircularSlider/MSGradientCircularSlider.swift index e847327..477a736 100755 --- a/MSCircularSliderExample/MSCircularSlider/MSGradientCircularSlider.swift +++ b/MSCircularSliderExample/MSCircularSlider/MSGradientCircularSlider.swift @@ -11,12 +11,15 @@ import UIKit public class MSGradientCircularSlider: MSCircularSlider { // Gradient colors array + + /** The slider's gradient colors array - Note: use the provided methods to apply changes */ public var gradientColors: [UIColor] = [.lightGray, .blue, .darkGray] { didSet { setNeedsDisplay() } } + /** The slider's current angle */ override public var angle: CGFloat { didSet { let anglePercentage = Double(angle) * 100.0 / Double(maximumAngle) @@ -30,6 +33,7 @@ public class MSGradientCircularSlider: MSCircularSlider { // SETTER METHODS //================================================================================ + /** Appends a new color to the `gradientColors` array */ public func addColor(_ color: UIColor) { gradientColors.append(color) @@ -38,6 +42,7 @@ public class MSGradientCircularSlider: MSCircularSlider { setNeedsDisplay() } + /** Replaces the color at a certain index with the given new color */ public func changeColor(at index: Int, newColor: UIColor) { assert(gradientColors.count > index && index >= 0, "gradient color index out of bounds") gradientColors[index] = newColor @@ -47,6 +52,7 @@ public class MSGradientCircularSlider: MSCircularSlider { setNeedsDisplay() } + /** Removes a gradientColors at a given index */ public func removeColor(at index: Int) { assert(gradientColors.count > index && index >= 0, "gradient color index out of bounds") assert(gradientColors.count <= 2, "gradient colors array must contain at least 2 elements") @@ -61,6 +67,7 @@ public class MSGradientCircularSlider: MSCircularSlider { // SUPPORT METHODS //================================================================================ + /** Calculates the color blend at a certain filled percentage */ private func blend(from: UIColor, to: UIColor, percentage: Double) -> UIColor { var fromR: CGFloat = 0.0 var fromG: CGFloat = 0.0 @@ -87,6 +94,7 @@ public class MSGradientCircularSlider: MSCircularSlider { return UIColor(red: rR, green: rG, blue: rB, alpha: rA) } + /** Calculates the color for the current angle */ private func colorFor(percentage: Double) -> UIColor { let colorPercentageInterval = 100.0 / Double(gradientColors.count - 1) diff --git a/MSCircularSliderExample/MSCircularSliderExample.xcodeproj/project.xcworkspace/xcuserdata/mohamed.xcuserdatad/UserInterfaceState.xcuserstate b/MSCircularSliderExample/MSCircularSliderExample.xcodeproj/project.xcworkspace/xcuserdata/mohamed.xcuserdatad/UserInterfaceState.xcuserstate index 8164819..127afd8 100644 Binary files a/MSCircularSliderExample/MSCircularSliderExample.xcodeproj/project.xcworkspace/xcuserdata/mohamed.xcuserdatad/UserInterfaceState.xcuserstate and b/MSCircularSliderExample/MSCircularSliderExample.xcodeproj/project.xcworkspace/xcuserdata/mohamed.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/MSCircularSliderExample/MSCircularSliderExample/Base.lproj/Main.storyboard b/MSCircularSliderExample/MSCircularSliderExample/Base.lproj/Main.storyboard index 892af5f..aabe411 100644 --- a/MSCircularSliderExample/MSCircularSliderExample/Base.lproj/Main.storyboard +++ b/MSCircularSliderExample/MSCircularSliderExample/Base.lproj/Main.storyboard @@ -1,11 +1,11 @@ - + - + @@ -21,7 +21,7 @@ - + @@ -40,13 +40,13 @@