From 1a1179fbe61d2e910d0b428be9e515c08bc70b83 Mon Sep 17 00:00:00 2001 From: Jonathan Cardasis Date: Sun, 14 Apr 2019 13:11:00 -0400 Subject: [PATCH] Added brightness slider and slider handle views. Created gradient backed slider track view. Refactored shadow stylings. --- ChromaColorPicker.xcodeproj/project.pbxproj | 24 +++ Example/ViewController.swift | 5 + Source/BrightnessSlider.swift | 166 ++++++++++++++++++++ Source/ChromaColorPicker.swift | 11 +- Source/ChromaControlStylable.swift | 25 +++ Source/ColorWheelView.swift | 14 +- Source/Helpers/SliderHandleView.swift | 75 +++++++++ Source/Helpers/SliderTrackView.swift | 49 ++++++ Tests/ColorWheelViewTests.swift | 12 ++ 9 files changed, 372 insertions(+), 9 deletions(-) create mode 100644 Source/BrightnessSlider.swift create mode 100644 Source/ChromaControlStylable.swift create mode 100644 Source/Helpers/SliderHandleView.swift create mode 100644 Source/Helpers/SliderTrackView.swift diff --git a/ChromaColorPicker.xcodeproj/project.pbxproj b/ChromaColorPicker.xcodeproj/project.pbxproj index a7fb596..9b3e268 100644 --- a/ChromaColorPicker.xcodeproj/project.pbxproj +++ b/ChromaColorPicker.xcodeproj/project.pbxproj @@ -18,6 +18,10 @@ FC1BD8C02207D7B700817AF3 /* ChromaColorPickerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC1BD8BF2207D7B700817AF3 /* ChromaColorPickerTests.swift */; }; FC1BD8C22207D7B700817AF3 /* ChromaColorPicker.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3503B82F1F2689BC00750356 /* ChromaColorPicker.framework */; }; FC4387C422603AE900F739F1 /* ColorWheelViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCCA42AB226038A400BE2FF9 /* ColorWheelViewTests.swift */; }; + FC4387C62262556600F739F1 /* BrightnessSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC4387C52262556600F739F1 /* BrightnessSlider.swift */; }; + FC4387C922625C7000F739F1 /* SliderTrackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC4387C822625C7000F739F1 /* SliderTrackView.swift */; }; + FC4387CB22625DA800F739F1 /* SliderHandleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC4387CA22625DA800F739F1 /* SliderHandleView.swift */; }; + FC4387CD2262B82600F739F1 /* ChromaControlStylable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC4387CC2262B82600F739F1 /* ChromaControlStylable.swift */; }; FCCA42A5226022A800BE2FF9 /* ColorWheelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCCA42A4226022A800BE2FF9 /* ColorWheelView.swift */; }; FCCA42A7226023F000BE2FF9 /* ChromaColorHandle.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCCA42A6226023F000BE2FF9 /* ChromaColorHandle.swift */; }; FCCA42AA2260329900BE2FF9 /* UIKit+DropShadow.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCCA42A92260329900BE2FF9 /* UIKit+DropShadow.swift */; }; @@ -75,6 +79,10 @@ FC1BD8CB2207FCE100817AF3 /* UIColor+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+Utilities.swift"; sourceTree = ""; }; FC1BD8CC2207FCE100817AF3 /* ChromaAddButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChromaAddButton.swift; sourceTree = ""; }; FC1BD8CD2207FCE100817AF3 /* ColorModeToggleButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ColorModeToggleButton.swift; sourceTree = ""; }; + FC4387C52262556600F739F1 /* BrightnessSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrightnessSlider.swift; sourceTree = ""; }; + FC4387C822625C7000F739F1 /* SliderTrackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SliderTrackView.swift; sourceTree = ""; }; + FC4387CA22625DA800F739F1 /* SliderHandleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SliderHandleView.swift; sourceTree = ""; }; + FC4387CC2262B82600F739F1 /* ChromaControlStylable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChromaControlStylable.swift; sourceTree = ""; }; FCCA42A4226022A800BE2FF9 /* ColorWheelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorWheelView.swift; sourceTree = ""; }; FCCA42A6226023F000BE2FF9 /* ChromaColorHandle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChromaColorHandle.swift; sourceTree = ""; }; FCCA42A92260329900BE2FF9 /* UIKit+DropShadow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIKit+DropShadow.swift"; sourceTree = ""; }; @@ -112,6 +120,7 @@ 3503B8301F2689BC00750356 /* Source */ = { isa = PBXGroup; children = ( + FC4387C722625C5F00F739F1 /* Helpers */, FCCA42A82260325F00BE2FF9 /* Extensions */, FCCA427822601F8800BE2FF9 /* Legacy */, 3503B8311F2689BC00750356 /* ChromaColorPicker.h */, @@ -119,6 +128,8 @@ FCEA4E262235AAA200C0A1B6 /* ChromaColorPicker.swift */, FCCA42A6226023F000BE2FF9 /* ChromaColorHandle.swift */, FCCA42A4226022A800BE2FF9 /* ColorWheelView.swift */, + FC4387C52262556600F739F1 /* BrightnessSlider.swift */, + FC4387CC2262B82600F739F1 /* ChromaControlStylable.swift */, ); path = Source; sourceTree = ""; @@ -167,6 +178,15 @@ path = Tests; sourceTree = ""; }; + FC4387C722625C5F00F739F1 /* Helpers */ = { + isa = PBXGroup; + children = ( + FC4387C822625C7000F739F1 /* SliderTrackView.swift */, + FC4387CA22625DA800F739F1 /* SliderHandleView.swift */, + ); + path = Helpers; + sourceTree = ""; + }; FCCA427822601F8800BE2FF9 /* Legacy */ = { isa = PBXGroup; children = ( @@ -344,9 +364,13 @@ buildActionMask = 2147483647; files = ( FCEA4E272235AAA200C0A1B6 /* ChromaColorPicker.swift in Sources */, + FC4387CD2262B82600F739F1 /* ChromaControlStylable.swift in Sources */, FCCA42AA2260329900BE2FF9 /* UIKit+DropShadow.swift in Sources */, + FC4387C62262556600F739F1 /* BrightnessSlider.swift in Sources */, FCCA42A7226023F000BE2FF9 /* ChromaColorHandle.swift in Sources */, FCCA42A5226022A800BE2FF9 /* ColorWheelView.swift in Sources */, + FC4387C922625C7000F739F1 /* SliderTrackView.swift in Sources */, + FC4387CB22625DA800F739F1 /* SliderHandleView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Example/ViewController.swift b/Example/ViewController.swift index fcb9331..b93948d 100644 --- a/Example/ViewController.swift +++ b/Example/ViewController.swift @@ -13,11 +13,16 @@ class ViewController: UIViewController { @IBOutlet weak var colorDisplayView: UIView! let colorPicker = ChromaColorPicker() + + let temp = BrightnessSlider() override func viewDidLoad() { super.viewDidLoad() + temp.frame = CGRect(x: 30, y: 70, width: 320, height: 32) + view.addSubview(temp) + colorPicker.translatesAutoresizingMaskIntoConstraints = false view.addSubview(colorPicker) diff --git a/Source/BrightnessSlider.swift b/Source/BrightnessSlider.swift new file mode 100644 index 0000000..4057d60 --- /dev/null +++ b/Source/BrightnessSlider.swift @@ -0,0 +1,166 @@ +// +// BrightnessSlider.swift +// ChromaColorPicker +// +// Created by Jon Cardasis on 4/13/19. +// Copyright © 2019 Jonathan Cardasis. All rights reserved. +// + +import UIKit + +public class BrightnessSlider: UIControl, ChromaControlStylable { + + /// The value of the slider between [0.0, 1.0]. + public var currentValue: CGFloat = 0.0 { + didSet { updateControl(to: currentValue) } + } + + /// The value of the color the handle is currently displaying. + public var currentColor: UIColor { + return sliderHandleView.handleColor + } + + public var borderWidth: CGFloat = 4.0 { + didSet { setNeedsLayout() } + } + + public var borderColor: UIColor = .white { + didSet { setNeedsLayout() } + } + + public var showsShadow: Bool = true { + didSet { setNeedsLayout() } + } + + //MARK: - Initialization + + public override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + public required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + commonInit() + } + + public override func layoutSubviews() { + super.layoutSubviews() + sliderTrackView.layer.cornerRadius = sliderTrackView.bounds.height / 2.0 + sliderTrackView.layer.borderColor = borderColor.cgColor + sliderTrackView.layer.borderWidth = borderWidth + + moveHandle(to: currentValue) + updateShadowIfNeeded() + } + + /// Sets the tracking color of the slider and updates the gradient view. + public func updateTrackingColor(to color: UIColor, value: CGFloat) { + updateTrackViewGradient(for: color) + currentValue = value + } + + // MARK: - Control + + public override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + let location = touch.location(in: self) + return confiningTrackFrame.contains(location) || sliderHandleView.frame.contains(location) + } + + public override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + let location = touch.location(in: self) + let clampedPositionX: CGFloat = max(0, min(location.x, confiningTrackFrame.width)) + let value = clampedPositionX / confiningTrackFrame.width + + updateControl(to: value) + sendActions(for: .valueChanged) + + return true + } + + public override func endTracking(_ touch: UITouch?, with event: UIEvent?) { + sendActions(for: .editingDidEnd) + } + + // MARK: - Private + internal let sliderTrackView = SliderTrackView() + internal let sliderHandleView = SliderHandleView() + + /// The amount of padding caused by visual stylings + internal var horizontalPadding: CGFloat { + return sliderTrackView.layer.cornerRadius / 2.0 + } + + internal var confiningTrackFrame: CGRect { + return sliderTrackView.frame.insetBy(dx: horizontalPadding, dy: 0) + } + + internal func commonInit() { + backgroundColor = .clear + setupSliderTrackView() + setupSliderHandleView() + updateControl(to: currentValue) + } + + internal func setupSliderTrackView() { + sliderTrackView.isUserInteractionEnabled = false + sliderTrackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(sliderTrackView) + NSLayoutConstraint.activate([ + sliderTrackView.leadingAnchor.constraint(equalTo: leadingAnchor), + sliderTrackView.trailingAnchor.constraint(equalTo: trailingAnchor), + sliderTrackView.heightAnchor.constraint(equalTo: heightAnchor, multiplier: 0.75), + sliderTrackView.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } + + internal func setupSliderHandleView() { + sliderHandleView.isUserInteractionEnabled = false + addSubview(sliderHandleView) + } + + internal func updateShadowIfNeeded() { + let views = [sliderHandleView, sliderTrackView] + + if showsShadow { + let shadowProps = shadowProperties(forHeight: bounds.height) + views.forEach { $0.applyDropShadow(shadowProps) } + } else { + views.forEach { $0.removeDropShadow() } + } + } + +// internal func updateControl(to color: UIColor) { +// updateTrackViewGradient(for: color) +// } + + internal func updateControl(to value: CGFloat) { + let brightness = 1 - max(0, min(1, value)) + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + currentColor.getHue(&hue, saturation: &saturation, brightness: nil, alpha: nil) + + let newColor = UIColor(hue: hue, saturation: saturation, brightness: brightness, alpha: 1.0) + + CATransaction.begin() + CATransaction.setDisableActions(true) + sliderHandleView.handleColor = newColor + CATransaction.commit() + + moveHandle(to: value) + } + + internal func updateTrackViewGradient(for color: UIColor) { + sliderTrackView.gradientValues = (color, .black) + } + + internal func moveHandle(to value: CGFloat) { + let clampedValue = max(0, min(1, value)) + let xPos = (clampedValue * confiningTrackFrame.width) + horizontalPadding + let size = CGSize(width: bounds.height * 1.15, height: bounds.height) + + sliderHandleView.frame = CGRect(origin: CGPoint(x: xPos - (size.width / 2), y: 0), size: size) + } + + +} diff --git a/Source/ChromaColorPicker.swift b/Source/ChromaColorPicker.swift index 153086c..b0b506e 100644 --- a/Source/ChromaColorPicker.swift +++ b/Source/ChromaColorPicker.swift @@ -17,7 +17,7 @@ public protocol ChromaColorPickerDelegate { @IBDesignable -public class ChromaColorPicker: UIControl { +public class ChromaColorPicker: UIControl, ChromaControlStylable { @IBInspectable public var borderWidth: CGFloat = 8.0 { didSet { setNeedsLayout() } @@ -27,6 +27,10 @@ public class ChromaColorPicker: UIControl { didSet { setNeedsLayout() } } + @IBInspectable public var showsBrightnessSlider: Bool = false { + didSet { /* todo */ } + } + @IBInspectable public var showsShadow: Bool = true { didSet { setNeedsLayout() } } @@ -76,10 +80,9 @@ public class ChromaColorPicker: UIControl { ]) } - internal func updateShadowIfNeeded() { + func updateShadowIfNeeded() { if showsShadow { - let dropShadowHeight = bounds.height * 0.01 - applyDropShadow(color: UIColor.black, opacity: 0.2, offset: CGSize(width: 0, height: dropShadowHeight), radius: 2) + applyDropShadow(shadowProperties(forHeight: bounds.height)) } else { removeDropShadow() } diff --git a/Source/ChromaControlStylable.swift b/Source/ChromaControlStylable.swift new file mode 100644 index 0000000..d30043c --- /dev/null +++ b/Source/ChromaControlStylable.swift @@ -0,0 +1,25 @@ +// +// ChromaControlStylable.swift +// ChromaColorPicker +// +// Created by Jon Cardasis on 4/13/19. +// Copyright © 2019 Jonathan Cardasis. All rights reserved. +// + +import UIKit + +internal protocol ChromaControlStylable { + var borderWidth: CGFloat { get set } + var borderColor: UIColor { get set } + var showsShadow: Bool { get set } + + func updateShadowIfNeeded() +} + +internal extension ChromaControlStylable where Self: UIView { + + func shadowProperties(forHeight height: CGFloat) -> ShadowProperties { + let dropShadowHeight = height * 0.01 + return ShadowProperties(color: UIColor.black.cgColor, opacity: 0.35, offset: CGSize(width: 0, height: dropShadowHeight), radius: 4) + } +} diff --git a/Source/ColorWheelView.swift b/Source/ColorWheelView.swift index e0fc9a8..e5fd9c0 100644 --- a/Source/ColorWheelView.swift +++ b/Source/ColorWheelView.swift @@ -56,11 +56,7 @@ public class ColorWheelView: UIView { the point does not exist within the bounds of the color wheel. */ public func pixelColor(at point: CGPoint) -> UIColor? { - guard bounds.offsetBy(dx: 1, dy: 1).contains(point) else { return nil } - - let distanceFromCenter: CGFloat = hypot(center.x - point.x, center.y - point.y) - let pointExistsInRadius: Bool = distanceFromCenter <= radius - guard pointExistsInRadius else { return nil } + guard pointIsInColorWheel(point) else { return nil } let pixel = UnsafeMutablePointer.allocate(capacity: 4) let colorSpace = CGColorSpaceCreateDeviceRGB() @@ -127,4 +123,12 @@ public class ColorWheelView: UIView { ]) return filter?.outputImage } + + internal func pointIsInColorWheel(_ point: CGPoint) -> Bool { + guard bounds.offsetBy(dx: 1, dy: 1).contains(point) else { return false } + + let distanceFromCenter: CGFloat = hypot(center.x - point.x, center.y - point.y) + let pointExistsInRadius: Bool = distanceFromCenter <= (radius - layer.borderWidth) + return pointExistsInRadius + } } diff --git a/Source/Helpers/SliderHandleView.swift b/Source/Helpers/SliderHandleView.swift new file mode 100644 index 0000000..74d03bd --- /dev/null +++ b/Source/Helpers/SliderHandleView.swift @@ -0,0 +1,75 @@ +// +// SliderHandleView.swift +// ChromaColorPicker +// +// Created by Jon Cardasis on 4/13/19. +// Copyright © 2019 Jonathan Cardasis. All rights reserved. +// + +import UIKit + +internal class SliderHandleView: UIView { + + var handleColor: UIColor = .black { + didSet { updateHandleColor(to: handleColor) } + } + + var borderWidth: CGFloat = 3.0 { + didSet { setNeedsLayout() } + } + + var borderColor: UIColor = .white { + didSet { setNeedsLayout() } + } + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + commonInit() + } + + override func layoutSubviews() { + let radius: CGFloat = bounds.height / 10 + handleLayer.path = makeRoundedTrianglePath(width: bounds.width, height: bounds.height, radius: radius) + handleLayer.strokeColor = borderColor.cgColor + handleLayer.lineWidth = borderWidth + handleLayer.position = CGPoint(x: bounds.width / 2, y: (bounds.height / 2.0) - (radius / 4.0)) + } + + // MARK: - Private + let handleLayer = CAShapeLayer() + + private struct CornerPoint { + let center: CGPoint + let startAngle: CGFloat + let endAngle: CGFloat + } + + private func commonInit() { + layer.addSublayer(handleLayer) + updateHandleColor(to: handleColor) + } + + private func updateHandleColor(to color: UIColor) { + handleLayer.fillColor = color.cgColor + } + + private func makeRoundedTrianglePath(width: CGFloat, height: CGFloat, radius: CGFloat) -> CGPath { + let point1 = CGPoint(x: -width / 2, y: height / 2) + let point2 = CGPoint(x: 0, y: -height / 2) + let point3 = CGPoint(x: width / 2, y: height / 2) + + let path = CGMutablePath() + path.move(to: CGPoint(x: 0, y: height / 2)) + path.addArc(tangent1End: point1, tangent2End: point2, radius: radius) + path.addArc(tangent1End: point2, tangent2End: point3, radius: radius) + path.addArc(tangent1End: point3, tangent2End: point1, radius: radius) + path.closeSubpath() + + return path + } +} diff --git a/Source/Helpers/SliderTrackView.swift b/Source/Helpers/SliderTrackView.swift new file mode 100644 index 0000000..7200e26 --- /dev/null +++ b/Source/Helpers/SliderTrackView.swift @@ -0,0 +1,49 @@ +// +// SliderTrackView.swift +// ChromaColorPicker +// +// Created by Jon Cardasis on 4/13/19. +// Copyright © 2019 Jonathan Cardasis. All rights reserved. +// + +import UIKit + +internal class SliderTrackView: UIView { + typealias GradientValues = (start: UIColor, end: UIColor) + + var gradientValues: GradientValues = (.white, .black) { + didSet { updateGradient(for: gradientValues) } + } + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + commonInit() + } + + override func layoutSubviews() { + super.layoutSubviews() + gradient.frame = layer.bounds + gradient.cornerRadius = layer.cornerRadius + } + + func updateGradient(for values: GradientValues) { + gradient.colors = [values.start.cgColor, values.end.cgColor] + } + + // MARK: - Private + private let gradient = CAGradientLayer() + + private func commonInit() { + gradient.masksToBounds = true + gradient.actions = ["position" : NSNull(), "bounds" : NSNull(), "path" : NSNull()] + gradient.startPoint = CGPoint(x: 0, y: 0.5) + gradient.endPoint = CGPoint(x: 1, y: 0.5) + updateGradient(for: gradientValues) + layer.addSublayer(gradient) + } +} diff --git a/Tests/ColorWheelViewTests.swift b/Tests/ColorWheelViewTests.swift index dfd3ff8..179691c 100644 --- a/Tests/ColorWheelViewTests.swift +++ b/Tests/ColorWheelViewTests.swift @@ -84,6 +84,18 @@ class ColorWheelViewTests: XCTestCase { XCTAssertNil(subject.pixelColor(at: testPoint)) } + func testPixelColorShouldReturnNilForPointOnBorder() { + // Given + subject.layer.borderWidth = 20 + subject.layer.borderColor = UIColor.white.cgColor + + // When + let testPoint = CGPoint(x: subject.bounds.width - 10, y: subject.frame.midY) + + // Then + XCTAssertNil(subject.pixelColor(at: testPoint)) + } + func testPixelColorShouldBeRedAtMaxXMidY() { // Given, When let size = subject.frame.size