Angled Gradient Layer

Angled Gradient Layer

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.

Sample Image

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.

How to fill a CAShapeLayer with an angled gradient

Why not use CAGradientLayer which has startPoint and endPoint properties.

You can do:

import UIKit
import PlaygroundSupport

let frame = CGRect(x: 0, y: 0, width: 100, height: 100)
let view = UIView(frame: frame)

PlaygroundPage.current.liveView = view

let path = UIBezierPath(ovalIn: frame)

let shape = CAShapeLayer()
shape.frame = frame
shape.path = path.cgPath
shape.fillColor = UIColor.blue.cgColor

let gradient = CAGradientLayer()
gradient.frame = frame
gradient.colors = [UIColor.blue.cgColor,
UIColor.red.cgColor]
gradient.startPoint = CGPoint(x: 0, y: 1)
gradient.endPoint = CGPoint(x: 1, y: 0)
gradient.mask = shape

view.layer.addSublayer(gradient)

Sample Image

Note: Added a bezier path for a circle, because it would work without the mask for the square.

CAGradientLayer with Angle

The start and end points of a linear gradient are specified in points relative to the whole size of the layer, with (0,0) at the top left and (1,1) at the bottom right.

Therefore, to make a linear gradient at an angle, you simply need to set the start and end points appropriately. For example, If you used (0,0) and (1,1) as the start and end points, the gradient would run from the top left to the bottom right, at a 45 degree angle.

Working out the specific start and end points for your needs is therefore just a matter of trigonometry.

CAGradientLayer diagonal gradient

Update: Use context.drawLinearGradient() instead of CAGradientLayer in a manner similar to the following. It will draw gradients that are consistent with Sketch/Photoshop.

If you absolutely must use CAGradientLayer, then here is the math you'll need to use...


It took some time to figure out, but from careful observation, I found out that Apple's implementation of gradients in CAGradientLayer is pretty odd:

  1. First it converts the view to a square.
  2. Then it applies the gradient using start/end points.
  3. The middle gradient will indeed form a 90 degree angle in this resolution.
  4. Finally, it squishes the view down to the original size.

This means that the middle gradient will no longer form a 90 degree angle in the new size. This contradicts the behavior of virtually every other paint application: Sketch, Photoshop, etc.

If you want to implement start/end points as it works in Sketch, you'll need to translate the start/end points to account for the fact that Apple is going to squish the view.


Steps to perform (Diagrams)

Sample Image
Sample Image
Sample Image

Code

import UIKit

/// Last updated 4/3/17.
/// See https://stackoverflow.com/a/43176174 for more information.
public enum LinearGradientFixer {
public static func fixPoints(start: CGPoint, end: CGPoint, bounds: CGSize) -> (CGPoint, CGPoint) {
// Naming convention:
// - a: point a
// - ab: line segment from a to b
// - abLine: line that passes through a and b
// - lineAB: line that passes through A and B
// - lineSegmentAB: line segment that passes from A to B

if start.x == end.x || start.y == end.y {
// Apple's implementation of horizontal and vertical gradients works just fine
return (start, end)
}

// 1. Convert to absolute coordinates
let startEnd = LineSegment(start, end)
let ab = startEnd.multiplied(multipliers: (x: bounds.width, y: bounds.height))
let a = ab.p1
let b = ab.p2

// 2. Calculate perpendicular bisector
let cd = ab.perpendicularBisector

// 3. Scale to square coordinates
let multipliers = calculateMultipliers(bounds: bounds)
let lineSegmentCD = cd.multiplied(multipliers: multipliers)

// 4. Create scaled perpendicular bisector
let lineSegmentEF = lineSegmentCD.perpendicularBisector

// 5. Unscale back to rectangle
let ef = lineSegmentEF.divided(divisors: multipliers)

// 6. Extend line
let efLine = ef.line

// 7. Extend two lines from a and b parallel to cd
let aParallelLine = Line(m: cd.slope, p: a)
let bParallelLine = Line(m: cd.slope, p: b)

// 8. Find the intersection of these lines
let g = efLine.intersection(with: aParallelLine)
let h = efLine.intersection(with: bParallelLine)

if let g = g, let h = h {
// 9. Convert to relative coordinates
let gh = LineSegment(g, h)
let result = gh.divided(divisors: (x: bounds.width, y: bounds.height))
return (result.p1, result.p2)
}
return (start, end)
}

private static func unitTest() {
let w = 320.0
let h = 60.0
let bounds = CGSize(width: w, height: h)
let a = CGPoint(x: 138.5, y: 11.5)
let b = CGPoint(x: 151.5, y: 53.5)
let ab = LineSegment(a, b)
let startEnd = ab.divided(divisors: (x: bounds.width, y: bounds.height))
let start = startEnd.p1
let end = startEnd.p2

let points = fixPoints(start: start, end: end, bounds: bounds)

let pointsSegment = LineSegment(points.0, points.1)
let result = pointsSegment.multiplied(multipliers: (x: bounds.width, y: bounds.height))

print(result.p1) // expected: (90.6119039567129, 26.3225059181603)
print(result.p2) // expected: (199.388096043287, 38.6774940818397)
}
}

private func calculateMultipliers(bounds: CGSize) -> (x: CGFloat, y: CGFloat) {
if bounds.height <= bounds.width {
return (x: 1, y: bounds.width/bounds.height)
} else {
return (x: bounds.height/bounds.width, y: 1)
}
}

private struct LineSegment {
let p1: CGPoint
let p2: CGPoint

init(_ p1: CGPoint, _ p2: CGPoint) {
self.p1 = p1
self.p2 = p2
}

init(p1: CGPoint, m: CGFloat, distance: CGFloat) {
self.p1 = p1

let line = Line(m: m, p: p1)
let measuringPoint = line.point(x: p1.x + 1)
let measuringDeltaH = LineSegment(p1, measuringPoint).distance

let deltaX = distance/measuringDeltaH
self.p2 = line.point(x: p1.x + deltaX)
}

var length: CGFloat {
let dx = p2.x - p1.x
let dy = p2.y - p1.y
return sqrt(dx * dx + dy * dy)
}
var distance: CGFloat {
return p1.x <= p2.x ? length : -length
}
var midpoint: CGPoint {
return CGPoint(x: (p1.x + p2.x)/2, y: (p1.y + p2.y)/2)
}
var slope: CGFloat {
return (p2.y-p1.y)/(p2.x-p1.x)
}
var perpendicularSlope: CGFloat {
return -1/slope
}
var line: Line {
return Line(p1, p2)
}
var perpendicularBisector: LineSegment {
let p1 = LineSegment(p1: midpoint, m: perpendicularSlope, distance: -distance/2).p2
let p2 = LineSegment(p1: midpoint, m: perpendicularSlope, distance: distance/2).p2
return LineSegment(p1, p2)
}

func multiplied(multipliers: (x: CGFloat, y: CGFloat)) -> LineSegment {
return LineSegment(
CGPoint(x: p1.x * multipliers.x, y: p1.y * multipliers.y),
CGPoint(x: p2.x * multipliers.x, y: p2.y * multipliers.y))
}
func divided(divisors: (x: CGFloat, y: CGFloat)) -> LineSegment {
return multiplied(multipliers: (x: 1/divisors.x, y: 1/divisors.y))
}
}

private struct Line {
let m: CGFloat
let b: CGFloat

/// y = mx+b
init(m: CGFloat, b: CGFloat) {
self.m = m
self.b = b
}

/// y-y1 = m(x-x1)
init(m: CGFloat, p: CGPoint) {
// y = m(x-x1) + y1
// y = mx-mx1 + y1
// y = mx + (y1 - mx1)
// b = y1 - mx1
self.m = m
self.b = p.y - m*p.x
}

init(_ p1: CGPoint, _ p2: CGPoint) {
self.init(m: LineSegment(p1, p2).slope, p: p1)
}

func y(x: CGFloat) -> CGFloat {
return m*x + b
}

func point(x: CGFloat) -> CGPoint {
return CGPoint(x: x, y: y(x: x))
}

func intersection(with line: Line) -> CGPoint? {
// Line 1: y = mx + b
// Line 2: y = nx + c
// mx+b = nx+c
// mx-nx = c-b
// x(m-n) = c-b
// x = (c-b)/(m-n)
let n = line.m
let c = line.b
if m-n == 0 {
// lines are parallel
return nil
}
let x = (c-b)/(m-n)
return point(x: x)
}
}

Proof it works regardless of rectangle size

I tried this with a view size=320x60, gradient=[red@0,green@0.5,blue@1], startPoint = (0,1), and endPoint = (1,0).

Sketch 3:

Sample Image

Actual generated iOS screenshot using the code above:

Sample Image

Note that the angle of the green line looks 100% accurate. The difference lies in how the red and blue are blended. I can't tell if that's because I'm calculating the start/end points incorrectly, or if it's just a difference in how Apple blends gradients vs. how Sketch blends gradients.



Related Topics



Leave a reply



Submit