diff --git a/.swift-version b/.swift-version new file mode 100644 index 0000000..5186d07 --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +4.0 diff --git a/.swift-version.txt b/.swift-version.txt new file mode 100644 index 0000000..389f774 --- /dev/null +++ b/.swift-version.txt @@ -0,0 +1 @@ +4.0 \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..901fbd6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Mohamed Shahawy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MSCircularSlider.podspec b/MSCircularSlider.podspec new file mode 100644 index 0000000..841345f --- /dev/null +++ b/MSCircularSlider.podspec @@ -0,0 +1,16 @@ +Pod::Spec.new do |s| + s.name = 'MSCircularSlider' + s.version = '0.1.0' + s.license = { :type => 'MIT', :file => 'LICENSE' } + s.authors = { 'ThunderStruct' => 'mohamedshahawy@aucegypt.edu' } + s.summary = 'A full-featured circular slider for iOS applications' + s.homepage = 'https://github.com/ThunderStruct/MSCircularSlider' + + # Source Info + s.platform = :ios, '9.3' + s.source = { :git => 'https://github.com/ThunderStruct/MSCircularSlider.git', :tag => "0.1.0" } + s.source_files = 'MSCircularSlider/*.{swift}' + s.pod_target_xcconfig = { 'SWIFT_VERSION' => '4.0' } + + s.requires_arc = true +end diff --git a/MSCircularSlider/MSCircularSlider+IB.swift b/MSCircularSlider/MSCircularSlider+IB.swift new file mode 100644 index 0000000..778281c --- /dev/null +++ b/MSCircularSlider/MSCircularSlider+IB.swift @@ -0,0 +1,231 @@ +// +// MSCircularSlider+IB.swift +// +// Created by Mohamed Shahawy on 27/09/17. +// Copyright © 2017 Mohamed Shahawy. All rights reserved. +// + +import UIKit + +extension MSCircularSlider { + + //================================================================================ + // VALUE PROPERTIES + //================================================================================ + + @IBInspectable var _minimumValue: Double { + get { + return minimumValue + } + set { + minimumValue = newValue + } + } + + @IBInspectable var _maximumValue: Double { + get { + return maximumValue + } + set { + maximumValue = newValue + } + } + + @IBInspectable var _currentValue: Double { + get { + return currentValue + } + set { + currentValue = min(max(newValue, minimumValue), maximumValue) + } + } + + //================================================================================ + // SHAPE PROPERTIES + //================================================================================ + + @IBInspectable var _maximumAngle: CGFloat { + get { + return maximumAngle + } + set { + let modifiedNewValue = newValue < 0.0 ? 360.0 - (newValue.truncatingRemainder(dividingBy: 360.0)) : newValue + maximumAngle = modifiedNewValue < 360.0 ? modifiedNewValue : modifiedNewValue.truncatingRemainder(dividingBy: 360.0) + } + } + + @IBInspectable var _lineWidth: Int { + get { + return lineWidth + } + set { + lineWidth = newValue + } + } + + @IBInspectable var _filledColor: UIColor { + get { + return filledColor + } + set { + filledColor = newValue + } + } + + @IBInspectable var _unfilledColor: UIColor { + get { + return unfilledColor + } + set { + unfilledColor = newValue + } + } + + @IBInspectable var _rotationAngle: CGFloat { + get { + return rotationAngle ?? 0 as CGFloat + } + set { + rotationAngle = newValue + } + } + + //================================================================================ + // HANDLE PROPERTIES + //================================================================================ + + @IBInspectable var _handleType: Int { // Takes values from 0 to 3 only + get { + return handleType.rawValue + } + set { + if let temp = MSCircularSliderHandleType(rawValue: newValue) { + handleType = temp + } + } + } + + @IBInspectable var _handleColor: UIColor { + get { + return handleColor + } + set { + handleColor = newValue + } + } + + @IBInspectable var _handleEnlargementPoints: Int { + get { + return handleEnlargementPoints + } + set { + handleEnlargementPoints = newValue + } + } + + @IBInspectable var _handleHighlightable: Bool { + get { + return handleHighlightable + } + set { + handleHighlightable = newValue + } + } + + //================================================================================ + // LABELS PROPERTIES + //================================================================================ + + @IBInspectable var _commaSeparatedLabels: String { + get { + return labels.isEmpty ? "" : labels.joined(separator: ",") + } + set { + if !newValue.trimmingCharacters(in: .whitespaces).isEmpty { + + labels = newValue.components(separatedBy: ",") + } + } + } + + @IBInspectable var _labelFont: UIFont { + get { + return labelFont + } + set { + labelFont = newValue + } + } + + @IBInspectable var _labelColor: UIColor { + get { + return labelColor + } + set { + labelColor = newValue + } + } + + @IBInspectable var _labelOffset: CGFloat { + get { + return labelOffset + } + set { + labelOffset = newValue + } + } + + @IBInspectable var _snapToLabels: Bool { + get { + return snapToLabels + } + set { + snapToLabels = newValue + } + } + + //================================================================================ + // MARKERS PROPERTIES + //================================================================================ + + @IBInspectable var _markerCount: Int { + get { + return markerCount + } + set { + markerCount = max(0, newValue) + } + } + + @IBInspectable var _markerColor: UIColor { + get { + return markerColor + } + set { + markerColor = newValue + } + } + + @IBInspectable var _markerImage: UIImage { + get { + return markerImage ?? UIImage() + } + set { + markerImage = newValue + } + } + + @IBInspectable var _snapToMarkers: Bool { + get { + return snapToMarkers + } + set { + snapToMarkers = newValue + } + } + +} + + + + diff --git a/MSCircularSlider/MSCircularSlider.swift b/MSCircularSlider/MSCircularSlider.swift new file mode 100755 index 0000000..5a82f23 --- /dev/null +++ b/MSCircularSlider/MSCircularSlider.swift @@ -0,0 +1,776 @@ +// +// MSCircularSlider.swift +// +// Created by Mohamed Shahawy on 27/09/17. +// Copyright © 2017 Mohamed Shahawy. All rights reserved. +// + +/* + ADDED LIST + SemiTransparentBigCircle HandleType + DoubleCircle HandleType + Bigger BigCircle HandleWidth + Markers with custom color and count + + */ + +import UIKit +import QuartzCore + +internal protocol MSCircularSliderProtocol: class { + // Acts as an abstract class only - not to be used +} + +protocol MSCircularSliderDelegate: MSCircularSliderProtocol { + func circularSlider(_ slider: MSCircularSlider, valueChangedTo value: Double, fromUser: Bool) // fromUser indicates whether the value changed by sliding the handle (fromUser == true) or through other means (fromUser == false) + func circularSlider(_ slider: MSCircularSlider, startedTrackingWith value: Double) + func circularSlider(_ slider: MSCircularSlider, endedTrackingWith value: Double) +} + +extension MSCircularSliderDelegate { + // Optional Methods + func circularSlider(_ slider: MSCircularSlider, startedTrackingWith value: Double) {} + func circularSlider(_ slider: MSCircularSlider, endedTrackingWith value: Double) {} +} + +@IBDesignable +class MSCircularSlider: UIControl { + + //================================================================================ + // MEMBERS + //================================================================================ + + // DELEGATE + weak var delegate: MSCircularSliderProtocol? = nil + private weak var castDelegate: MSCircularSliderDelegate? { + get { + return delegate as? MSCircularSliderDelegate + } + set { + delegate = newValue + } + } + + // VALUE/ANGLE MEMBERS + var minimumValue: Double = 0.0 { + didSet { + setNeedsDisplay() + } + } + + var maximumValue: Double = 100.0 { + didSet { + setNeedsDisplay() + } + } + + var currentValue: Double { + set { + let val = min(max(minimumValue, newValue), maximumValue) + angle = angleFrom(value: val) + + castDelegate?.circularSlider(self, valueChangedTo: val, fromUser: false) + + sendActions(for: UIControlEvents.valueChanged) + + + setNeedsDisplay() + } get { + return valueFrom(angle: angle) + } + } + + var maximumAngle: CGFloat = 360.0 { // Full circle by default + didSet { + if maximumAngle > 360.0 { + print("maximumAngle \(maximumAngle) should be 360° or less - setting member to 360°") + maximumAngle = 360.0 + } + else if maximumAngle < 0 { + print("maximumAngle \(maximumAngle) should be 0° or more - setting member to 0°") + maximumAngle = 360.0 + } + + currentValue = valueFrom(angle: angle) + + setNeedsDisplay() + } + } + + var angle: CGFloat = 0 { + didSet { + angle = max(0, angle).truncatingRemainder(dividingBy: maximumAngle + 1) + } + } + + var rotationAngle: CGFloat? = nil { + didSet { + setNeedsUpdateConstraints() + setNeedsDisplay() + } + } + + private var radius: CGFloat = -1.0 { + didSet { + setNeedsUpdateConstraints() + setNeedsDisplay() + } + } + + // LINE MEMBERS + var lineWidth: Int = 5 { + didSet { + setNeedsUpdateConstraints() + invalidateIntrinsicContentSize() + setNeedsDisplay() + } + } + + + var filledColor: UIColor = .darkGray { + didSet { + setNeedsDisplay() + } + } + + + var unfilledColor: UIColor = .lightGray { + didSet { + setNeedsDisplay() + } + } + + var unfilledLineCap: CGLineCap = .round { + didSet { + setNeedsDisplay() + } + } + + var filledLineCap: CGLineCap = .round { + didSet { + setNeedsDisplay() + } + } + + // HANDLE MEMBERS + let handle = MSCircularSliderHandle() + + var handleColor: UIColor = .darkGray { + didSet { + setNeedsDisplay() + } + } + + var handleType: MSCircularSliderHandleType = .LargeCircle { + didSet { + setNeedsUpdateConstraints() + setNeedsDisplay() + } + } + + var handleEnlargementPoints: Int = 10 { + didSet { + setNeedsUpdateConstraints() + setNeedsDisplay() + } + } + + var handleHighlightable: Bool = true { + didSet { + handle.isHighlightable = handleHighlightable + setNeedsDisplay() + } + } + + // LABEL MEMBERS + var labels: [String] = [] { // All labels are evenly spaced + didSet { + setNeedsUpdateConstraints() + setNeedsDisplay() + } + } + + var snapToLabels: Bool = false { // The 'snap' occurs on touchUp + didSet { + setNeedsUpdateConstraints() + setNeedsDisplay() + } + } + + var labelFont: UIFont = .systemFont(ofSize: 12.0) { + didSet { + setNeedsDisplay() + } + } + + var labelColor: UIColor = .black { + didSet { + setNeedsDisplay() + } + } + + var labelOffset: CGFloat = 0 { // Negative values move the labels closer to the center + didSet { + setNeedsUpdateConstraints() + setNeedsDisplay() + } + } + + private var labelInwardsDistance: CGFloat { + return 0.1 * -(radius) - 0.5 * CGFloat(lineWidth) - 0.5 * labelFont.pointSize + } + + // MARKER MEMBERS + var markerCount: Int = 0 { // All markers are evenly spaced + didSet { + markerCount = max(markerCount, 0) + setNeedsUpdateConstraints() + setNeedsDisplay() + } + } + + var markerColor: UIColor = .darkGray { + didSet { + setNeedsDisplay() + } + } + + var markerPath: UIBezierPath? = nil { // Takes precedence over markerImage + didSet { + setNeedsUpdateConstraints() + setNeedsDisplay() + } + } + + var markerImage: UIImage? = nil { // Mutually-exclusive with markerPath + didSet { + setNeedsUpdateConstraints() + setNeedsDisplay() + } + } + + var snapToMarkers: Bool = false { // The 'snap' occurs on touchUp + didSet { + setNeedsUpdateConstraints() + setNeedsDisplay() + } + } + + // CALCULATED MEMBERS + var calculatedRadius: CGFloat { + if (radius == -1.0) { + let minimumSize = min(bounds.size.height, bounds.size.width) + let halfLineWidth = ceilf(Float(lineWidth) / 2.0) + let halfHandleWidth = ceilf(Float(handleDiameter) / 2.0) + return minimumSize * 0.5 - CGFloat(max(halfHandleWidth, halfLineWidth)) + } + return radius + } + + internal var centerPoint: CGPoint { + return CGPoint(x: bounds.size.width * 0.5, y: bounds.size.height * 0.5) + } + + 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 + //================================================================================ + + func addLabel(_ string: String) { + labels.append(string) + + setNeedsUpdateConstraints() + setNeedsDisplay() + } + + func changeLabel(at index: Int, string: String) { + assert(labels.count > index && index >= 0, "label index out of bounds") + labels[index] = string + + setNeedsUpdateConstraints() + setNeedsDisplay() + } + + func removeLabel(at index: Int) { + assert(labels.count > index && index >= 0, "label index out of bounds") + labels.remove(at: index) + + setNeedsUpdateConstraints() + setNeedsDisplay() + } + + //================================================================================ + // VIRTUAL METHODS + //================================================================================ + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + backgroundColor = .clear + } + + override var intrinsicContentSize: CGSize { + let diameter = radius * 2 + let handleRadius = ceilf(Float(handleDiameter) / 2.0) + + let totalWidth = diameter + CGFloat(2 * max(handleRadius, ceilf(Float(lineWidth) / 2.0))) + + return CGSize(width: totalWidth, height: totalWidth) + } + + override func draw(_ rect: CGRect) { + super.draw(rect) + let ctx = UIGraphicsGetCurrentContext() + + // Draw filled and unfilled lines + drawLine(ctx: ctx!) + + // Draw markings + drawMarkings(ctx: ctx!) + + // Draw handle + let handleCenter = pointOnCircleAt(angle: angle) + handle.frame = drawHandle(ctx: ctx!, atPoint: handleCenter, handle: handle) + + // Draw labels + drawLabels(ctx: ctx!) + + // Rotate slider + self.transform = getRotationalTransform() + for view in subviews { // cancel rotation on all subviews added by the user + view.transform = getRotationalTransform().inverted() + } + + } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + guard event != nil else { + return false + } + + if pointInsideHandle(point, handleCenter: pointOnCircleAt(angle: angle)) { + + return true + } + else { + return pointInsideCircle(point) + } + } + + override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + let location = touch.location(in: self) + + if pointInsideHandle(location, handleCenter: pointOnCircleAt(angle: angle)) { + handle.isPressed = true + castDelegate?.circularSlider(self, startedTrackingWith: currentValue) + setNeedsDisplay() + return true + } + + return pointInsideCircle(location) + } + + override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + let lastPoint = touch.location(in: self) + let lastAngle = floor(calculateAngle(from: centerPoint, to: lastPoint)) + + moveHandle(newAngle: lastAngle) + + castDelegate?.circularSlider(self, valueChangedTo: currentValue, fromUser: true) + + sendActions(for: UIControlEvents.valueChanged) + + return true + } + + override func endTracking(_ touch: UITouch?, with event: UIEvent?) { + super.endTracking(touch, with: event) + + castDelegate?.circularSlider(self, endedTrackingWith: currentValue) + snapHandle() + + handle.isPressed = false + + setNeedsDisplay() + } + + //================================================================================ + // DRAWING METHODS + //================================================================================ + + internal func drawLine(ctx: CGContext) { + unfilledColor.set() + // Draw unfilled circle + drawUnfilledCircle(ctx: ctx, center: centerPoint, radius: calculatedRadius, lineWidth: CGFloat(lineWidth), maximumAngle: maximumAngle, lineCap: unfilledLineCap) + + filledColor.set() + // Draw filled circle + 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 + } + + private func drawLabels(ctx: CGContext) { + if labels.count > 0 { + let attributes = [NSAttributedStringKey.font: labelFont, NSAttributedStringKey.foregroundColor: labelColor] as [NSAttributedStringKey : Any] + + for i in 0 ..< labels.count { + let label = labels[i] as NSString + let labelFrame = frameForLabelAt(i) + + ctx.saveGState() + + // Invert transform to cancel rotation on labels + ctx.concatenate(CGAffineTransform(translationX: labelFrame.origin.x + (labelFrame.width / 2), + y: labelFrame.origin.y + (labelFrame.height / 2))) + ctx.concatenate(getRotationalTransform().inverted()) + ctx.concatenate(CGAffineTransform(translationX: -(labelFrame.origin.x + (labelFrame.width / 2)), + y: -(labelFrame.origin.y + (labelFrame.height / 2)))) + + // Draw label + label.draw(in: labelFrame, withAttributes: attributes) + + ctx.restoreGState() + } + } + } + + private func drawMarkings(ctx: CGContext) { + for i in 0 ..< markerCount { + let markFrame = frameForMarkingAt(i) + + ctx.saveGState() + + ctx.concatenate(CGAffineTransform(translationX: markFrame.origin.x + (markFrame.width / 2), + y: markFrame.origin.y + (markFrame.height / 2))) + ctx.concatenate(getRotationalTransform().inverted()) + ctx.concatenate(CGAffineTransform(translationX: -(markFrame.origin.x + (markFrame.width / 2)), + y: -(markFrame.origin.y + (markFrame.height / 2)))) + + if self.markerPath != nil { + markerColor.setFill() + markerPath?.fill() + } + else if self.markerImage != nil { + self.markerImage?.draw(in: markFrame) + } + else { + let markPath = UIBezierPath(ovalIn: markFrame) + markerColor.setFill() + markPath.fill() + } + + ctx.restoreGState() + } + } + + @discardableResult + private 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) + ctx.fillEllipse(in: frame) + return frame + } + + 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) + } + + 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))) + + ctx.addArc(center: center, radius: radius, startAngle: CGFloat(cartesianFromAngle), endAngle: CGFloat(cartesianToAngle), clockwise: false) + + ctx.setLineWidth(lineWidth) + ctx.setLineCap(lineCap) + ctx.drawPath(using: CGPathDrawingMode.stroke) + } + + //================================================================================ + // CALCULATION METHODS + //================================================================================ + + 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)))) + vector.x /= magnitude + vector.y /= magnitude + let cartesianRad = Double(atan2(vector.y, vector.x)) + + var compassRad = toCompass(cartesianRad) + + if (compassRad < 0) { + compassRad += (2 * Double.pi) + } + + assert(compassRad >= 0 && compassRad <= 2 * Double.pi, "angle must be positive") + return CGFloat(toDeg(compassRad)) + } + + private func pointOn(radius: CGFloat, angle: CGFloat) -> CGPoint { + var result = CGPoint() + + let cartesianAngle = CGFloat(toCartesian(toRad(Double(angle)))) + result.y = round(radius * sin(cartesianAngle)) + result.x = round(radius * cos(cartesianAngle)) + + return result + } + + 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) + } + + private func frameForMarkingAt(_ index: Int) -> CGRect { + var percentageAlongCircle: CGFloat! + + // Calculate degrees for marking + percentageAlongCircle = fullCircle ? ((100.0 / CGFloat(markerCount)) * CGFloat(index)) / 100.0 : ((100.0 / CGFloat(markerCount - 1)) * CGFloat(index)) / 100.0 + + + let markerDegrees = percentageAlongCircle * maximumAngle + let pointOnCircle = pointOnCircleAt(angle: markerDegrees) + + let markSize = CGSize(width: ((CGFloat(lineWidth) + handleDiameter) / CGFloat(2)), + height: ((CGFloat(lineWidth) + handleDiameter) / CGFloat(2))) + + // center along line + let offsetFromCircle = CGPoint(x: -markSize.width / 2.0, + y: -markSize.height / 2.0) + + return CGRect(x: pointOnCircle.x + offsetFromCircle.x, + y: pointOnCircle.y + offsetFromCircle.y, + width: markSize.width, + height: markSize.height) + } + + private func frameForLabelAt(_ index: Int) -> CGRect { + let label = labels[index] + var percentageAlongCircle: CGFloat! + + // calculate degrees for label + 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 + let pointOnCircle = pointOnCircleAt(angle: labelDegrees) + + let labelSize = sizeOf(string: label, withFont: labelFont) + let offsetFromCircle = offsetForLabelAt(index: index, withSize: labelSize) + + return CGRect(x: pointOnCircle.x + offsetFromCircle.x, + y: pointOnCircle.y + offsetFromCircle.y, + width: labelSize.width, + height: labelSize.height) + } + + 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 + + let radialDistance = labelInwardsDistance + labelOffset + let inwardOffset = pointOn(radius: radialDistance, angle: CGFloat(labelDegrees)) + + return CGPoint(x: -labelSize.width * 0.5 + inwardOffset.x, y: -labelSize.height * 0.5 + inwardOffset.y) + } + + private func degreesFor(arcLength: CGFloat, onCircleWithRadius radius: CGFloat, withMaximumAngle degrees: CGFloat) -> CGFloat { + let totalCircumference = CGFloat(2 * Double.pi) * radius + + let arcRatioToCircumference = arcLength / totalCircumference + + return degrees * arcRatioToCircumference + } + + private func pointInsideCircle(_ point: CGPoint) -> Bool { + let p1 = centerPoint + let p2 = point + let xDist = p2.x - p1.x + let yDist = p2.y - p1.y + let distance = sqrt((xDist * xDist) + (yDist * yDist)) + 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 + //================================================================================ + + private func moveHandle(newAngle: CGFloat) { + if newAngle > maximumAngle { // for incomplete circles + if newAngle > maximumAngle + (360 - maximumAngle) / 2.0 { + angle = 0 + setNeedsDisplay() + } + else { + angle = maximumAngle + setNeedsDisplay() + } + } + else { + angle = newAngle + } + setNeedsDisplay() + } + + 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 { + fixedAngle = -angle + } + else { + fixedAngle = maximumAngle - angle + } + + if snapToLabels { + var minDist = maximumAngle + var newAngle = 0.0 as CGFloat + + for i in 0 ..< labels.count + 1 { + let percentageAlongCircle = Double(i) / Double(labels.count - (fullCircle ? 0 : 1)) + let degreesToLbl = CGFloat(percentageAlongCircle) * maximumAngle + if abs(fixedAngle - degreesToLbl) < minDist { + newAngle = degreesToLbl != 0 || !fullCircle ? maximumAngle - degreesToLbl : 0 + minDist = abs(fixedAngle - degreesToLbl) + } + + } + + currentValue = valueFrom(angle: newAngle) + } + + if snapToMarkers { + var minDist = maximumAngle + var newAngle = 0.0 as CGFloat + + for i in 0 ..< markerCount + 1 { + let percentageAlongCircle = Double(i) / Double(markerCount - (fullCircle ? 0 : 1)) + let degreesToMarker = CGFloat(percentageAlongCircle) * maximumAngle + if abs(fixedAngle - degreesToMarker) < minDist { + newAngle = degreesToMarker != 0 || !fullCircle ? maximumAngle - degreesToMarker : 0 + minDist = abs(fixedAngle - degreesToMarker) + } + + } + + currentValue = valueFrom(angle: newAngle) + } + + setNeedsDisplay() + + } + + //================================================================================ + // SUPPORT METHODS + //================================================================================ + + internal func angleFrom(value: Double) -> CGFloat { + return (CGFloat(value) * maximumAngle) / CGFloat(maximumValue - minimumValue) + } + + internal func valueFrom(angle: CGFloat) -> Double { + return (maximumValue - minimumValue) * Double(angle) / Double(maximumAngle) + } + + private func toRad(_ degrees: Double) -> Double { + return ((Double.pi * degrees) / 180.0) + } + + private func toDeg(_ radians: Double) -> Double { + return ((180.0 * radians) / Double.pi) + } + + internal func square(_ value: Double) -> Double { + return value * value + } + + private func toCompass(_ cartesianRad: Double) -> Double { + return cartesianRad + (Double.pi / 2) + } + + private func toCartesian(_ compassRad: Double) -> Double { + return compassRad - (Double.pi / 2) + } + + private func sizeOf(string: String, withFont font: UIFont) -> CGSize { + let attributes = [NSAttributedStringKey.font: font] + return NSAttributedString(string: string, attributes: attributes).size() + } + + func getRotationalTransform() -> CGAffineTransform { + if fullCircle { + // No rotation required + let transform = CGAffineTransform.identity.rotated(by: CGFloat(0)) + return transform + } + else { + + if let rotation = self.rotationAngle { + return CGAffineTransform.identity.rotated(by: CGFloat(toRad(Double(rotation)))) + } + + let radians = Double(-(maximumAngle / 2)) / 180.0 * Double.pi + let transform = CGAffineTransform.identity.rotated(by: CGFloat(radians)) + return transform + } + } + + + +} + + diff --git a/MSCircularSlider/MSCircularSliderHandle.swift b/MSCircularSlider/MSCircularSliderHandle.swift new file mode 100755 index 0000000..115fdd3 --- /dev/null +++ b/MSCircularSlider/MSCircularSliderHandle.swift @@ -0,0 +1,36 @@ +// +// MSCircularSliderHandle.swift +// +// Created by Mohamed Shahawy on 27/09/17. +// Copyright © 2017 Mohamed Shahawy. All rights reserved. +// + +import UIKit + +enum MSCircularSliderHandleType: Int, RawRepresentable { + case SmallCircle = 0, + MediumCircle, + LargeCircle, + DoubleCircle // Semitransparent big circle with a nested small circle +} + +@IBDesignable +class MSCircularSliderHandle: CALayer { + + //================================================================================ + // MEMBERS + //================================================================================ + + internal var isPressed: Bool = false { + didSet { + superlayer?.needsDisplay() + } + } + + internal var isHighlightable: Bool = true { + didSet { + superlayer?.needsDisplay() + } + } + +} diff --git a/MSCircularSlider/MSDoubleHandleCircularSlider+IB.swift b/MSCircularSlider/MSDoubleHandleCircularSlider+IB.swift new file mode 100644 index 0000000..bf1cee4 --- /dev/null +++ b/MSCircularSlider/MSDoubleHandleCircularSlider+IB.swift @@ -0,0 +1,34 @@ +// +// MSDoubleHandleCircularSlider+IB.swift +// MoodiTrack +// +// Created by Mohamed Shahawy on 9/30/17. +// Copyright © 2017 Mohamed Shahawy. All rights reserved. +// + +import UIKit + +extension MSDoubleHandleCircularSlider { + + //================================================================================ + // SECOND HANDLE PROPERTIES + //================================================================================ + + @IBInspectable var _minimumHandlesDistance: CGFloat { + get { + return minimumHandlesDistance + } + set { + minimumHandlesDistance = newValue + } + } + + @IBInspectable var _secondCurrentValue: Double { + get { + return secondCurrentValue + } + set { + secondCurrentValue = newValue + } + } +} diff --git a/MSCircularSlider/MSDoubleHandleCircularSlider.swift b/MSCircularSlider/MSDoubleHandleCircularSlider.swift new file mode 100755 index 0000000..a354660 --- /dev/null +++ b/MSCircularSlider/MSDoubleHandleCircularSlider.swift @@ -0,0 +1,266 @@ +// +// MSDoubleHandleCircularSlider.swift +// +// Created by Mohamed Shahawy on 27/09/17. +// Copyright © 2017 Mohamed Shahawy. All rights reserved. +// + +import UIKit + +protocol MSDoubleHandleCircularSliderDelegate: MSCircularSliderProtocol { + func circularSlider(_ slider: MSCircularSlider, valueChangedTo firstValue: Double, secondValue: Double, isFirstHandle: Bool?, fromUser: Bool) // fromUser indicates whether the value changed by sliding the handle (fromUser == true) or through other means (fromUser == false, isFirstHandle == nil) + func circularSlider(_ slider: MSCircularSlider, startedTrackingWith firstValue: Double, secondValue: Double, isFirstHandle: Bool) + func circularSlider(_ slider: MSCircularSlider, endedTrackingWith firstValue: Double, secondValue: Double, isFirstHandle: Bool) +} + +extension MSDoubleHandleCircularSliderDelegate { + // Optional Methods + func circularSlider(_ slider: MSCircularSlider, startedTrackingWith firstValue: Double, secondValue: Double, isFirstHandle: Bool) {} + func circularSlider(_ slider: MSCircularSlider, endedTrackingWith firstValue: Double, secondValue: Double, isFirstHandle: Bool) {} +} + +@IBDesignable +class MSDoubleHandleCircularSlider: MSCircularSlider { + + //================================================================================ + // MEMBERS + //================================================================================ + + // DELEGATE + private weak var castDelegate: MSDoubleHandleCircularSliderDelegate? { + get { + return delegate as? MSDoubleHandleCircularSliderDelegate + } + set { + delegate = newValue + } + } + + + // SECOND HANDLE'S PROPERTIES + var minimumHandlesDistance: CGFloat = 10 { // distance between handles + didSet { + let maxValue = CGFloat.pi * calculatedRadius * maximumAngle / 360.0 + + if minimumHandlesDistance < 1 { + print("minimumHandlesDistance \(minimumHandlesDistance) should be 1 or more - setting member to 1") + minimumHandlesDistance = 1 + } + else if minimumHandlesDistance > maxValue { + print("minimumHandlesDistance \(minimumHandlesDistance) should be \(maxValue) or less - setting member to \(maxValue)") + minimumHandlesDistance = maxValue + } + } + } + + override var handleHighlightable: Bool { + didSet { + secondHandle.isHighlightable = handleHighlightable + setNeedsDisplay() + } + } + + var secondCurrentValue: Double { // second handle's value + 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) + } + get { + return valueFrom(angle: secondAngle) + } + } + + var secondAngle: CGFloat = 60 { + didSet { + //assert(secondAngle >= 0 && secondAngle <= 360, "secondAngle \(secondAngle) must be between 0 and 360 inclusive") + secondAngle = max(0.0, secondAngle).truncatingRemainder(dividingBy: maximumAngle + 1) + } + } + + let secondHandle = MSCircularSliderHandle() + + // OVERRIDDEN MEMBERS + override var maximumAngle: CGFloat { + didSet { + // to account for dynamic maximumAngle changes + secondCurrentValue = valueFrom(angle: secondAngle) + } + } + + @available(*, unavailable, message: "this feature is not implemented yet") + override var snapToLabels: Bool { + set { + + } + get { + return false + } + } + + @available(*, unavailable, message: "this feature is not implemented yet") + override var snapToMarkers: Bool { + set { + + } + get { + return false + } + } + + //================================================================================ + // VIRTUAL METHODS + //================================================================================ + + override func draw(_ rect: CGRect) { + super.draw(rect) + let ctx = UIGraphicsGetCurrentContext() + + // Draw the second handle + let handleCenter = super.pointOnCircleAt(angle: secondAngle) + secondHandle.frame = self.drawHandle(ctx: ctx!, atPoint: handleCenter, handle: secondHandle) + } + + override func drawLine(ctx: CGContext) { + unfilledColor.set() + // Draw unfilled circle + drawUnfilledCircle(ctx: ctx, center: centerPoint, radius: calculatedRadius, lineWidth: CGFloat(lineWidth), maximumAngle: maximumAngle, lineCap: unfilledLineCap) + + filledColor.set() + // Draw filled circle + drawArc(ctx: ctx, center: centerPoint, radius: calculatedRadius, lineWidth: CGFloat(lineWidth), fromAngle: CGFloat(angle), toAngle: CGFloat(secondAngle), lineCap: filledLineCap) + } + + override 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) { + handle.isPressed = true + } + if pointInsideHandle(location, handleCenter: secondHandleCenter) { + 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))) { + // first handle is closer + secondHandle.isPressed = false + } + else { + // second handle is closer + handle.isPressed = false + } + } + + if secondHandle.isPressed || handle.isPressed { + castDelegate?.circularSlider(self, startedTrackingWith: currentValue, secondValue: secondCurrentValue, isFirstHandle: handle.isPressed) + + setNeedsDisplay() + return true + } + return false + } + + override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + let point = touch.location(in: self) + let newAngle = floor(calculateAngle(from: centerPoint, to: point)) + + if handle.isPressed { + moveFirstHandleTo(newAngle) + } + else if secondHandle.isPressed { + moveSecondHandleTo(newAngle) + } + + castDelegate?.circularSlider(self, valueChangedTo: currentValue, secondValue: secondCurrentValue, isFirstHandle: handle.isPressed, fromUser: false) + + sendActions(for: UIControlEvents.valueChanged) + + return true + } + + override func endTracking(_ touch: UITouch?, with event: UIEvent?) { + castDelegate?.circularSlider(self, endedTrackingWith: currentValue, secondValue: secondCurrentValue, isFirstHandle: handle.isPressed) + + handle.isPressed = false + secondHandle.isPressed = false + + setNeedsDisplay() + + // TODO: + // Snap To Labels/Markings future feature + } + + //================================================================================ + // DRAWING METHODS + //================================================================================ + + override func drawHandle(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 + + return super.drawHandle(ctx: ctx, atPoint: handleCenter, handle: handle) + } + + //================================================================================ + // HANDLE-MOVING METHODS + //================================================================================ + 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)))) + let circleDiameter = calculatedRadius * 2.0 + let circularDistance = circleDiameter * asin(straightDistance / circleDiameter) + return circularDistance + } + + private func moveFirstHandleTo(_ newAngle: CGFloat) { + let center = pointOnCircleAt(angle: newAngle) + let radius = handleDiameter / 2.0 + let newHandleFrame = CGRect(x: center.x - radius, y: center.y - radius, width: 2 * radius, height: 2 * radius) + + if !fullCircle && newAngle > secondAngle { + // will cross over the open part of the arc + return + } + + if distanceBetweenHandles(newHandleFrame, secondHandle.frame) < minimumHandlesDistance + handleDiameter { + // will cross the minimumHandlesDistance - no changes + return + } + + angle = newAngle + setNeedsDisplay() + } + + private func moveSecondHandleTo(_ newAngle: CGFloat) { + let center = pointOnCircleAt(angle: newAngle) + let radius = handleDiameter / 2.0 + let newHandleFrame = CGRect(x: center.x - radius, y: center.y - radius, width: 2 * radius, height: 2 * radius) + + if !fullCircle && newAngle > maximumAngle { + // will cross over the open part of the arc + return + } + + if distanceBetweenHandles(newHandleFrame, handle.frame) < minimumHandlesDistance + handleDiameter { + // will cross the minimumHandlesDistance - no changes + return + } + secondAngle = newAngle + setNeedsDisplay() + + } + +} + + + diff --git a/MSCircularSlider/MSGradientCircularSlider+IB.swift b/MSCircularSlider/MSGradientCircularSlider+IB.swift new file mode 100644 index 0000000..6b1dbf9 --- /dev/null +++ b/MSCircularSlider/MSGradientCircularSlider+IB.swift @@ -0,0 +1,46 @@ +// +// MSGradientCircularSlider+IB.swift +// MSCircularSliderExample +// +// Created by Mohamed Shahawy on 10/2/17. +// Copyright © 2017 Mohamed Shahawy. All rights reserved. +// + +import UIKit + +extension MSGradientCircularSlider { + + //================================================================================ + // GRADIENT COLORS PROPERTIES + //================================================================================ + + @IBInspectable var _firstGradientColor: UIColor { + get { + return gradientColors[0] + } + set { + gradientColors[0] = newValue + } + } + + @IBInspectable var _secondGradientColor: UIColor { + get { + return gradientColors[1] + } + set { + gradientColors[1] = newValue + } + } + + @IBInspectable var _thirdGradientColor: UIColor { + get { + return gradientColors[2] + } + set { + gradientColors[2] = newValue + } + } + + // More colors can be added programatically + +} diff --git a/MSCircularSlider/MSGradientCircularSlider.swift b/MSCircularSlider/MSGradientCircularSlider.swift new file mode 100755 index 0000000..d794798 --- /dev/null +++ b/MSCircularSlider/MSGradientCircularSlider.swift @@ -0,0 +1,105 @@ +// +// MSGradientCircularSlider.swift +// +// Created by Mohamed Shahawy on 27/09/17. +// Copyright © 2017 Mohamed Shahawy. All rights reserved. +// + +import UIKit + +@IBDesignable +class MSGradientCircularSlider: MSCircularSlider { + + // Gradient colors array + var gradientColors: [UIColor] = [.lightGray, .blue, .darkGray] { + didSet { + setNeedsDisplay() + } + } + + override var angle: CGFloat { + didSet { + let anglePercentage = Double(angle) * 100.0 / Double(maximumAngle) + filledColor = colorFor(percentage: anglePercentage) + + setNeedsDisplay() + } + } + + //================================================================================ + // SETTER METHODS + //================================================================================ + + func addColor(_ color: UIColor) { + gradientColors.append(color) + + let anglePercentage = Double(angle) * 100.0 / Double(maximumAngle) + filledColor = colorFor(percentage: anglePercentage) + setNeedsDisplay() + } + + func changeColor(at index: Int, newColor: UIColor) { + assert(gradientColors.count > index && index >= 0, "gradient color index out of bounds") + gradientColors[index] = newColor + + let anglePercentage = Double(angle) * 100.0 / Double(maximumAngle) + filledColor = colorFor(percentage: anglePercentage) + setNeedsDisplay() + } + + 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") + gradientColors.remove(at: index) + + let anglePercentage = Double(angle) * 100.0 / Double(maximumAngle) + filledColor = colorFor(percentage: anglePercentage) + setNeedsDisplay() + } + + //================================================================================ + // SUPPORT METHODS + //================================================================================ + + private func blend(from: UIColor, to: UIColor, percentage: Double) -> UIColor { + var fromR: CGFloat = 0.0 + var fromG: CGFloat = 0.0 + var fromB: CGFloat = 0.0 + var fromA: CGFloat = 0.0 + var toR: CGFloat = 0.0 + var toG: CGFloat = 0.0 + var toB: CGFloat = 0.0 + var toA: CGFloat = 0.0 + + from.getRed(&fromR, green: &fromG, blue: &fromB, alpha: &fromA) + to.getRed(&toR, green: &toG, blue: &toB, alpha: &toA) + + let dR = toR - fromR + let dG = toG - fromG + let dB = toB - fromB + let dA = toA - fromA + + let rR = fromR + dR * CGFloat(percentage) + let rG = fromG + dG * CGFloat(percentage) + let rB = fromB + dB * CGFloat(percentage) + let rA = fromA + dA * CGFloat(percentage) + + return UIColor(red: rR, green: rG, blue: rB, alpha: rA) + } + + private func colorFor(percentage: Double) -> UIColor { + let colorPercentageInterval = 100.0 / Double(gradientColors.count - 1) + + let currentInterval = percentage / colorPercentageInterval - (percentage == 100 ? 1 : 0) + + let intervalPercentage = currentInterval - Double(Int(currentInterval)) // how far along between two colors + + return blend(from: gradientColors[Int(floor(currentInterval))], + to: gradientColors[min(Int(floor(currentInterval + 1)), gradientColors.count - 1)], + percentage: intervalPercentage) + + } +} + + + diff --git a/MSCircularSliderExample/MSCircularSlider/MSCircularSlider+IB.swift b/MSCircularSliderExample/MSCircularSlider/MSCircularSlider+IB.swift new file mode 100644 index 0000000..778281c --- /dev/null +++ b/MSCircularSliderExample/MSCircularSlider/MSCircularSlider+IB.swift @@ -0,0 +1,231 @@ +// +// MSCircularSlider+IB.swift +// +// Created by Mohamed Shahawy on 27/09/17. +// Copyright © 2017 Mohamed Shahawy. All rights reserved. +// + +import UIKit + +extension MSCircularSlider { + + //================================================================================ + // VALUE PROPERTIES + //================================================================================ + + @IBInspectable var _minimumValue: Double { + get { + return minimumValue + } + set { + minimumValue = newValue + } + } + + @IBInspectable var _maximumValue: Double { + get { + return maximumValue + } + set { + maximumValue = newValue + } + } + + @IBInspectable var _currentValue: Double { + get { + return currentValue + } + set { + currentValue = min(max(newValue, minimumValue), maximumValue) + } + } + + //================================================================================ + // SHAPE PROPERTIES + //================================================================================ + + @IBInspectable var _maximumAngle: CGFloat { + get { + return maximumAngle + } + set { + let modifiedNewValue = newValue < 0.0 ? 360.0 - (newValue.truncatingRemainder(dividingBy: 360.0)) : newValue + maximumAngle = modifiedNewValue < 360.0 ? modifiedNewValue : modifiedNewValue.truncatingRemainder(dividingBy: 360.0) + } + } + + @IBInspectable var _lineWidth: Int { + get { + return lineWidth + } + set { + lineWidth = newValue + } + } + + @IBInspectable var _filledColor: UIColor { + get { + return filledColor + } + set { + filledColor = newValue + } + } + + @IBInspectable var _unfilledColor: UIColor { + get { + return unfilledColor + } + set { + unfilledColor = newValue + } + } + + @IBInspectable var _rotationAngle: CGFloat { + get { + return rotationAngle ?? 0 as CGFloat + } + set { + rotationAngle = newValue + } + } + + //================================================================================ + // HANDLE PROPERTIES + //================================================================================ + + @IBInspectable var _handleType: Int { // Takes values from 0 to 3 only + get { + return handleType.rawValue + } + set { + if let temp = MSCircularSliderHandleType(rawValue: newValue) { + handleType = temp + } + } + } + + @IBInspectable var _handleColor: UIColor { + get { + return handleColor + } + set { + handleColor = newValue + } + } + + @IBInspectable var _handleEnlargementPoints: Int { + get { + return handleEnlargementPoints + } + set { + handleEnlargementPoints = newValue + } + } + + @IBInspectable var _handleHighlightable: Bool { + get { + return handleHighlightable + } + set { + handleHighlightable = newValue + } + } + + //================================================================================ + // LABELS PROPERTIES + //================================================================================ + + @IBInspectable var _commaSeparatedLabels: String { + get { + return labels.isEmpty ? "" : labels.joined(separator: ",") + } + set { + if !newValue.trimmingCharacters(in: .whitespaces).isEmpty { + + labels = newValue.components(separatedBy: ",") + } + } + } + + @IBInspectable var _labelFont: UIFont { + get { + return labelFont + } + set { + labelFont = newValue + } + } + + @IBInspectable var _labelColor: UIColor { + get { + return labelColor + } + set { + labelColor = newValue + } + } + + @IBInspectable var _labelOffset: CGFloat { + get { + return labelOffset + } + set { + labelOffset = newValue + } + } + + @IBInspectable var _snapToLabels: Bool { + get { + return snapToLabels + } + set { + snapToLabels = newValue + } + } + + //================================================================================ + // MARKERS PROPERTIES + //================================================================================ + + @IBInspectable var _markerCount: Int { + get { + return markerCount + } + set { + markerCount = max(0, newValue) + } + } + + @IBInspectable var _markerColor: UIColor { + get { + return markerColor + } + set { + markerColor = newValue + } + } + + @IBInspectable var _markerImage: UIImage { + get { + return markerImage ?? UIImage() + } + set { + markerImage = newValue + } + } + + @IBInspectable var _snapToMarkers: Bool { + get { + return snapToMarkers + } + set { + snapToMarkers = newValue + } + } + +} + + + + diff --git a/MSCircularSliderExample/MSCircularSlider/MSCircularSlider.swift b/MSCircularSliderExample/MSCircularSlider/MSCircularSlider.swift new file mode 100755 index 0000000..5a82f23 --- /dev/null +++ b/MSCircularSliderExample/MSCircularSlider/MSCircularSlider.swift @@ -0,0 +1,776 @@ +// +// MSCircularSlider.swift +// +// Created by Mohamed Shahawy on 27/09/17. +// Copyright © 2017 Mohamed Shahawy. All rights reserved. +// + +/* + ADDED LIST + SemiTransparentBigCircle HandleType + DoubleCircle HandleType + Bigger BigCircle HandleWidth + Markers with custom color and count + + */ + +import UIKit +import QuartzCore + +internal protocol MSCircularSliderProtocol: class { + // Acts as an abstract class only - not to be used +} + +protocol MSCircularSliderDelegate: MSCircularSliderProtocol { + func circularSlider(_ slider: MSCircularSlider, valueChangedTo value: Double, fromUser: Bool) // fromUser indicates whether the value changed by sliding the handle (fromUser == true) or through other means (fromUser == false) + func circularSlider(_ slider: MSCircularSlider, startedTrackingWith value: Double) + func circularSlider(_ slider: MSCircularSlider, endedTrackingWith value: Double) +} + +extension MSCircularSliderDelegate { + // Optional Methods + func circularSlider(_ slider: MSCircularSlider, startedTrackingWith value: Double) {} + func circularSlider(_ slider: MSCircularSlider, endedTrackingWith value: Double) {} +} + +@IBDesignable +class MSCircularSlider: UIControl { + + //================================================================================ + // MEMBERS + //================================================================================ + + // DELEGATE + weak var delegate: MSCircularSliderProtocol? = nil + private weak var castDelegate: MSCircularSliderDelegate? { + get { + return delegate as? MSCircularSliderDelegate + } + set { + delegate = newValue + } + } + + // VALUE/ANGLE MEMBERS + var minimumValue: Double = 0.0 { + didSet { + setNeedsDisplay() + } + } + + var maximumValue: Double = 100.0 { + didSet { + setNeedsDisplay() + } + } + + var currentValue: Double { + set { + let val = min(max(minimumValue, newValue), maximumValue) + angle = angleFrom(value: val) + + castDelegate?.circularSlider(self, valueChangedTo: val, fromUser: false) + + sendActions(for: UIControlEvents.valueChanged) + + + setNeedsDisplay() + } get { + return valueFrom(angle: angle) + } + } + + var maximumAngle: CGFloat = 360.0 { // Full circle by default + didSet { + if maximumAngle > 360.0 { + print("maximumAngle \(maximumAngle) should be 360° or less - setting member to 360°") + maximumAngle = 360.0 + } + else if maximumAngle < 0 { + print("maximumAngle \(maximumAngle) should be 0° or more - setting member to 0°") + maximumAngle = 360.0 + } + + currentValue = valueFrom(angle: angle) + + setNeedsDisplay() + } + } + + var angle: CGFloat = 0 { + didSet { + angle = max(0, angle).truncatingRemainder(dividingBy: maximumAngle + 1) + } + } + + var rotationAngle: CGFloat? = nil { + didSet { + setNeedsUpdateConstraints() + setNeedsDisplay() + } + } + + private var radius: CGFloat = -1.0 { + didSet { + setNeedsUpdateConstraints() + setNeedsDisplay() + } + } + + // LINE MEMBERS + var lineWidth: Int = 5 { + didSet { + setNeedsUpdateConstraints() + invalidateIntrinsicContentSize() + setNeedsDisplay() + } + } + + + var filledColor: UIColor = .darkGray { + didSet { + setNeedsDisplay() + } + } + + + var unfilledColor: UIColor = .lightGray { + didSet { + setNeedsDisplay() + } + } + + var unfilledLineCap: CGLineCap = .round { + didSet { + setNeedsDisplay() + } + } + + var filledLineCap: CGLineCap = .round { + didSet { + setNeedsDisplay() + } + } + + // HANDLE MEMBERS + let handle = MSCircularSliderHandle() + + var handleColor: UIColor = .darkGray { + didSet { + setNeedsDisplay() + } + } + + var handleType: MSCircularSliderHandleType = .LargeCircle { + didSet { + setNeedsUpdateConstraints() + setNeedsDisplay() + } + } + + var handleEnlargementPoints: Int = 10 { + didSet { + setNeedsUpdateConstraints() + setNeedsDisplay() + } + } + + var handleHighlightable: Bool = true { + didSet { + handle.isHighlightable = handleHighlightable + setNeedsDisplay() + } + } + + // LABEL MEMBERS + var labels: [String] = [] { // All labels are evenly spaced + didSet { + setNeedsUpdateConstraints() + setNeedsDisplay() + } + } + + var snapToLabels: Bool = false { // The 'snap' occurs on touchUp + didSet { + setNeedsUpdateConstraints() + setNeedsDisplay() + } + } + + var labelFont: UIFont = .systemFont(ofSize: 12.0) { + didSet { + setNeedsDisplay() + } + } + + var labelColor: UIColor = .black { + didSet { + setNeedsDisplay() + } + } + + var labelOffset: CGFloat = 0 { // Negative values move the labels closer to the center + didSet { + setNeedsUpdateConstraints() + setNeedsDisplay() + } + } + + private var labelInwardsDistance: CGFloat { + return 0.1 * -(radius) - 0.5 * CGFloat(lineWidth) - 0.5 * labelFont.pointSize + } + + // MARKER MEMBERS + var markerCount: Int = 0 { // All markers are evenly spaced + didSet { + markerCount = max(markerCount, 0) + setNeedsUpdateConstraints() + setNeedsDisplay() + } + } + + var markerColor: UIColor = .darkGray { + didSet { + setNeedsDisplay() + } + } + + var markerPath: UIBezierPath? = nil { // Takes precedence over markerImage + didSet { + setNeedsUpdateConstraints() + setNeedsDisplay() + } + } + + var markerImage: UIImage? = nil { // Mutually-exclusive with markerPath + didSet { + setNeedsUpdateConstraints() + setNeedsDisplay() + } + } + + var snapToMarkers: Bool = false { // The 'snap' occurs on touchUp + didSet { + setNeedsUpdateConstraints() + setNeedsDisplay() + } + } + + // CALCULATED MEMBERS + var calculatedRadius: CGFloat { + if (radius == -1.0) { + let minimumSize = min(bounds.size.height, bounds.size.width) + let halfLineWidth = ceilf(Float(lineWidth) / 2.0) + let halfHandleWidth = ceilf(Float(handleDiameter) / 2.0) + return minimumSize * 0.5 - CGFloat(max(halfHandleWidth, halfLineWidth)) + } + return radius + } + + internal var centerPoint: CGPoint { + return CGPoint(x: bounds.size.width * 0.5, y: bounds.size.height * 0.5) + } + + 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 + //================================================================================ + + func addLabel(_ string: String) { + labels.append(string) + + setNeedsUpdateConstraints() + setNeedsDisplay() + } + + func changeLabel(at index: Int, string: String) { + assert(labels.count > index && index >= 0, "label index out of bounds") + labels[index] = string + + setNeedsUpdateConstraints() + setNeedsDisplay() + } + + func removeLabel(at index: Int) { + assert(labels.count > index && index >= 0, "label index out of bounds") + labels.remove(at: index) + + setNeedsUpdateConstraints() + setNeedsDisplay() + } + + //================================================================================ + // VIRTUAL METHODS + //================================================================================ + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + backgroundColor = .clear + } + + override var intrinsicContentSize: CGSize { + let diameter = radius * 2 + let handleRadius = ceilf(Float(handleDiameter) / 2.0) + + let totalWidth = diameter + CGFloat(2 * max(handleRadius, ceilf(Float(lineWidth) / 2.0))) + + return CGSize(width: totalWidth, height: totalWidth) + } + + override func draw(_ rect: CGRect) { + super.draw(rect) + let ctx = UIGraphicsGetCurrentContext() + + // Draw filled and unfilled lines + drawLine(ctx: ctx!) + + // Draw markings + drawMarkings(ctx: ctx!) + + // Draw handle + let handleCenter = pointOnCircleAt(angle: angle) + handle.frame = drawHandle(ctx: ctx!, atPoint: handleCenter, handle: handle) + + // Draw labels + drawLabels(ctx: ctx!) + + // Rotate slider + self.transform = getRotationalTransform() + for view in subviews { // cancel rotation on all subviews added by the user + view.transform = getRotationalTransform().inverted() + } + + } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + guard event != nil else { + return false + } + + if pointInsideHandle(point, handleCenter: pointOnCircleAt(angle: angle)) { + + return true + } + else { + return pointInsideCircle(point) + } + } + + override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + let location = touch.location(in: self) + + if pointInsideHandle(location, handleCenter: pointOnCircleAt(angle: angle)) { + handle.isPressed = true + castDelegate?.circularSlider(self, startedTrackingWith: currentValue) + setNeedsDisplay() + return true + } + + return pointInsideCircle(location) + } + + override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + let lastPoint = touch.location(in: self) + let lastAngle = floor(calculateAngle(from: centerPoint, to: lastPoint)) + + moveHandle(newAngle: lastAngle) + + castDelegate?.circularSlider(self, valueChangedTo: currentValue, fromUser: true) + + sendActions(for: UIControlEvents.valueChanged) + + return true + } + + override func endTracking(_ touch: UITouch?, with event: UIEvent?) { + super.endTracking(touch, with: event) + + castDelegate?.circularSlider(self, endedTrackingWith: currentValue) + snapHandle() + + handle.isPressed = false + + setNeedsDisplay() + } + + //================================================================================ + // DRAWING METHODS + //================================================================================ + + internal func drawLine(ctx: CGContext) { + unfilledColor.set() + // Draw unfilled circle + drawUnfilledCircle(ctx: ctx, center: centerPoint, radius: calculatedRadius, lineWidth: CGFloat(lineWidth), maximumAngle: maximumAngle, lineCap: unfilledLineCap) + + filledColor.set() + // Draw filled circle + 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 + } + + private func drawLabels(ctx: CGContext) { + if labels.count > 0 { + let attributes = [NSAttributedStringKey.font: labelFont, NSAttributedStringKey.foregroundColor: labelColor] as [NSAttributedStringKey : Any] + + for i in 0 ..< labels.count { + let label = labels[i] as NSString + let labelFrame = frameForLabelAt(i) + + ctx.saveGState() + + // Invert transform to cancel rotation on labels + ctx.concatenate(CGAffineTransform(translationX: labelFrame.origin.x + (labelFrame.width / 2), + y: labelFrame.origin.y + (labelFrame.height / 2))) + ctx.concatenate(getRotationalTransform().inverted()) + ctx.concatenate(CGAffineTransform(translationX: -(labelFrame.origin.x + (labelFrame.width / 2)), + y: -(labelFrame.origin.y + (labelFrame.height / 2)))) + + // Draw label + label.draw(in: labelFrame, withAttributes: attributes) + + ctx.restoreGState() + } + } + } + + private func drawMarkings(ctx: CGContext) { + for i in 0 ..< markerCount { + let markFrame = frameForMarkingAt(i) + + ctx.saveGState() + + ctx.concatenate(CGAffineTransform(translationX: markFrame.origin.x + (markFrame.width / 2), + y: markFrame.origin.y + (markFrame.height / 2))) + ctx.concatenate(getRotationalTransform().inverted()) + ctx.concatenate(CGAffineTransform(translationX: -(markFrame.origin.x + (markFrame.width / 2)), + y: -(markFrame.origin.y + (markFrame.height / 2)))) + + if self.markerPath != nil { + markerColor.setFill() + markerPath?.fill() + } + else if self.markerImage != nil { + self.markerImage?.draw(in: markFrame) + } + else { + let markPath = UIBezierPath(ovalIn: markFrame) + markerColor.setFill() + markPath.fill() + } + + ctx.restoreGState() + } + } + + @discardableResult + private 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) + ctx.fillEllipse(in: frame) + return frame + } + + 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) + } + + 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))) + + ctx.addArc(center: center, radius: radius, startAngle: CGFloat(cartesianFromAngle), endAngle: CGFloat(cartesianToAngle), clockwise: false) + + ctx.setLineWidth(lineWidth) + ctx.setLineCap(lineCap) + ctx.drawPath(using: CGPathDrawingMode.stroke) + } + + //================================================================================ + // CALCULATION METHODS + //================================================================================ + + 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)))) + vector.x /= magnitude + vector.y /= magnitude + let cartesianRad = Double(atan2(vector.y, vector.x)) + + var compassRad = toCompass(cartesianRad) + + if (compassRad < 0) { + compassRad += (2 * Double.pi) + } + + assert(compassRad >= 0 && compassRad <= 2 * Double.pi, "angle must be positive") + return CGFloat(toDeg(compassRad)) + } + + private func pointOn(radius: CGFloat, angle: CGFloat) -> CGPoint { + var result = CGPoint() + + let cartesianAngle = CGFloat(toCartesian(toRad(Double(angle)))) + result.y = round(radius * sin(cartesianAngle)) + result.x = round(radius * cos(cartesianAngle)) + + return result + } + + 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) + } + + private func frameForMarkingAt(_ index: Int) -> CGRect { + var percentageAlongCircle: CGFloat! + + // Calculate degrees for marking + percentageAlongCircle = fullCircle ? ((100.0 / CGFloat(markerCount)) * CGFloat(index)) / 100.0 : ((100.0 / CGFloat(markerCount - 1)) * CGFloat(index)) / 100.0 + + + let markerDegrees = percentageAlongCircle * maximumAngle + let pointOnCircle = pointOnCircleAt(angle: markerDegrees) + + let markSize = CGSize(width: ((CGFloat(lineWidth) + handleDiameter) / CGFloat(2)), + height: ((CGFloat(lineWidth) + handleDiameter) / CGFloat(2))) + + // center along line + let offsetFromCircle = CGPoint(x: -markSize.width / 2.0, + y: -markSize.height / 2.0) + + return CGRect(x: pointOnCircle.x + offsetFromCircle.x, + y: pointOnCircle.y + offsetFromCircle.y, + width: markSize.width, + height: markSize.height) + } + + private func frameForLabelAt(_ index: Int) -> CGRect { + let label = labels[index] + var percentageAlongCircle: CGFloat! + + // calculate degrees for label + 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 + let pointOnCircle = pointOnCircleAt(angle: labelDegrees) + + let labelSize = sizeOf(string: label, withFont: labelFont) + let offsetFromCircle = offsetForLabelAt(index: index, withSize: labelSize) + + return CGRect(x: pointOnCircle.x + offsetFromCircle.x, + y: pointOnCircle.y + offsetFromCircle.y, + width: labelSize.width, + height: labelSize.height) + } + + 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 + + let radialDistance = labelInwardsDistance + labelOffset + let inwardOffset = pointOn(radius: radialDistance, angle: CGFloat(labelDegrees)) + + return CGPoint(x: -labelSize.width * 0.5 + inwardOffset.x, y: -labelSize.height * 0.5 + inwardOffset.y) + } + + private func degreesFor(arcLength: CGFloat, onCircleWithRadius radius: CGFloat, withMaximumAngle degrees: CGFloat) -> CGFloat { + let totalCircumference = CGFloat(2 * Double.pi) * radius + + let arcRatioToCircumference = arcLength / totalCircumference + + return degrees * arcRatioToCircumference + } + + private func pointInsideCircle(_ point: CGPoint) -> Bool { + let p1 = centerPoint + let p2 = point + let xDist = p2.x - p1.x + let yDist = p2.y - p1.y + let distance = sqrt((xDist * xDist) + (yDist * yDist)) + 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 + //================================================================================ + + private func moveHandle(newAngle: CGFloat) { + if newAngle > maximumAngle { // for incomplete circles + if newAngle > maximumAngle + (360 - maximumAngle) / 2.0 { + angle = 0 + setNeedsDisplay() + } + else { + angle = maximumAngle + setNeedsDisplay() + } + } + else { + angle = newAngle + } + setNeedsDisplay() + } + + 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 { + fixedAngle = -angle + } + else { + fixedAngle = maximumAngle - angle + } + + if snapToLabels { + var minDist = maximumAngle + var newAngle = 0.0 as CGFloat + + for i in 0 ..< labels.count + 1 { + let percentageAlongCircle = Double(i) / Double(labels.count - (fullCircle ? 0 : 1)) + let degreesToLbl = CGFloat(percentageAlongCircle) * maximumAngle + if abs(fixedAngle - degreesToLbl) < minDist { + newAngle = degreesToLbl != 0 || !fullCircle ? maximumAngle - degreesToLbl : 0 + minDist = abs(fixedAngle - degreesToLbl) + } + + } + + currentValue = valueFrom(angle: newAngle) + } + + if snapToMarkers { + var minDist = maximumAngle + var newAngle = 0.0 as CGFloat + + for i in 0 ..< markerCount + 1 { + let percentageAlongCircle = Double(i) / Double(markerCount - (fullCircle ? 0 : 1)) + let degreesToMarker = CGFloat(percentageAlongCircle) * maximumAngle + if abs(fixedAngle - degreesToMarker) < minDist { + newAngle = degreesToMarker != 0 || !fullCircle ? maximumAngle - degreesToMarker : 0 + minDist = abs(fixedAngle - degreesToMarker) + } + + } + + currentValue = valueFrom(angle: newAngle) + } + + setNeedsDisplay() + + } + + //================================================================================ + // SUPPORT METHODS + //================================================================================ + + internal func angleFrom(value: Double) -> CGFloat { + return (CGFloat(value) * maximumAngle) / CGFloat(maximumValue - minimumValue) + } + + internal func valueFrom(angle: CGFloat) -> Double { + return (maximumValue - minimumValue) * Double(angle) / Double(maximumAngle) + } + + private func toRad(_ degrees: Double) -> Double { + return ((Double.pi * degrees) / 180.0) + } + + private func toDeg(_ radians: Double) -> Double { + return ((180.0 * radians) / Double.pi) + } + + internal func square(_ value: Double) -> Double { + return value * value + } + + private func toCompass(_ cartesianRad: Double) -> Double { + return cartesianRad + (Double.pi / 2) + } + + private func toCartesian(_ compassRad: Double) -> Double { + return compassRad - (Double.pi / 2) + } + + private func sizeOf(string: String, withFont font: UIFont) -> CGSize { + let attributes = [NSAttributedStringKey.font: font] + return NSAttributedString(string: string, attributes: attributes).size() + } + + func getRotationalTransform() -> CGAffineTransform { + if fullCircle { + // No rotation required + let transform = CGAffineTransform.identity.rotated(by: CGFloat(0)) + return transform + } + else { + + if let rotation = self.rotationAngle { + return CGAffineTransform.identity.rotated(by: CGFloat(toRad(Double(rotation)))) + } + + let radians = Double(-(maximumAngle / 2)) / 180.0 * Double.pi + let transform = CGAffineTransform.identity.rotated(by: CGFloat(radians)) + return transform + } + } + + + +} + + diff --git a/MSCircularSliderExample/MSCircularSlider/MSCircularSliderHandle.swift b/MSCircularSliderExample/MSCircularSlider/MSCircularSliderHandle.swift new file mode 100755 index 0000000..115fdd3 --- /dev/null +++ b/MSCircularSliderExample/MSCircularSlider/MSCircularSliderHandle.swift @@ -0,0 +1,36 @@ +// +// MSCircularSliderHandle.swift +// +// Created by Mohamed Shahawy on 27/09/17. +// Copyright © 2017 Mohamed Shahawy. All rights reserved. +// + +import UIKit + +enum MSCircularSliderHandleType: Int, RawRepresentable { + case SmallCircle = 0, + MediumCircle, + LargeCircle, + DoubleCircle // Semitransparent big circle with a nested small circle +} + +@IBDesignable +class MSCircularSliderHandle: CALayer { + + //================================================================================ + // MEMBERS + //================================================================================ + + internal var isPressed: Bool = false { + didSet { + superlayer?.needsDisplay() + } + } + + internal var isHighlightable: Bool = true { + didSet { + superlayer?.needsDisplay() + } + } + +} diff --git a/MSCircularSliderExample/MSCircularSlider/MSDoubleHandleCircularSlider+IB.swift b/MSCircularSliderExample/MSCircularSlider/MSDoubleHandleCircularSlider+IB.swift new file mode 100644 index 0000000..bf1cee4 --- /dev/null +++ b/MSCircularSliderExample/MSCircularSlider/MSDoubleHandleCircularSlider+IB.swift @@ -0,0 +1,34 @@ +// +// MSDoubleHandleCircularSlider+IB.swift +// MoodiTrack +// +// Created by Mohamed Shahawy on 9/30/17. +// Copyright © 2017 Mohamed Shahawy. All rights reserved. +// + +import UIKit + +extension MSDoubleHandleCircularSlider { + + //================================================================================ + // SECOND HANDLE PROPERTIES + //================================================================================ + + @IBInspectable var _minimumHandlesDistance: CGFloat { + get { + return minimumHandlesDistance + } + set { + minimumHandlesDistance = newValue + } + } + + @IBInspectable var _secondCurrentValue: Double { + get { + return secondCurrentValue + } + set { + secondCurrentValue = newValue + } + } +} diff --git a/MSCircularSliderExample/MSCircularSlider/MSDoubleHandleCircularSlider.swift b/MSCircularSliderExample/MSCircularSlider/MSDoubleHandleCircularSlider.swift new file mode 100755 index 0000000..a354660 --- /dev/null +++ b/MSCircularSliderExample/MSCircularSlider/MSDoubleHandleCircularSlider.swift @@ -0,0 +1,266 @@ +// +// MSDoubleHandleCircularSlider.swift +// +// Created by Mohamed Shahawy on 27/09/17. +// Copyright © 2017 Mohamed Shahawy. All rights reserved. +// + +import UIKit + +protocol MSDoubleHandleCircularSliderDelegate: MSCircularSliderProtocol { + func circularSlider(_ slider: MSCircularSlider, valueChangedTo firstValue: Double, secondValue: Double, isFirstHandle: Bool?, fromUser: Bool) // fromUser indicates whether the value changed by sliding the handle (fromUser == true) or through other means (fromUser == false, isFirstHandle == nil) + func circularSlider(_ slider: MSCircularSlider, startedTrackingWith firstValue: Double, secondValue: Double, isFirstHandle: Bool) + func circularSlider(_ slider: MSCircularSlider, endedTrackingWith firstValue: Double, secondValue: Double, isFirstHandle: Bool) +} + +extension MSDoubleHandleCircularSliderDelegate { + // Optional Methods + func circularSlider(_ slider: MSCircularSlider, startedTrackingWith firstValue: Double, secondValue: Double, isFirstHandle: Bool) {} + func circularSlider(_ slider: MSCircularSlider, endedTrackingWith firstValue: Double, secondValue: Double, isFirstHandle: Bool) {} +} + +@IBDesignable +class MSDoubleHandleCircularSlider: MSCircularSlider { + + //================================================================================ + // MEMBERS + //================================================================================ + + // DELEGATE + private weak var castDelegate: MSDoubleHandleCircularSliderDelegate? { + get { + return delegate as? MSDoubleHandleCircularSliderDelegate + } + set { + delegate = newValue + } + } + + + // SECOND HANDLE'S PROPERTIES + var minimumHandlesDistance: CGFloat = 10 { // distance between handles + didSet { + let maxValue = CGFloat.pi * calculatedRadius * maximumAngle / 360.0 + + if minimumHandlesDistance < 1 { + print("minimumHandlesDistance \(minimumHandlesDistance) should be 1 or more - setting member to 1") + minimumHandlesDistance = 1 + } + else if minimumHandlesDistance > maxValue { + print("minimumHandlesDistance \(minimumHandlesDistance) should be \(maxValue) or less - setting member to \(maxValue)") + minimumHandlesDistance = maxValue + } + } + } + + override var handleHighlightable: Bool { + didSet { + secondHandle.isHighlightable = handleHighlightable + setNeedsDisplay() + } + } + + var secondCurrentValue: Double { // second handle's value + 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) + } + get { + return valueFrom(angle: secondAngle) + } + } + + var secondAngle: CGFloat = 60 { + didSet { + //assert(secondAngle >= 0 && secondAngle <= 360, "secondAngle \(secondAngle) must be between 0 and 360 inclusive") + secondAngle = max(0.0, secondAngle).truncatingRemainder(dividingBy: maximumAngle + 1) + } + } + + let secondHandle = MSCircularSliderHandle() + + // OVERRIDDEN MEMBERS + override var maximumAngle: CGFloat { + didSet { + // to account for dynamic maximumAngle changes + secondCurrentValue = valueFrom(angle: secondAngle) + } + } + + @available(*, unavailable, message: "this feature is not implemented yet") + override var snapToLabels: Bool { + set { + + } + get { + return false + } + } + + @available(*, unavailable, message: "this feature is not implemented yet") + override var snapToMarkers: Bool { + set { + + } + get { + return false + } + } + + //================================================================================ + // VIRTUAL METHODS + //================================================================================ + + override func draw(_ rect: CGRect) { + super.draw(rect) + let ctx = UIGraphicsGetCurrentContext() + + // Draw the second handle + let handleCenter = super.pointOnCircleAt(angle: secondAngle) + secondHandle.frame = self.drawHandle(ctx: ctx!, atPoint: handleCenter, handle: secondHandle) + } + + override func drawLine(ctx: CGContext) { + unfilledColor.set() + // Draw unfilled circle + drawUnfilledCircle(ctx: ctx, center: centerPoint, radius: calculatedRadius, lineWidth: CGFloat(lineWidth), maximumAngle: maximumAngle, lineCap: unfilledLineCap) + + filledColor.set() + // Draw filled circle + drawArc(ctx: ctx, center: centerPoint, radius: calculatedRadius, lineWidth: CGFloat(lineWidth), fromAngle: CGFloat(angle), toAngle: CGFloat(secondAngle), lineCap: filledLineCap) + } + + override 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) { + handle.isPressed = true + } + if pointInsideHandle(location, handleCenter: secondHandleCenter) { + 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))) { + // first handle is closer + secondHandle.isPressed = false + } + else { + // second handle is closer + handle.isPressed = false + } + } + + if secondHandle.isPressed || handle.isPressed { + castDelegate?.circularSlider(self, startedTrackingWith: currentValue, secondValue: secondCurrentValue, isFirstHandle: handle.isPressed) + + setNeedsDisplay() + return true + } + return false + } + + override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + let point = touch.location(in: self) + let newAngle = floor(calculateAngle(from: centerPoint, to: point)) + + if handle.isPressed { + moveFirstHandleTo(newAngle) + } + else if secondHandle.isPressed { + moveSecondHandleTo(newAngle) + } + + castDelegate?.circularSlider(self, valueChangedTo: currentValue, secondValue: secondCurrentValue, isFirstHandle: handle.isPressed, fromUser: false) + + sendActions(for: UIControlEvents.valueChanged) + + return true + } + + override func endTracking(_ touch: UITouch?, with event: UIEvent?) { + castDelegate?.circularSlider(self, endedTrackingWith: currentValue, secondValue: secondCurrentValue, isFirstHandle: handle.isPressed) + + handle.isPressed = false + secondHandle.isPressed = false + + setNeedsDisplay() + + // TODO: + // Snap To Labels/Markings future feature + } + + //================================================================================ + // DRAWING METHODS + //================================================================================ + + override func drawHandle(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 + + return super.drawHandle(ctx: ctx, atPoint: handleCenter, handle: handle) + } + + //================================================================================ + // HANDLE-MOVING METHODS + //================================================================================ + 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)))) + let circleDiameter = calculatedRadius * 2.0 + let circularDistance = circleDiameter * asin(straightDistance / circleDiameter) + return circularDistance + } + + private func moveFirstHandleTo(_ newAngle: CGFloat) { + let center = pointOnCircleAt(angle: newAngle) + let radius = handleDiameter / 2.0 + let newHandleFrame = CGRect(x: center.x - radius, y: center.y - radius, width: 2 * radius, height: 2 * radius) + + if !fullCircle && newAngle > secondAngle { + // will cross over the open part of the arc + return + } + + if distanceBetweenHandles(newHandleFrame, secondHandle.frame) < minimumHandlesDistance + handleDiameter { + // will cross the minimumHandlesDistance - no changes + return + } + + angle = newAngle + setNeedsDisplay() + } + + private func moveSecondHandleTo(_ newAngle: CGFloat) { + let center = pointOnCircleAt(angle: newAngle) + let radius = handleDiameter / 2.0 + let newHandleFrame = CGRect(x: center.x - radius, y: center.y - radius, width: 2 * radius, height: 2 * radius) + + if !fullCircle && newAngle > maximumAngle { + // will cross over the open part of the arc + return + } + + if distanceBetweenHandles(newHandleFrame, handle.frame) < minimumHandlesDistance + handleDiameter { + // will cross the minimumHandlesDistance - no changes + return + } + secondAngle = newAngle + setNeedsDisplay() + + } + +} + + + diff --git a/MSCircularSliderExample/MSCircularSlider/MSGradientCircularSlider+IB.swift b/MSCircularSliderExample/MSCircularSlider/MSGradientCircularSlider+IB.swift new file mode 100644 index 0000000..6b1dbf9 --- /dev/null +++ b/MSCircularSliderExample/MSCircularSlider/MSGradientCircularSlider+IB.swift @@ -0,0 +1,46 @@ +// +// MSGradientCircularSlider+IB.swift +// MSCircularSliderExample +// +// Created by Mohamed Shahawy on 10/2/17. +// Copyright © 2017 Mohamed Shahawy. All rights reserved. +// + +import UIKit + +extension MSGradientCircularSlider { + + //================================================================================ + // GRADIENT COLORS PROPERTIES + //================================================================================ + + @IBInspectable var _firstGradientColor: UIColor { + get { + return gradientColors[0] + } + set { + gradientColors[0] = newValue + } + } + + @IBInspectable var _secondGradientColor: UIColor { + get { + return gradientColors[1] + } + set { + gradientColors[1] = newValue + } + } + + @IBInspectable var _thirdGradientColor: UIColor { + get { + return gradientColors[2] + } + set { + gradientColors[2] = newValue + } + } + + // More colors can be added programatically + +} diff --git a/MSCircularSliderExample/MSCircularSlider/MSGradientCircularSlider.swift b/MSCircularSliderExample/MSCircularSlider/MSGradientCircularSlider.swift new file mode 100755 index 0000000..d794798 --- /dev/null +++ b/MSCircularSliderExample/MSCircularSlider/MSGradientCircularSlider.swift @@ -0,0 +1,105 @@ +// +// MSGradientCircularSlider.swift +// +// Created by Mohamed Shahawy on 27/09/17. +// Copyright © 2017 Mohamed Shahawy. All rights reserved. +// + +import UIKit + +@IBDesignable +class MSGradientCircularSlider: MSCircularSlider { + + // Gradient colors array + var gradientColors: [UIColor] = [.lightGray, .blue, .darkGray] { + didSet { + setNeedsDisplay() + } + } + + override var angle: CGFloat { + didSet { + let anglePercentage = Double(angle) * 100.0 / Double(maximumAngle) + filledColor = colorFor(percentage: anglePercentage) + + setNeedsDisplay() + } + } + + //================================================================================ + // SETTER METHODS + //================================================================================ + + func addColor(_ color: UIColor) { + gradientColors.append(color) + + let anglePercentage = Double(angle) * 100.0 / Double(maximumAngle) + filledColor = colorFor(percentage: anglePercentage) + setNeedsDisplay() + } + + func changeColor(at index: Int, newColor: UIColor) { + assert(gradientColors.count > index && index >= 0, "gradient color index out of bounds") + gradientColors[index] = newColor + + let anglePercentage = Double(angle) * 100.0 / Double(maximumAngle) + filledColor = colorFor(percentage: anglePercentage) + setNeedsDisplay() + } + + 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") + gradientColors.remove(at: index) + + let anglePercentage = Double(angle) * 100.0 / Double(maximumAngle) + filledColor = colorFor(percentage: anglePercentage) + setNeedsDisplay() + } + + //================================================================================ + // SUPPORT METHODS + //================================================================================ + + private func blend(from: UIColor, to: UIColor, percentage: Double) -> UIColor { + var fromR: CGFloat = 0.0 + var fromG: CGFloat = 0.0 + var fromB: CGFloat = 0.0 + var fromA: CGFloat = 0.0 + var toR: CGFloat = 0.0 + var toG: CGFloat = 0.0 + var toB: CGFloat = 0.0 + var toA: CGFloat = 0.0 + + from.getRed(&fromR, green: &fromG, blue: &fromB, alpha: &fromA) + to.getRed(&toR, green: &toG, blue: &toB, alpha: &toA) + + let dR = toR - fromR + let dG = toG - fromG + let dB = toB - fromB + let dA = toA - fromA + + let rR = fromR + dR * CGFloat(percentage) + let rG = fromG + dG * CGFloat(percentage) + let rB = fromB + dB * CGFloat(percentage) + let rA = fromA + dA * CGFloat(percentage) + + return UIColor(red: rR, green: rG, blue: rB, alpha: rA) + } + + private func colorFor(percentage: Double) -> UIColor { + let colorPercentageInterval = 100.0 / Double(gradientColors.count - 1) + + let currentInterval = percentage / colorPercentageInterval - (percentage == 100 ? 1 : 0) + + let intervalPercentage = currentInterval - Double(Int(currentInterval)) // how far along between two colors + + return blend(from: gradientColors[Int(floor(currentInterval))], + to: gradientColors[min(Int(floor(currentInterval + 1)), gradientColors.count - 1)], + percentage: intervalPercentage) + + } +} + + + diff --git a/MSCircularSliderExample/MSCircularSliderExample.xcodeproj/project.pbxproj b/MSCircularSliderExample/MSCircularSliderExample.xcodeproj/project.pbxproj new file mode 100644 index 0000000..152790b --- /dev/null +++ b/MSCircularSliderExample/MSCircularSliderExample.xcodeproj/project.pbxproj @@ -0,0 +1,373 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + D9254D231F852E71006F7A81 /* ColorPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9254D221F852E71006F7A81 /* ColorPickerView.swift */; }; + D935DBFE1F81AE5300E6B6EE /* SliderPropertiesVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = D935DBFD1F81AE5300E6B6EE /* SliderPropertiesVC.swift */; }; + D935DC021F81AE7A00E6B6EE /* MarkersLabelsVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = D935DC011F81AE7A00E6B6EE /* MarkersLabelsVC.swift */; }; + D935DC041F81AE8500E6B6EE /* DoubleHandleVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = D935DC031F81AE8500E6B6EE /* DoubleHandleVC.swift */; }; + D935DC061F81AE8E00E6B6EE /* GradientColorsVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = D935DC051F81AE8E00E6B6EE /* GradientColorsVC.swift */; }; + D96C34881F813B050041B50C /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D96C34871F813B050041B50C /* AppDelegate.swift */; }; + D96C348A1F813B050041B50C /* ExamplesMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = D96C34891F813B050041B50C /* ExamplesMenu.swift */; }; + D96C348D1F813B050041B50C /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D96C348B1F813B050041B50C /* Main.storyboard */; }; + D96C348F1F813B060041B50C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D96C348E1F813B060041B50C /* Assets.xcassets */; }; + D96C34921F813B060041B50C /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D96C34901F813B060041B50C /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + D9254D221F852E71006F7A81 /* ColorPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPickerView.swift; sourceTree = ""; }; + D9254D241F859AF8006F7A81 /* MSCircularSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MSCircularSlider.swift; path = MSCircularSlider/MSCircularSlider.swift; sourceTree = ""; }; + D9254D251F859AF8006F7A81 /* MSCircularSliderHandle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MSCircularSliderHandle.swift; path = MSCircularSlider/MSCircularSliderHandle.swift; sourceTree = ""; }; + D9254D261F859AF8006F7A81 /* MSCircularSlider+IB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "MSCircularSlider+IB.swift"; path = "MSCircularSlider/MSCircularSlider+IB.swift"; sourceTree = ""; }; + D9254D271F859AF8006F7A81 /* MSCircularTrig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MSCircularTrig.swift; path = MSCircularSlider/MSCircularTrig.swift; sourceTree = ""; }; + D9254D281F859AF8006F7A81 /* MSDoubleHandleCircularSlider+IB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "MSDoubleHandleCircularSlider+IB.swift"; path = "MSCircularSlider/MSDoubleHandleCircularSlider+IB.swift"; sourceTree = ""; }; + D9254D291F859AF8006F7A81 /* MSDoubleHandleCircularSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MSDoubleHandleCircularSlider.swift; path = MSCircularSlider/MSDoubleHandleCircularSlider.swift; sourceTree = ""; }; + D9254D2A1F859AF8006F7A81 /* MSGradientCircularSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MSGradientCircularSlider.swift; path = MSCircularSlider/MSGradientCircularSlider.swift; sourceTree = ""; }; + D9254D2B1F859AF8006F7A81 /* MSGradientCircularSlider+IB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "MSGradientCircularSlider+IB.swift"; path = "MSCircularSlider/MSGradientCircularSlider+IB.swift"; sourceTree = ""; }; + D935DBFD1F81AE5300E6B6EE /* SliderPropertiesVC.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SliderPropertiesVC.swift; sourceTree = ""; }; + D935DC011F81AE7A00E6B6EE /* MarkersLabelsVC.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MarkersLabelsVC.swift; sourceTree = ""; }; + D935DC031F81AE8500E6B6EE /* DoubleHandleVC.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DoubleHandleVC.swift; sourceTree = ""; }; + D935DC051F81AE8E00E6B6EE /* GradientColorsVC.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GradientColorsVC.swift; sourceTree = ""; }; + D96C34841F813B050041B50C /* MSCircularSliderExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MSCircularSliderExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; + D96C34871F813B050041B50C /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + D96C34891F813B050041B50C /* ExamplesMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExamplesMenu.swift; sourceTree = ""; }; + D96C348C1F813B050041B50C /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + D96C348E1F813B060041B50C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + D96C34911F813B060041B50C /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + D96C34931F813B060041B50C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + D96C34811F813B050041B50C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + D96C347B1F813B050041B50C = { + isa = PBXGroup; + children = ( + D96C34861F813B050041B50C /* MSCircularSliderExample */, + D96C34A71F813BBB0041B50C /* MSCircularSlider */, + D96C34851F813B050041B50C /* Products */, + ); + sourceTree = ""; + }; + D96C34851F813B050041B50C /* Products */ = { + isa = PBXGroup; + children = ( + D96C34841F813B050041B50C /* MSCircularSliderExample.app */, + ); + name = Products; + sourceTree = ""; + }; + D96C34861F813B050041B50C /* MSCircularSliderExample */ = { + isa = PBXGroup; + children = ( + D96C34871F813B050041B50C /* AppDelegate.swift */, + D96C34891F813B050041B50C /* ExamplesMenu.swift */, + D935DBFD1F81AE5300E6B6EE /* SliderPropertiesVC.swift */, + D935DC011F81AE7A00E6B6EE /* MarkersLabelsVC.swift */, + D935DC031F81AE8500E6B6EE /* DoubleHandleVC.swift */, + D935DC051F81AE8E00E6B6EE /* GradientColorsVC.swift */, + D9254D221F852E71006F7A81 /* ColorPickerView.swift */, + D96C348B1F813B050041B50C /* Main.storyboard */, + D96C348E1F813B060041B50C /* Assets.xcassets */, + D96C34901F813B060041B50C /* LaunchScreen.storyboard */, + D96C34931F813B060041B50C /* Info.plist */, + ); + path = MSCircularSliderExample; + sourceTree = ""; + }; + D96C34A71F813BBB0041B50C /* MSCircularSlider */ = { + isa = PBXGroup; + children = ( + D9254D241F859AF8006F7A81 /* MSCircularSlider.swift */, + D9254D261F859AF8006F7A81 /* MSCircularSlider+IB.swift */, + D9254D251F859AF8006F7A81 /* MSCircularSliderHandle.swift */, + D9254D271F859AF8006F7A81 /* MSCircularTrig.swift */, + D9254D291F859AF8006F7A81 /* MSDoubleHandleCircularSlider.swift */, + D9254D281F859AF8006F7A81 /* MSDoubleHandleCircularSlider+IB.swift */, + D9254D2A1F859AF8006F7A81 /* MSGradientCircularSlider.swift */, + D9254D2B1F859AF8006F7A81 /* MSGradientCircularSlider+IB.swift */, + ); + name = MSCircularSlider; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + D96C34831F813B050041B50C /* MSCircularSliderExample */ = { + isa = PBXNativeTarget; + buildConfigurationList = D96C34961F813B060041B50C /* Build configuration list for PBXNativeTarget "MSCircularSliderExample" */; + buildPhases = ( + D96C34801F813B050041B50C /* Sources */, + D96C34811F813B050041B50C /* Frameworks */, + D96C34821F813B050041B50C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = MSCircularSliderExample; + productName = MSCircularSliderExample; + productReference = D96C34841F813B050041B50C /* MSCircularSliderExample.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + D96C347C1F813B050041B50C /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0830; + LastUpgradeCheck = 0900; + ORGANIZATIONNAME = "Mohamed Shahawy"; + TargetAttributes = { + D96C34831F813B050041B50C = { + CreatedOnToolsVersion = 8.3.3; + DevelopmentTeam = 7L72FBCFDH; + LastSwiftMigration = 0900; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = D96C347F1F813B050041B50C /* Build configuration list for PBXProject "MSCircularSliderExample" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = English; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = D96C347B1F813B050041B50C; + productRefGroup = D96C34851F813B050041B50C /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + D96C34831F813B050041B50C /* MSCircularSliderExample */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + D96C34821F813B050041B50C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D96C34921F813B060041B50C /* LaunchScreen.storyboard in Resources */, + D96C348F1F813B060041B50C /* Assets.xcassets in Resources */, + D96C348D1F813B050041B50C /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + D96C34801F813B050041B50C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D935DC021F81AE7A00E6B6EE /* MarkersLabelsVC.swift in Sources */, + D96C348A1F813B050041B50C /* ExamplesMenu.swift in Sources */, + D935DC061F81AE8E00E6B6EE /* GradientColorsVC.swift in Sources */, + D935DC041F81AE8500E6B6EE /* DoubleHandleVC.swift in Sources */, + D9254D231F852E71006F7A81 /* ColorPickerView.swift in Sources */, + D935DBFE1F81AE5300E6B6EE /* SliderPropertiesVC.swift in Sources */, + D96C34881F813B050041B50C /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + D96C348B1F813B050041B50C /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + D96C348C1F813B050041B50C /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + D96C34901F813B060041B50C /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + D96C34911F813B060041B50C /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + D96C34941F813B060041B50C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 10.3; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + D96C34951F813B060041B50C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 10.3; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + D96C34971F813B060041B50C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + DEVELOPMENT_TEAM = 7L72FBCFDH; + INFOPLIST_FILE = MSCircularSliderExample/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 9.3; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = mshahawy.MSCircularSliderExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_SWIFT3_OBJC_INFERENCE = Default; + SWIFT_VERSION = 4.0; + }; + name = Debug; + }; + D96C34981F813B060041B50C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + DEVELOPMENT_TEAM = 7L72FBCFDH; + INFOPLIST_FILE = MSCircularSliderExample/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 9.3; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = mshahawy.MSCircularSliderExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_SWIFT3_OBJC_INFERENCE = Default; + SWIFT_VERSION = 4.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + D96C347F1F813B050041B50C /* Build configuration list for PBXProject "MSCircularSliderExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D96C34941F813B060041B50C /* Debug */, + D96C34951F813B060041B50C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D96C34961F813B060041B50C /* Build configuration list for PBXNativeTarget "MSCircularSliderExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D96C34971F813B060041B50C /* Debug */, + D96C34981F813B060041B50C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = D96C347C1F813B050041B50C /* Project object */; +} diff --git a/MSCircularSliderExample/MSCircularSliderExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/MSCircularSliderExample/MSCircularSliderExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..3a73067 --- /dev/null +++ b/MSCircularSliderExample/MSCircularSliderExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/MSCircularSliderExample/MSCircularSliderExample.xcodeproj/project.xcworkspace/xcuserdata/mohamed.xcuserdatad/IDEFindNavigatorScopes.plist b/MSCircularSliderExample/MSCircularSliderExample.xcodeproj/project.xcworkspace/xcuserdata/mohamed.xcuserdatad/IDEFindNavigatorScopes.plist new file mode 100644 index 0000000..5dd5da8 --- /dev/null +++ b/MSCircularSliderExample/MSCircularSliderExample.xcodeproj/project.xcworkspace/xcuserdata/mohamed.xcuserdatad/IDEFindNavigatorScopes.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/MSCircularSliderExample/MSCircularSliderExample.xcodeproj/project.xcworkspace/xcuserdata/mohamed.xcuserdatad/UserInterfaceState.xcuserstate b/MSCircularSliderExample/MSCircularSliderExample.xcodeproj/project.xcworkspace/xcuserdata/mohamed.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..2e003a2 Binary files /dev/null and b/MSCircularSliderExample/MSCircularSliderExample.xcodeproj/project.xcworkspace/xcuserdata/mohamed.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/MSCircularSliderExample/MSCircularSliderExample.xcodeproj/xcuserdata/mohamed.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/MSCircularSliderExample/MSCircularSliderExample.xcodeproj/xcuserdata/mohamed.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..fe2b454 --- /dev/null +++ b/MSCircularSliderExample/MSCircularSliderExample.xcodeproj/xcuserdata/mohamed.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,5 @@ + + + diff --git a/MSCircularSliderExample/MSCircularSliderExample.xcodeproj/xcuserdata/mohamed.xcuserdatad/xcschemes/MSCircularSliderExample.xcscheme b/MSCircularSliderExample/MSCircularSliderExample.xcodeproj/xcuserdata/mohamed.xcuserdatad/xcschemes/MSCircularSliderExample.xcscheme new file mode 100644 index 0000000..fc333d7 --- /dev/null +++ b/MSCircularSliderExample/MSCircularSliderExample.xcodeproj/xcuserdata/mohamed.xcuserdatad/xcschemes/MSCircularSliderExample.xcscheme @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MSCircularSliderExample/MSCircularSliderExample.xcodeproj/xcuserdata/mohamed.xcuserdatad/xcschemes/xcschememanagement.plist b/MSCircularSliderExample/MSCircularSliderExample.xcodeproj/xcuserdata/mohamed.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..64f0b97 --- /dev/null +++ b/MSCircularSliderExample/MSCircularSliderExample.xcodeproj/xcuserdata/mohamed.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,22 @@ + + + + + SchemeUserState + + MSCircularSliderExample.xcscheme + + orderHint + 0 + + + SuppressBuildableAutocreation + + D96C34831F813B050041B50C + + primary + + + + + diff --git a/MSCircularSliderExample/MSCircularSliderExample/AppDelegate.swift b/MSCircularSliderExample/MSCircularSliderExample/AppDelegate.swift new file mode 100644 index 0000000..15f76c0 --- /dev/null +++ b/MSCircularSliderExample/MSCircularSliderExample/AppDelegate.swift @@ -0,0 +1,46 @@ +// +// AppDelegate.swift +// MSCircularSliderExample +// +// Created by Mohamed Shahawy on 10/1/17. +// Copyright © 2017 Mohamed Shahawy. All rights reserved. +// + +import UIKit + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + func applicationWillResignActive(_ application: UIApplication) { + // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. + // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. + // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + } + + func applicationWillEnterForeground(_ application: UIApplication) { + // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + } + + func applicationDidBecomeActive(_ application: UIApplication) { + // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + } + + func applicationWillTerminate(_ application: UIApplication) { + // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } + + +} + diff --git a/MSCircularSliderExample/MSCircularSliderExample/Assets.xcassets/AppIcon.appiconset/Contents.json b/MSCircularSliderExample/MSCircularSliderExample/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..36d2c80 --- /dev/null +++ b/MSCircularSliderExample/MSCircularSliderExample/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "3x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/MSCircularSliderExample/MSCircularSliderExample/Base.lproj/LaunchScreen.storyboard b/MSCircularSliderExample/MSCircularSliderExample/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..fdf3f97 --- /dev/null +++ b/MSCircularSliderExample/MSCircularSliderExample/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MSCircularSliderExample/MSCircularSliderExample/Base.lproj/Main.storyboard b/MSCircularSliderExample/MSCircularSliderExample/Base.lproj/Main.storyboard new file mode 100644 index 0000000..b0384f5 --- /dev/null +++ b/MSCircularSliderExample/MSCircularSliderExample/Base.lproj/Main.storyboard @@ -0,0 +1,674 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MSCircularSliderExample/MSCircularSliderExample/ColorPickerView.swift b/MSCircularSliderExample/MSCircularSliderExample/ColorPickerView.swift new file mode 100644 index 0000000..faf4cc0 --- /dev/null +++ b/MSCircularSliderExample/MSCircularSliderExample/ColorPickerView.swift @@ -0,0 +1,113 @@ +// +// ColorPickerView.swift +// MSCircularSliderExample +// +// Created by Mohamed Shahawy on 10/4/17. +// Copyright © 2017 Mohamed Shahawy. All rights reserved. +// + + +//================================================================================ +// Credit to Joel Teply from SO +// Reference: https://stackoverflow.com/a/34142316/3551916 +// Mere changes were applied to his code +//================================================================================ + +import UIKit + +internal protocol ColorPickerDelegate : NSObjectProtocol { + func colorPickerTouched(sender: ColorPickerView, color: UIColor, point: CGPoint, state: UIGestureRecognizerState) +} + +@IBDesignable +class ColorPickerView: UIView { + + weak internal var delegate: ColorPickerDelegate? + let saturationExponentTop:Float = 2.0 + let saturationExponentBottom:Float = 1.3 + + @IBInspectable var elementSize: CGFloat = 1.0 { + didSet { + setNeedsDisplay() + } + } + + private func initialize() { + self.clipsToBounds = true + let touchGesture = UILongPressGestureRecognizer(target: self, action: #selector(ColorPickerView.touchedColor(_:))) + touchGesture.minimumPressDuration = 0 + touchGesture.allowableMovement = CGFloat.greatestFiniteMagnitude + self.addGestureRecognizer(touchGesture) + } + + override init(frame: CGRect) { + super.init(frame: frame) + initialize() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + initialize() + } + + override func draw(_ rect: CGRect) { + let context = UIGraphicsGetCurrentContext() + + for y : CGFloat in stride(from: 0 as CGFloat, to: rect.height, by: elementSize) { + + var saturation = y < rect.height / 2.0 ? CGFloat(2 * y) / rect.height : 2.0 * CGFloat(rect.height - y) / rect.height + saturation = CGFloat(powf(Float(saturation), y < rect.height / 2.0 ? saturationExponentTop : saturationExponentBottom)) + let brightness = y < rect.height / 2.0 ? CGFloat(1.0) : 2.0 * CGFloat(rect.height - y) / rect.height + + for x : CGFloat in stride(from: 0 as CGFloat, to: rect.width, by: elementSize) { + let hue = x / rect.width + let color = UIColor(hue: hue, saturation: saturation, brightness: brightness, alpha: 1.0) + context!.setFillColor(color.cgColor) + context!.fill(CGRect(x: x, y: y, width: elementSize, height: elementSize)) + } + } + } + + func getColorAtPoint(point:CGPoint) -> UIColor { + let roundedPoint = CGPoint(x:elementSize * CGFloat(Int(point.x / elementSize)), + y:elementSize * CGFloat(Int(point.y / elementSize))) + var saturation = roundedPoint.y < self.bounds.height / 2.0 ? CGFloat(2 * roundedPoint.y) / self.bounds.height + : 2.0 * CGFloat(self.bounds.height - roundedPoint.y) / self.bounds.height + saturation = CGFloat(powf(Float(saturation), roundedPoint.y < self.bounds.height / 2.0 ? saturationExponentTop : saturationExponentBottom)) + let brightness = roundedPoint.y < self.bounds.height / 2.0 ? CGFloat(1.0) : 2.0 * CGFloat(self.bounds.height - roundedPoint.y) / self.bounds.height + let hue = roundedPoint.x / self.bounds.width + return UIColor(hue: hue, saturation: saturation, brightness: brightness, alpha: 1.0) + } + + func getPointForColor(color:UIColor) -> CGPoint { + var hue:CGFloat=0; + var saturation:CGFloat=0; + var brightness:CGFloat=0; + color.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: nil); + + var yPos:CGFloat = 0 + let halfHeight = (self.bounds.height / 2) + + if (brightness >= 0.99) { + let percentageY = powf(Float(saturation), 1.0 / saturationExponentTop) + yPos = CGFloat(percentageY) * halfHeight + } else { + //use brightness to get Y + yPos = halfHeight + halfHeight * (1.0 - brightness) + } + + let xPos = hue * self.bounds.width + + return CGPoint(x: xPos, y: yPos) + } + + @objc func touchedColor(_ gestureRecognizer: UILongPressGestureRecognizer){ + if (gestureRecognizer.state == UIGestureRecognizerState.began) { + let point = gestureRecognizer.location(in: self) + let color = getColorAtPoint(point: point) + + self.delegate?.colorPickerTouched(sender: self, color: color, point: point, state:gestureRecognizer.state) + } + + } +} diff --git a/MSCircularSliderExample/MSCircularSliderExample/DoubleHandleVC.swift b/MSCircularSliderExample/MSCircularSliderExample/DoubleHandleVC.swift new file mode 100644 index 0000000..085ab67 --- /dev/null +++ b/MSCircularSliderExample/MSCircularSliderExample/DoubleHandleVC.swift @@ -0,0 +1,116 @@ +// +// DoubleHandleVC.swift +// MSCircularSliderExample +// +// Created by Mohamed Shahawy on 10/2/17. +// Copyright © 2017 Mohamed Shahawy. All rights reserved. +// + +import UIKit + +class DoubleHandleVC: UIViewController, MSDoubleHandleCircularSliderDelegate, ColorPickerDelegate { + + // Outlets + @IBOutlet weak var slider: MSDoubleHandleCircularSlider! + @IBOutlet weak var valuesLbl: UILabel! + @IBOutlet weak var handleTypeLbl: UILabel! + @IBOutlet weak var descriptionLbl: UILabel! + @IBOutlet weak var unfilledColorBtn: UIButton! + @IBOutlet weak var filledColorBtn: UIButton! + @IBOutlet weak var handleColorBtn: UIButton! + @IBOutlet weak var minDistSlider: UISlider! + + + // Members + var currentColorPickTag = 0 + var colorPicker: ColorPickerView? + + // Actions + @IBAction func handleTypeValueChanged(_ sender: UIStepper) { + slider.handleType = MSCircularSliderHandleType(rawValue: Int(sender.value)) ?? slider.handleType + handleTypeLbl.text = handleTypeStrFrom(slider.handleType) + } + + @IBAction func maxAngleAction(_ sender: UISlider) { + slider.maximumAngle = CGFloat(sender.value) + descriptionLbl.text = getDescription() + } + + @IBAction func colorPickAction(_ sender: UIButton) { + currentColorPickTag = sender.tag + + colorPicker?.isHidden = false + } + + @IBAction func minDistanceValueChanged(_ sender: UISlider) { + slider.minimumHandlesDistance = CGFloat(sender.value) + descriptionLbl.text = getDescription() + } + + // Init + override func viewDidLoad() { + super.viewDidLoad() + + handleTypeLbl.text = handleTypeStrFrom(slider.handleType) + + colorPicker = ColorPickerView(frame: CGRect(x: 0, y: view.center.y - view.frame.height * 0.3 / 2.0, width: view.frame.width, height: view.frame.height * 0.3)) + colorPicker?.isHidden = true + colorPicker?.delegate = self + view.addSubview(colorPicker!) + + slider.delegate = self + + valuesLbl.text = String(format: "%.1f, %.1f", slider.currentValue, slider.secondCurrentValue) + + minDistSlider.maximumValue = Float(CGFloat.pi * slider.calculatedRadius * slider.maximumAngle / 360.0) + + descriptionLbl.text = getDescription() + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + // Dispose of any resources that can be recreated. + } + + // Support Methods + func handleTypeStrFrom(_ type: MSCircularSliderHandleType) -> String { + switch type { + case .SmallCircle: + return "Small Circle" + case .MediumCircle: + return "Medium Circle" + case .LargeCircle: + return "Large Circle" + case .DoubleCircle: + return "Double Circle" + } + } + + func getDescription() -> String { + return "Maximum Angle: \(String(format: "%.1f", slider.maximumAngle))°\nMinimum Distance Between Handles: \(String(format: "%.1f", slider.minimumHandlesDistance))" + } + + // Delegate Methods + func circularSlider(_ slider: MSCircularSlider, valueChangedTo firstValue: Double, secondValue: Double, isFirstHandle: Bool?, fromUser: Bool) { + valuesLbl.text = String(format: "%.1f, %.1f", firstValue, secondValue) + } + + func colorPickerTouched(sender: ColorPickerView, color: UIColor, point: CGPoint, state: UIGestureRecognizerState) { + switch currentColorPickTag { + case 0: + unfilledColorBtn.setTitleColor(color, for: .normal) + slider.unfilledColor = color + case 1: + filledColorBtn.setTitleColor(color, for: .normal) + slider.filledColor = color + case 2: + handleColorBtn.setTitleColor(color, for: .normal) + slider.handleColor = color + default: + break + } + + colorPicker?.isHidden = true + } + +} diff --git a/MSCircularSliderExample/MSCircularSliderExample/ExamplesMenu.swift b/MSCircularSliderExample/MSCircularSliderExample/ExamplesMenu.swift new file mode 100644 index 0000000..8031085 --- /dev/null +++ b/MSCircularSliderExample/MSCircularSliderExample/ExamplesMenu.swift @@ -0,0 +1,51 @@ +// +// ExamplesMenu.swift +// MSCircularSliderExample +// +// Created by Mohamed Shahawy on 10/1/17. +// Copyright © 2017 Mohamed Shahawy. All rights reserved. +// + +import UIKit + +class ExamplesMenu: UITableViewController { + + var lastSelectedTitle = "" + + override func viewDidLoad() { + super.viewDidLoad() + + + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + // Dispose of any resources that can be recreated. + } + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + segue.destination.title = lastSelectedTitle + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + + lastSelectedTitle = (tableView.cellForRow(at: indexPath)?.textLabel?.text!)! + + switch indexPath.row { + case 0: + performSegue(withIdentifier: "SliderPropertiesSegue", sender: self) + case 1: + performSegue(withIdentifier: "MarkersLabelsSegue", sender: self) + case 2: + performSegue(withIdentifier: "DoubleHandleSegue", sender: self) + case 3: + performSegue(withIdentifier: "GradientColorsSegue", sender: self) + default: + break + } + + tableView.deselectRow(at: indexPath, animated: true) + } + +} + diff --git a/MSCircularSliderExample/MSCircularSliderExample/GradientColorsVC.swift b/MSCircularSliderExample/MSCircularSliderExample/GradientColorsVC.swift new file mode 100644 index 0000000..c8fb1e4 --- /dev/null +++ b/MSCircularSliderExample/MSCircularSliderExample/GradientColorsVC.swift @@ -0,0 +1,73 @@ +// +// GradientColorsVC.swift +// MSCircularSliderExample +// +// Created by Mohamed Shahawy on 10/2/17. +// Copyright © 2017 Mohamed Shahawy. All rights reserved. +// + +import UIKit + +class GradientColorsVC: UIViewController, MSCircularSliderDelegate, ColorPickerDelegate { + // Outlets + @IBOutlet weak var slider: MSGradientCircularSlider! + @IBOutlet weak var valueLbl: UILabel! + @IBOutlet weak var firstColorBtn: UIButton! + @IBOutlet weak var secondColorBtn: UIButton! + @IBOutlet weak var thirdColorBtn: UIButton! + + + // Members + var currentColorPickTag = 0 + var colorPicker: ColorPickerView? + + // Action + @IBAction func colorPickAction(_ sender: UIButton) { + currentColorPickTag = sender.tag + + colorPicker?.isHidden = false + + } + + // Init + override func viewDidLoad() { + super.viewDidLoad() + + colorPicker = ColorPickerView(frame: CGRect(x: 0, y: view.center.y - view.frame.height * 0.3 / 2.0, width: view.frame.width, height: view.frame.height * 0.3)) + colorPicker?.isHidden = true + colorPicker?.delegate = self + view.addSubview(colorPicker!) + + valueLbl.text = String(format: "%.1f", (slider?.currentValue)!) + + slider.delegate = self + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + // Dispose of any resources that can be recreated. + } + + // Delegate Methos + func circularSlider(_ slider: MSCircularSlider, valueChangedTo value: Double, fromUser: Bool) { + valueLbl.text = String(format: "%.1f", value) + } + + func colorPickerTouched(sender: ColorPickerView, color: UIColor, point: CGPoint, state: UIGestureRecognizerState) { + switch currentColorPickTag { + case 0: + firstColorBtn.setTitleColor(color, for: .normal) + slider?.changeColor(at: 0, newColor: color) + case 1: + secondColorBtn.setTitleColor(color, for: .normal) + slider?.changeColor(at: 1, newColor: color) + case 2: + thirdColorBtn.setTitleColor(color, for: .normal) + slider?.changeColor(at: 2, newColor: color) + default: + break + } + + colorPicker?.isHidden = true + } +} diff --git a/MSCircularSliderExample/MSCircularSliderExample/Info.plist b/MSCircularSliderExample/MSCircularSliderExample/Info.plist new file mode 100644 index 0000000..d052473 --- /dev/null +++ b/MSCircularSliderExample/MSCircularSliderExample/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/MSCircularSliderExample/MSCircularSliderExample/MarkersLabelsVC.swift b/MSCircularSliderExample/MSCircularSliderExample/MarkersLabelsVC.swift new file mode 100644 index 0000000..80872cb --- /dev/null +++ b/MSCircularSliderExample/MSCircularSliderExample/MarkersLabelsVC.swift @@ -0,0 +1,111 @@ +// +// MarkersLabelsVC.swift +// MSCircularSliderExample +// +// Created by Mohamed Shahawy on 10/2/17. +// Copyright © 2017 Mohamed Shahawy. All rights reserved. +// + +import UIKit + +class MarkersLabelsVC: UIViewController, MSCircularSliderDelegate, ColorPickerDelegate { + // Outlets + @IBOutlet weak var sliderView: UIView! // frame reference + @IBOutlet weak var markerCountLbl: UILabel! + @IBOutlet weak var labelsColorBtn: UIButton! + @IBOutlet weak var markersColorBtn: UIButton! + @IBOutlet weak var valueLbl: UILabel! + @IBOutlet weak var snapToLabelSwitch: UISwitch! + @IBOutlet weak var snapToMarkerSwitch: UISwitch! + + // Members + var slider: MSCircularSlider? + var currentColorPickTag = 0 + var colorPicker: ColorPickerView? + + // Actions + @IBAction func labelsTextfieldDidChange(_ sender: UITextField) { + let text = sender.text! + if !text.trimmingCharacters(in: .whitespaces).isEmpty { + + slider?.labels = text.components(separatedBy: ",") + } + else { + slider?.labels.removeAll() + } + } + + @IBAction func markerCountStepperValueChanged(_ sender: UIStepper) { + slider?.markerCount = Int(sender.value) + markerCountLbl.text = "\(slider?.markerCount ?? 0) Marker\(slider?.markerCount == 1 ? "": "s")" + } + + @IBAction func colorPickAction(_ sender: UIButton) { + currentColorPickTag = sender.tag + + colorPicker?.isHidden = false + } + + @IBAction func snapToLabelsValueChanged(_ sender: UISwitch) { + slider?.snapToLabels = sender.isOn + snapToMarkerSwitch.setOn(false, animated: true) // mutually-exclusive + } + + @IBAction func snapToMarkersValueChanged(_ sender: UISwitch) { + slider?.snapToMarkers = sender.isOn + snapToLabelSwitch.setOn(false, animated: true) // mutually-exclusive + } + + + // Init + override func viewDidLoad() { + super.viewDidLoad() + + // Slider programmatic instantiation + slider = MSCircularSlider(frame: sliderView.frame) + slider?.delegate = self + view.addSubview(slider!) + + colorPicker = ColorPickerView(frame: CGRect(x: 0, y: view.center.y - view.frame.height * 0.3 / 2.0, width: view.frame.width, height: view.frame.height * 0.3)) + colorPicker?.isHidden = true + colorPicker?.delegate = self + view.addSubview(colorPicker!) + + valueLbl.text = String(format: "%.1f", (slider?.currentValue)!) + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + // Dispose of any resources that can be recreated. + } + + + // Delegate Methods + func circularSlider(_ slider: MSCircularSlider, valueChangedTo value: Double, fromUser: Bool) { + valueLbl.text = String(format: "%.1f", value) + } + + func circularSlider(_ slider: MSCircularSlider, startedTrackingWith value: Double) { + // optional delegate method + } + + func circularSlider(_ slider: MSCircularSlider, endedTrackingWith value: Double) { + // optional delegate method + } + + func colorPickerTouched(sender: ColorPickerView, color: UIColor, point: CGPoint, state: UIGestureRecognizerState) { + switch currentColorPickTag { + case 0: + labelsColorBtn.setTitleColor(color, for: .normal) + slider?.labelColor = color + case 1: + markersColorBtn.setTitleColor(color, for: .normal) + slider?.markerColor = color + default: + break + } + + colorPicker?.isHidden = true + } + +} diff --git a/MSCircularSliderExample/MSCircularSliderExample/SliderPropertiesVC.swift b/MSCircularSliderExample/MSCircularSliderExample/SliderPropertiesVC.swift new file mode 100644 index 0000000..e4f8749 --- /dev/null +++ b/MSCircularSliderExample/MSCircularSliderExample/SliderPropertiesVC.swift @@ -0,0 +1,129 @@ +// +// SliderPropertiesVC.swift +// MSCircularSliderExample +// +// Created by Mohamed Shahawy on 10/2/17. +// Copyright © 2017 Mohamed Shahawy. All rights reserved. +// + +import UIKit + +class SliderProperties: UIViewController, MSCircularSliderDelegate, ColorPickerDelegate { + // Outlets + @IBOutlet weak var slider: MSCircularSlider! + @IBOutlet weak var handleTypeLbl: UILabel! + @IBOutlet weak var unfilledColorBtn: UIButton! + @IBOutlet weak var filledColorBtn: UIButton! + @IBOutlet weak var handleColorBtn: UIButton! + @IBOutlet weak var descriptionLbl: UILabel! + @IBOutlet weak var valueLbl: UILabel! + + // Members + var currentColorPickTag = 0 + var colorPicker: ColorPickerView? + + // Actions + @IBAction func handleTypeValueChanged(_ sender: UIStepper) { + slider.handleType = MSCircularSliderHandleType(rawValue: Int(sender.value)) ?? slider.handleType + handleTypeLbl.text = handleTypeStrFrom(slider.handleType) + } + + @IBAction func maxAngleAction(_ sender: UISlider) { + slider.maximumAngle = CGFloat(sender.value) + descriptionLbl.text = getDescription() + } + + @IBAction func colorPickAction(_ sender: UIButton) { + currentColorPickTag = sender.tag + + colorPicker?.isHidden = false + } + + @IBAction func rotationAngleAction(_ sender: UISlider) { + descriptionLbl.text = getDescription() + if sender.value == 0 { + slider.rotationAngle = nil + return + } + slider.rotationAngle = CGFloat(sender.value) + } + + @IBAction func lineWidthAction(_ sender: UIStepper) { + slider.lineWidth = Int(sender.value) + descriptionLbl.text = getDescription() + } + + // Init + override func viewDidLoad() { + super.viewDidLoad() + + handleTypeLbl.text = handleTypeStrFrom(slider.handleType) + + colorPicker = ColorPickerView(frame: CGRect(x: 0, y: view.center.y - view.frame.height * 0.3 / 2.0, width: view.frame.width, height: view.frame.height * 0.3)) + colorPicker?.isHidden = true + colorPicker?.delegate = self + + slider.delegate = self + + valueLbl.text = String(format: "%.1f", slider.currentValue) + + descriptionLbl.text = getDescription() + + view.addSubview(colorPicker!) + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + // Dispose of any resources that can be recreated. + } + + // Support Methods + func handleTypeStrFrom(_ type: MSCircularSliderHandleType) -> String { + switch type { + case .SmallCircle: + return "Small Circle" + case .MediumCircle: + return "Medium Circle" + case .LargeCircle: + return "Large Circle" + case .DoubleCircle: + return "Double Circle" + } + } + + func getDescription() -> String { + let rotationAngle = slider.rotationAngle == nil ? "Computed" : String(format: "%.1f", slider.rotationAngle!) + "°" + return "Maximum Angle: \(String(format: "%.1f", slider.maximumAngle))°\nLine Width: \(slider.lineWidth)\nRotation Angle: \(rotationAngle)" + } + + // Delegate Methods + func circularSlider(_ slider: MSCircularSlider, valueChangedTo value: Double, fromUser: Bool) { + valueLbl.text = String(format: "%.1f", value) + } + + func circularSlider(_ slider: MSCircularSlider, startedTrackingWith value: Double) { + // optional delegate method + } + + func circularSlider(_ slider: MSCircularSlider, endedTrackingWith value: Double) { + // optional delegate method + } + + func colorPickerTouched(sender: ColorPickerView, color: UIColor, point: CGPoint, state: UIGestureRecognizerState) { + switch currentColorPickTag { + case 0: + unfilledColorBtn.setTitleColor(color, for: .normal) + slider.unfilledColor = color + case 1: + filledColorBtn.setTitleColor(color, for: .normal) + slider.filledColor = color + case 2: + handleColorBtn.setTitleColor(color, for: .normal) + slider.handleColor = color + default: + break + } + + colorPicker?.isHidden = true + } +} diff --git a/Podfile b/Podfile new file mode 100644 index 0000000..eefc027 --- /dev/null +++ b/Podfile @@ -0,0 +1,6 @@ +# Uncomment the next line to define a global platform for your project +# platform :ios, '9.0' + +target 'MSCircularSlider' do + +end diff --git a/Podfile.lock b/Podfile.lock new file mode 100644 index 0000000..09b04a5 --- /dev/null +++ b/Podfile.lock @@ -0,0 +1,3 @@ +PODFILE CHECKSUM: 3b40503b95fdeb296572240a06095e78bb69cd85 + +COCOAPODS: 1.3.1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..73b1a61 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# MSCircularSlider +A full-featured circular slider for iOS applications