Kira
Kira

Reputation: 1603

Angled Gradient Layer

I have custom UIView class that renders a gradient in Swift 2. I'm struggling with making an angled gradient so that it draws from the top-left to the bottom-right. Can somebody help me a bit?

import UIKit

class GradientView: UIView {

    let gradientLayer = CAGradientLayer()

    override func awakeFromNib() {
        // 1
        self.backgroundColor = ColorPalette.White

        // 2
        gradientLayer.frame = self.bounds

        // 3
        let color1 = ColorPalette.GrdTop.CGColor as CGColorRef
        let color2 = ColorPalette.GrdBottom.CGColor as CGColorRef
        gradientLayer.colors = [color1, color2]

        // 4
        gradientLayer.locations = [0.0, 1.0]

        // 5
        self.layer.addSublayer(gradientLayer)
    }

}

I suspect this should be something else but whatever I input nothing changes.

gradientLayer.locations = [0.0, 1.0]

Upvotes: 12

Views: 18018

Answers (5)

Rob
Rob

Reputation: 437632

You don't want to use locations to specify the direction of the gradient. Instead use startPoint and endPoint for that.

The locations array is used when one wants to specify where, in between startPoint and endPoint, the gradient should to take place. For example, if you want the colors to only take place in the middle 10% of the range from the start and end points, you'd use:

locations = [0.45, 0.55]

The locations array doesn't dictate the direction. The startPoint and endPoint do. So, for a diagonal gradient from upper left to lower right, you would set startPoint of CGPoint(x: 0, y: 0) and an endPoint to CGPoint(x: 1, y: 1).

For example:

@IBDesignable
class GradientView: UIView {

    override class var layerClass: AnyClass { return CAGradientLayer.self }

    private var gradientLayer: CAGradientLayer { return layer as! CAGradientLayer }

    @IBInspectable var color1: UIColor = .white { didSet { updateColors() } }
    @IBInspectable var color2: UIColor = .blue  { didSet { updateColors() } }

    override init(frame: CGRect = .zero) {
        super.init(frame: frame)
        configureGradient()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        configureGradient()
    }

    private func configureGradient() {
        gradientLayer.startPoint = CGPoint(x: 0, y: 0)
        gradientLayer.endPoint = CGPoint(x: 1, y: 1)
        updateColors()
    }

    private func updateColors() {
        gradientLayer.colors = [color1.cgColor, color2.cgColor]
    }

}

E.g.

enter image description here

Note, unrelated to the immediate issue:

  • If you’re going to add the gradient as a sublayer, you want to update this sublayer’s frame in layoutSubviews so that as the view's bounds changes, so does the frame of the gradientLayer. But, better than that, override the layerClass of the view, and it will not only instantiate the CAGradientLayer for you, but you also enjoy dynamic adjustments of the gradient as the view’s size changes, notably handling animated changes more gracefully.

  • Likewise, I set color1 and color2 such that they'll trigger an updating of the gradient, so that any changes in colors will be immediately reflected in the view.

  • I made this @IBDesignable, so that if I drop this in its own framework, and then add the GradientView in IB, I'll see the effect rendered in IB.

For Swift 2 implementation, see previous revision of this answer.

Upvotes: 30

Noah Wilder
Noah Wilder

Reputation: 1574

Gradient Start & End Points for Any Angle

Swift 4.2, Xcode 10.0

Given any angle, my code will set the respective start point and end point of a gradient layer.

If an angle greater than 360° is inputted, it will use the remainder when divided by 360.

  • An input of 415° would yield the same result as an input of 55°

If an angle less than is inputted, it will reverse the clockwise direction of rotation

  • An input of -15° would yield the same result as an input of 345°

Code:

public extension CAGradientLayer {

    /// Sets the start and end points on a gradient layer for a given angle.
    ///
    /// - Important:
    /// *0°* is a horizontal gradient from left to right.
    ///
    /// With a positive input, the rotational direction is clockwise.
    ///
    ///    * An input of *400°* will have the same output as an input of *40°*
    ///
    /// With a negative input, the rotational direction is clockwise.
    ///
    ///    * An input of *-15°* will have the same output as *345°*
    ///
    /// - Parameters:
    ///     - angle: The angle of the gradient.
    ///                  
    public func calculatePoints(for angle: CGFloat) {


        var ang = (-angle).truncatingRemainder(dividingBy: 360)

        if ang < 0 { ang = 360 + ang }

        let n: CGFloat = 0.5

        switch ang {

        case 0...45, 315...360:
            let a = CGPoint(x: 0, y: n * tanx(ang) + n)
            let b = CGPoint(x: 1, y: n * tanx(-ang) + n)
            startPoint = a
            endPoint = b

        case 45...135:
            let a = CGPoint(x: n * tanx(ang - 90) + n, y: 1)
            let b = CGPoint(x: n * tanx(-ang - 90) + n, y: 0)
            startPoint = a
            endPoint = b

        case 135...225:
            let a = CGPoint(x: 1, y: n * tanx(-ang) + n)
            let b = CGPoint(x: 0, y: n * tanx(ang) + n)
           startPoint = a
            endPoint = b

        case 225...315:
            let a = CGPoint(x: n * tanx(-ang - 90) + n, y: 0)
            let b = CGPoint(x: n * tanx(ang - 90) + n, y: 1)
            startPoint = a
            endPoint = b

        default:
            let a = CGPoint(x: 0, y: n)
            let b = CGPoint(x: 1, y: n)
            startPoint = a
            endPoint = b

        }
    }

    /// Private function to aid with the math when calculating the gradient angle
    private func tanx(_ 𝜽: CGFloat) -> CGFloat {
        return tan(𝜽 * CGFloat.pi / 180)
    }


    // Overloads

    /// Sets the start and end points on a gradient layer for a given angle.
    public func calculatePoints(for angle: Int) {
        calculatePoints(for: CGFloat(angle))
    }

    /// Sets the start and end points on a gradient layer for a given angle.
    public func calculatePoints(for angle: Float) {
        calculatePoints(for: CGFloat(angle))
    }

    /// Sets the start and end points on a gradient layer for a given angle.
    public func calculatePoints(for angle: Double) {
        calculatePoints(for: CGFloat(angle))
    }

}

Usage:

let gradientLayer = CAGradientLayer()

// Setup gradient layer...

// Gradient Direction: →
gradient.calculatePoints(for: 0)

// Gradient Direction: ↗︎
gradient.calculatePoints(for: -45)

// Gradient Direction: ←
gradient.calculatePoints(for: 180)

// Gradient Direction: ↓
gradient.calculatePoints(for: 450)

Mathematical Explanation

So I actually just recently spent a lot of time trying to answer this myself. Here are some example angles just to help understand and visualize the clockwise direction of rotation.

Example Angles

If you are interested in how I figured it out, I made a table to visualize essentially what I am doing from - 360°.

Table

Upvotes: 27

Echelon
Echelon

Reputation: 7922

An angled gradient can be achieved using some basic trigonometry. You can achieve it by subclassing UIView, as I describe in my blog post on the subject.

First define some variables:-

// The end point of the gradient when drawn in the layer’s coordinate space. Animatable.
var endPoint: CGPoint

// The start point of the gradient when drawn in the layer’s coordinate space. Animatable.
var startPoint: CGPoint

// the gradient angle, in degrees anticlockwise from 0 (east/right)
@IBInspectable var angle: CGFloat = 270

The core function, below, gets the start and end points in unit space.

// create vector pointing in direction of angle
private func gradientPointsForAngle(_ angle: CGFloat) -> (CGPoint, CGPoint) {
    // get vector start and end points
    let end = pointForAngle(angle)
    let start = oppositePoint(end)
    // convert to gradient space
    let p0 = transformToGradientSpace(start)
    let p1 = transformToGradientSpace(end)
    return (p0, p1)
}

This simply takes the angle that the user specified and uses it to create a vector pointing in that direction. The angle specifies the rotation of the vector from 0 degrees, which by convention points east in Core Animation, and increases anti-clockwise (counter-clockwise).

The rest of the relevant code is below, and is concerned with the fact that the resulting point is on the unit circle. The points we need, however, are in a unit square: the vector is extrapolated to the unit square.

private func pointForAngle(_ angle: CGFloat) -> CGPoint {
    // convert degrees to radians
    let radians = angle * .pi / 180.0
    var x = cos(radians)
    var y = sin(radians)
    // (x,y) is in terms unit circle. Extrapolate to unit square to get full vector length
    if (fabs(x) > fabs(y)) {
        // extrapolate x to unit length
        x = x > 0 ? 1 : -1
        y = x * tan(radians)
    } else {
        // extrapolate y to unit length
        y = y > 0 ? 1 : -1
        x = y / tan(radians)
    }
    return CGPoint(x: x, y: y)
}

private func oppositePoint(_ point: CGPoint) -> CGPoint {
    return CGPoint(x: -point.x, y: -point.y)
}

private func transformToGradientSpace(_ point: CGPoint) -> CGPoint {
    // input point is in signed unit space: (-1,-1) to (1,1)
    // convert to gradient space: (0,0) to (1,1), with flipped Y axis
    return CGPoint(x: (point.x + 1) * 0.5, y: 1.0 - (point.y + 1) * 0.5)
}

And ultimately everything must be called from an update function:-

private func updateGradient() {
    if let gradient = self.gradient {
        let (start, end) = gradientPointsForAngle(self.angle)
        gradient.startPoint = start
        gradient.endPoint = end
        gradient.frame = self.bounds
    }
}

For the full implementation please see my blog post.

Upvotes: 0

ZGski
ZGski

Reputation: 2538

It looks like you forgot to set the startPoint on your CAGradientLayer(). The code below is the code you've provide, plus my addition.

import UIKit

class GradientView: UIView {

    let gradientLayer = CAGradientLayer()

    override func awakeFromNib() {
        // 1
        self.backgroundColor = ColorPalette.White

        // 2
        gradientLayer.frame = self.bounds

        // 3
        let color1 = ColorPalette.GrdTop.CGColor as CGColorRef
        let color2 = ColorPalette.GrdBottom.CGColor as CGColorRef
        gradientLayer.colors = [color1, color2]

        //** This code should do the trick... **//
        gradientLayer.startPoint = CGPointMake(0.0, 0.5)

        // 4
        gradientLayer.locations = [0.0, 1.0]

        // 5
        self.layer.addSublayer(gradientLayer)
    }
}

Upvotes: 4

Travis Griggs
Travis Griggs

Reputation: 22252

I'm not sure what's making yours not work, but I do have a GradientView that I use that can be horizontal or vertical, and works with the ui builder stuff. Feel free to run with it and refine for your needs:

import UIKit

@IBDesignable  class  GradientView: UIView {
    var gradient:CAGradientLayer
    @IBInspectable var startColor:UIColor = UIColor.whiteColor() {
        didSet {
            self.updateGradient()
        }
    }

    @IBInspectable var color1:UIColor? = nil {
        didSet {
            self.updateGradient()
        }
    }

    @IBInspectable var stop1:Double = (1.0 / 3.0) {
        didSet {
            self.updateGradient()
        }
    }

    @IBInspectable var color2:UIColor? = nil {
        didSet {
            self.updateGradient()
        }
    }

    @IBInspectable var stop2:Double = (2.0 / 3.0) {
        didSet {
            self.updateGradient()
        }
    }

    @IBInspectable var endColor:UIColor = UIColor.blackColor() {
        didSet {
            self.updateGradient()
        }
    }

    @IBInspectable var isHorizontal:Bool {
        get {
            return self.gradient.endPoint.y == self.gradient.startPoint.y
        }
        set {
            self.gradient.endPoint = newValue ? CGPoint(x: 1, y: 0) : CGPoint(x: 0, y: 1)
        }
    }

    override init(frame: CGRect) {
        gradient = CAGradientLayer()
        super.init(frame: frame)
        self.configGradient()
    }

    required init?(coder aDecoder: NSCoder) {
        gradient = CAGradientLayer()
        super.init(coder: aDecoder)
        self.configGradient()
    }

    func configGradient() {
        self.backgroundColor = UIColor.clearColor()
        self.layer.insertSublayer(self.gradient, atIndex: 0)
        self.gradient.masksToBounds = true
        self.gradient.frame = self.bounds
        self.gradient.startPoint = CGPoint(x: 0, y: 0)
        self.gradient.endPoint = CGPoint(x: 1, y: 0)
        self.updateGradient()
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        self.gradient.frame = self.bounds
    }

    func updateGradient() {
        var colors:[CGColorRef] = []
        var locations:[NSNumber] = []
        colors.append(self.startColor.CGColor)
        locations.append(0.0.nsNumber)

        if let color = self.color1 {
            colors.append(color.CGColor)
            locations.append(self.stop1)}

        if let color = self.color2 {
            colors.append(color.CGColor)
            locations.append(self.stop2)
        }

        colors.append(self.endColor.CGColor)
        locations.append(1.0.nsNumber)

        self.gradient.colors = colors
        self.gradient.locations = locations

        self.layer.setNeedsDisplay()
    }
}

Upvotes: 3

Related Questions