diff --git a/Example/SwiftGradients/Base.lproj/Main.storyboard b/Example/SwiftGradients/Base.lproj/Main.storyboard index c94a02c..5adc8e7 100644 --- a/Example/SwiftGradients/Base.lproj/Main.storyboard +++ b/Example/SwiftGradients/Base.lproj/Main.storyboard @@ -25,11 +25,20 @@ + + + diff --git a/Example/SwiftGradients/ViewController.swift b/Example/SwiftGradients/ViewController.swift index 05f344b..600731d 100644 --- a/Example/SwiftGradients/ViewController.swift +++ b/Example/SwiftGradients/ViewController.swift @@ -3,11 +3,44 @@ import SwiftGradients class ViewController: UIViewController { + var gradientLayer: CAGradientLayer! + override func viewDidLoad() { super.viewDidLoad() - view.addGradient( + gradientLayer = view.addGradient( colors: [.beachBlue, .limeGreen], direction: .bottomToTop ) } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + let randomChange = arc4random_uniform(4) + switch randomChange { + case 0: + self.gradientLayer.uiColors = [UIColor.random, UIColor.random] + case 1: + let initialStop = Int(arc4random_uniform(100)) + gradientLayer.percentLocations = [ + initialStop, + initialStop + Int(arc4random_uniform(UInt32(100 - initialStop))) + ] + case 2: + gradientLayer.direction = .rightToLeft + case 3: + gradientLayer.angle = CGFloat(arc4random_uniform(360)) + default: + break + } + } +} + +extension UIColor { + static var random: UIColor { + return UIColor( + red: CGFloat(arc4random_uniform(255)) / 255, + green: CGFloat(arc4random_uniform(255)) / 255, + blue: CGFloat(arc4random_uniform(255)) / 255, + alpha: 1 + ) + } } diff --git a/Sources/CAGradientLayerExtension.swift b/Sources/CAGradientLayerExtension.swift index 48fe945..32ce055 100644 --- a/Sources/CAGradientLayerExtension.swift +++ b/Sources/CAGradientLayerExtension.swift @@ -11,76 +11,125 @@ import Foundation import UIKit extension CAGradientLayer { - class func startPointFor(_ angle: Double) -> CGPoint { - if let defaultDirection = GradientDirection(rawValue: angle) { - switch defaultDirection { - case .topToBottom: - return CGPoint(x: 0.5, y: 0.0) - case .bottomToTop: - return CGPoint(x: 0.5, y: 1.0) - case .leftToRight: - return CGPoint(x: 0.0, y: 0.5) - default: - return CGPoint(x: 1.0, y: 0.5) + //MARK: Attributes accessors + + /// Collection of UIColors used in the gradient. + public var uiColors: [UIColor] { + get { + guard let anyColors = colors else { return [] } + return anyColors.map { color in + CFGetTypeID(color as CFTypeRef) == CGColor.typeID ? + UIColor(cgColor: color as! CGColor) : + UIColor.clear } } - return pointWithAngle(angle) + set { + colors = newValue.map { $0.cgColor } + } } - - class func endPointFor(_ angle: Double) -> CGPoint { - if let defaultDirection = GradientDirection(rawValue: angle) { - switch defaultDirection { - case .topToBottom: - return CGPoint(x: 0.5, y: 1.0) - case .bottomToTop: - return CGPoint(x: 0.5, y: 0.0) - case .leftToRight: - return CGPoint(x: 1.0, y: 0.5) - default: - return CGPoint(x: 0.0, y: 0.5) + + /// Color stop locations in percentages. + public var percentLocations: [Int] { + get { + guard let decimalLocations = locations else { return [] } + return decimalLocations.map { Int(exactly: $0.floatValue * 100) ?? 0 } + } + set{ + locations = newValue.map { NSNumber(value: Float($0) / 100.0) } + } + } + + /// Predefined gradient direction if specified or if the current angle + /// matches any of the GradientDirection cases. + public var direction: GradientDirection? { + get { + if + let direction = GradientDirection.allCases.first(where: { direction in + abs(direction.startPoint.x - startPoint.x) <= .ulpOfOne && + abs(direction.startPoint.y - startPoint.y) <= .ulpOfOne && + abs(direction.endPoint.x - endPoint.x) <= .ulpOfOne && + abs(direction.endPoint.y - endPoint.y) <= .ulpOfOne + }) + { + return direction } + return nil + } + set { + guard let newDirection = newValue else { return } + startPoint = newDirection.startPoint + endPoint = newDirection.endPoint + } + } + + /// The gradient angle in degrees, measured clockwise and starting at the left. + /// 0 -> left, 90 -> up, etc + public var angle: CGFloat { + get { + let product = endPoint.y - startPoint.y + let determinant = endPoint.x - startPoint.x + var degrees = CGFloat(atan2(product, determinant)) * 180 / CGFloat.pi + if degrees < 0 { degrees = 360 + degrees } + + return degrees.truncatingRemainder(dividingBy: 360) } + set { + startPoint = CAGradientLayer.startPointFor(newValue) + endPoint = CAGradientLayer.endPointFor(newValue) + } + } + + //MARK: Angle and points helpers + + class func startPointFor(_ angle: CGFloat) -> CGPoint { + return pointWithAngle(angle) + } + + class func endPointFor(_ angle: CGFloat) -> CGPoint { return pointWithAngle(angle, isStartPoint: false) } - /// **pointWithAngle**: Helper for CAGradientLayer's start and endPoint given an angle in degrees - /// - Parameter **angle** The desired angle in degrees and measured anti-clockwise. + /// **pointWithAngle**: Helper for CAGradientLayer's start and endPoint + /// given an angle in degrees + /// - Parameter **angle** The desired angle in degrees, measured clockwise + /// and starting at the left. /// - Parameter **isStartPoint** A boolean indicating which point you need. - /// - Returns: The initial or ending CGPoint for a CAGradientLayer within the Unit Cordinate System. + /// - Returns: The initial or ending CGPoint for a CAGradientLayer + /// within the Unit Cordinate System. private class func pointWithAngle( - _ angle: Double, + _ angle: CGFloat, isStartPoint: Bool = true ) -> CGPoint { - // negative angles not allowed - var positiveAngle = angle < 0 ? angle * -1.0 : angle - var y1: Double, y2: Double, x1: Double, x2: Double + var ang = (-angle).truncatingRemainder(dividingBy: 360) + if ang < 0 { ang = 360 + ang } + let n: CGFloat = 0.5 - if // ranges when we know Y values - (positiveAngle >= 45 && positiveAngle <= 135) || - (positiveAngle >= 225 && positiveAngle <= 315) - { - y1 = positiveAngle < 180 ? 0.0 : 1.0 - y2 = 1.0 - y1 //opposite to start Y - x1 = positiveAngle >= 45 && positiveAngle <= 135 ? - 1.5 - positiveAngle / 90 : - abs(2.5 - positiveAngle / 90) - x2 = 1.0-x1 //opposite to start X - } else { // ranges when we know X values - x1 = positiveAngle < 45 || positiveAngle >= 315 ? 1.0 : 0.0 - x2 = 1.0 - x1 - if positiveAngle > 135 && positiveAngle < 225 { - y2 = abs(2.5 - positiveAngle / 90) - y1 = 1.0 - y2 - } else { // Range 0-45 315-360 - //Turn this ranges into one single 90 degrees range - positiveAngle = positiveAngle >= 0 && positiveAngle <= 45 ? - 45.0 - positiveAngle : - 360 - positiveAngle + 45 - y1 = positiveAngle / 90 - y2 = 1.0 - y1 - } + switch ang { + case 0...45, 315...360: + return isStartPoint ? + CGPoint(x: 0, y: n * tanx(ang) + n) : + CGPoint(x: 1, y: n * tanx(-ang) + n) + case 45...135: + return isStartPoint ? + CGPoint(x: n * tanx(ang - 90) + n, y: 1) : + CGPoint(x: n * tanx(-ang - 90) + n, y: 0) + case 135...225: + return isStartPoint ? + CGPoint(x: 1, y: n * tanx(-ang) + n) : + CGPoint(x: 0, y: n * tanx(ang) + n) + case 225...315: + return isStartPoint ? + CGPoint(x: n * tanx(-ang - 90) + n, y: 0) : + CGPoint(x: n * tanx(ang - 90) + n, y: 1) + default: + return isStartPoint ? + CGPoint(x: 0, y: n) : + CGPoint(x: 1, y: n) } - return isStartPoint ? CGPoint(x: x1, y: y1) : CGPoint(x: x2, y: y2) + } + + private class func tanx(_ 𝜽: CGFloat) -> CGFloat { + return tan(𝜽 * CGFloat.pi / 180) } } diff --git a/Sources/CALayerGradientsExtension.swift b/Sources/CALayerGradientsExtension.swift index 62faa9a..9362263 100644 --- a/Sources/CALayerGradientsExtension.swift +++ b/Sources/CALayerGradientsExtension.swift @@ -29,7 +29,7 @@ public extension CALayer { @discardableResult func addGradient( colors: [UIColor], - angle: Double, + angle: CGFloat, locations: [Int] = [] ) -> CAGradientLayer { return addGradient( @@ -57,12 +57,12 @@ public extension CALayer { insertSublayer(gradient, at: 0) } gradient.frame = bounds - gradient.colors = colors.map { $0.cgColor } + gradient.uiColors = colors gradient.startPoint = startPoint gradient.endPoint = endPoint if !locations.isEmpty { - gradient.locations = locations.map { NSNumber(value: Float($0) / 100.0) } + gradient.percentLocations = locations } return gradient } diff --git a/Sources/UIViewGradientsExtension.swift b/Sources/UIViewGradientsExtension.swift index 631027e..0a332bc 100644 --- a/Sources/UIViewGradientsExtension.swift +++ b/Sources/UIViewGradientsExtension.swift @@ -10,11 +10,37 @@ import Foundation #if canImport(UIKit) import UIKit -public enum GradientDirection: Double { - case topToBottom = 90.0 - case bottomToTop = 270.0 - case leftToRight = 180.0 - case rightToLeft = 0.0 +public enum GradientDirection: CGFloat, CaseIterable { + case leftToRight = 0 + case topToBottom = 90 + case rightToLeft = 180 + case bottomToTop = 270 + + var startPoint: CGPoint { + switch self { + case .topToBottom: + return CGPoint(x: 0.5, y: 0.0) + case .bottomToTop: + return CGPoint(x: 0.5, y: 1.0) + case .leftToRight: + return CGPoint(x: 0.0, y: 0.5) + case .rightToLeft: + return CGPoint(x: 1.0, y: 0.5) + } + } + + var endPoint: CGPoint { + switch self { + case .topToBottom: + return CGPoint(x: 0.5, y: 1.0) + case .bottomToTop: + return CGPoint(x: 0.5, y: 0.0) + case .leftToRight: + return CGPoint(x: 1.0, y: 0.5) + case .rightToLeft: + return CGPoint(x: 0.0, y: 0.5) + } + } } public extension UIView { @@ -35,7 +61,7 @@ public extension UIView { @discardableResult func addGradient( colors: [UIColor], - angle: Double, + angle: CGFloat, locations: [Int] = [] ) -> CAGradientLayer { return addGradient( @@ -73,7 +99,7 @@ public extension Array where Element: UIView { } } - func addGradient(colors: [UIColor], angle: Double, locations: [Int] = []) { + func addGradient(colors: [UIColor], angle: CGFloat, locations: [Int] = []) { for view in self { view.addGradient(colors: colors, angle: angle, locations: locations) } diff --git a/SwiftGradientsTests/SwiftGradientsTests.swift b/SwiftGradientsTests/SwiftGradientsTests.swift index 57d0054..d1f0837 100644 --- a/SwiftGradientsTests/SwiftGradientsTests.swift +++ b/SwiftGradientsTests/SwiftGradientsTests.swift @@ -65,19 +65,88 @@ class SwiftGradientsTests: XCTestCase { func testGradientAngleCalculation() { let colors: [UIColor] = [.yellow, .green] var gradientLayer = view.addGradient(colors: colors, angle: 90) - XCTAssert(gradientLayer.startPoint == CGPoint(x: 0.5, y: 0)) - XCTAssert(gradientLayer.endPoint == CGPoint(x: 0.5, y: 1)) + XCTAssertEqual(gradientLayer.startPoint.x, 0.5, accuracy: .ulpOfOne) + XCTAssertEqual(gradientLayer.endPoint.x, 0.5, accuracy: .ulpOfOne) + XCTAssert(gradientLayer.startPoint.y == 0) + XCTAssert(gradientLayer.endPoint.y == 1) gradientLayer = view.addGradient(colors: colors, angle: 270) - XCTAssert(gradientLayer.startPoint == CGPoint(x: 0.5, y: 1)) - XCTAssert(gradientLayer.endPoint == CGPoint(x: 0.5, y: 0)) + XCTAssertEqual(gradientLayer.startPoint.x, 0.5, accuracy: .ulpOfOne) + XCTAssertEqual(gradientLayer.endPoint.x, 0.5, accuracy: .ulpOfOne) + XCTAssert(gradientLayer.startPoint.y == 1) + XCTAssert(gradientLayer.endPoint.y == 0) gradientLayer = view.addGradient(colors: colors, angle: 180) + XCTAssertEqual(gradientLayer.startPoint.y, 0.5, accuracy: .ulpOfOne) + XCTAssertEqual(gradientLayer.endPoint.y, 0.5, accuracy: .ulpOfOne) + XCTAssert(gradientLayer.startPoint.x == 1) + XCTAssert(gradientLayer.endPoint.x == 0) + + gradientLayer = view.addGradient(colors: colors, angle: 0) XCTAssert(gradientLayer.startPoint == CGPoint(x: 0, y: 0.5)) XCTAssert(gradientLayer.endPoint == CGPoint(x: 1, y: 0.5)) + } + + //MARK: Accessors + + func testUIColorAccessor() { + let locations = [50, 70] + let finalColors: [UIColor] = [.white, .black] + let gradientLayer = view.addGradient( + colors: [.blue, .red], + locations: locations + ) + gradientLayer.uiColors = finalColors + let uiColors = gradientLayer.uiColors + XCTAssert(uiColors == finalColors) + } + + func testPercentLocationsAccessor() { + let initialStop = Int(arc4random_uniform(100)) + let locations = [ + initialStop, + initialStop + Int(arc4random_uniform(UInt32(100 - initialStop))) + ] + let gradientLayer = view.addGradient(colors: [.white, .black]) + gradientLayer.percentLocations = locations + XCTAssert(locations == gradientLayer.percentLocations) + } + + func testDirectionAccessor() { + guard let direction = GradientDirection.allCases.randomElement() else { + XCTFail("Invalid gradient direction case.") + return + } + + let gradientLayer = view.addGradient(colors: [.purple, .cyan]) + gradientLayer.direction = direction + XCTAssert(direction == gradientLayer.direction) - gradientLayer = view.addGradient(colors: colors, angle: 0) - XCTAssert(gradientLayer.startPoint == CGPoint(x: 1, y: 0.5)) - XCTAssert(gradientLayer.endPoint == CGPoint(x: 0, y: 0.5)) + gradientLayer.angle = 0 + XCTAssert(gradientLayer.direction == .leftToRight) + gradientLayer.angle = 90 + XCTAssert(gradientLayer.direction == .topToBottom) + gradientLayer.angle = 180 + XCTAssert(gradientLayer.direction == .rightToLeft) + gradientLayer.angle = 270 + XCTAssert(gradientLayer.direction == .bottomToTop) + gradientLayer.angle = 360 + XCTAssert(gradientLayer.direction == .leftToRight) + } + + func testAngleAccessor() { + let gradientLayer = view.addGradient(colors: [.brown, .orange]) + gradientLayer.angle = 0 + XCTAssert(gradientLayer.angle == 0) + gradientLayer.angle = 90 + XCTAssertEqual(gradientLayer.angle, 90, accuracy: 0.001) + gradientLayer.angle = 180 + XCTAssertEqual(gradientLayer.angle, 180, accuracy: 0.001) + gradientLayer.angle = 270 + XCTAssertEqual(gradientLayer.angle, 270, accuracy: 0.001) + gradientLayer.angle = 360 + XCTAssert(gradientLayer.angle == 0) + gradientLayer.angle = 450 + XCTAssertEqual(gradientLayer.angle, 90, accuracy: 0.001) } }