Radial Gradient Background in Swift

Radial gradient background in Swift

Have a look at my implementation of RadialGradientLayer, and feel free to modify it

class RadialGradientLayer: CALayer {

override init(){

super.init()

needsDisplayOnBoundsChange = true
}

init(center:CGPoint,radius:CGFloat,colors:[CGColor]){

self.center = center
self.radius = radius
self.colors = colors

super.init()

}

required init(coder aDecoder: NSCoder) {

super.init()

}

var center:CGPoint = CGPointMake(50,50)
var radius:CGFloat = 20
var colors:[CGColor] = [UIColor(red: 251/255, green: 237/255, blue: 33/255, alpha: 1.0).CGColor , UIColor(red: 251/255, green: 179/255, blue: 108/255, alpha: 1.0).CGColor]

override func drawInContext(ctx: CGContext!) {

CGContextSaveGState(ctx)

var colorSpace = CGColorSpaceCreateDeviceRGB()

var locations:[CGFloat] = [0.0, 1.0]

var gradient = CGGradientCreateWithColors(colorSpace, colors, [0.0,1.0])

var startPoint = CGPointMake(0, self.bounds.height)
var endPoint = CGPointMake(self.bounds.width, self.bounds.height)

CGContextDrawRadialGradient(ctx, gradient, center, 0.0, center, radius, 0)

}

}

In my case I needed it with two colors only and if you need more colors you need to modify location array declared in drawInContext. Also after creating object from this class don't forget to call its setNeedsDisplay() otherwise it wont work. Also sometimes I needed different size gradients so thats why you have to pass radius parameter in initializer and the center point of your gradient

How to Apply Gradient to background view of iOS Swift App

The Colors you're providing to gradient must be of type CGColor. So set your array of CGColor to gl.colors.

The correct code is :

class Colors {
var gl:CAGradientLayer!

init() {
let colorTop = UIColor(red: 192.0 / 255.0, green: 38.0 / 255.0, blue: 42.0 / 255.0, alpha: 1.0).cgColor
let colorBottom = UIColor(red: 35.0 / 255.0, green: 2.0 / 255.0, blue: 2.0 / 255.0, alpha: 1.0).cgColor

self.gl = CAGradientLayer()
self.gl.colors = [colorTop, colorBottom]
self.gl.locations = [0.0, 1.0]
}
}

How to draw radial gradients in a CALayer?

From what I understood, you just need a layer that draws a gradient, and CGContextDrawRadialGradient works perfectly for that need. And to reiterate on what you said, CAGradientLayer doesn't support radial gradients, and nothing we can do about that, except unnecessary swizzling that can be done cleanly with a CALayer subclass.

(note: the gradient drawing code was taken from here. It isn't what this answer is about.)


viewDidLoad:

GradientLayer *gradientLayer = [[GradientLayer alloc] init];
gradientLayer.frame = self.view.bounds;

[self.view.layer addSublayer:gradientLayer];

CALayer subclass:

- (instancetype)init
{
self = [super init];
if (self) {
[self setNeedsDisplay];
}
return self;
}

- (void)drawInContext:(CGContextRef)ctx
{

size_t gradLocationsNum = 2;
CGFloat gradLocations[2] = {0.0f, 1.0f};
CGFloat gradColors[8] = {0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.5f};
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
CGGradientRef gradient = CGGradientCreateWithColorComponents(colorSpace, gradColors, gradLocations, gradLocationsNum);
CGColorSpaceRelease(colorSpace);

CGPoint gradCenter= CGPointMake(CGRectGetMidX(self.bounds), CGRectGetMidY(self.bounds));
CGFloat gradRadius = MIN(self.bounds.size.width , self.bounds.size.height) ;

CGContextDrawRadialGradient (ctx, gradient, gradCenter, 0, gradCenter, gradRadius, kCGGradientDrawsAfterEndLocation);

CGGradientRelease(gradient);
}

Sample Image

How do I add a radial gradient to a UIView?

To do this you need to drop down to Core Graphics and use CGContextDrawRadialGradient.

Similar Stack Overflow Questions

How can I draw a sector with radial gradient (iphone)

How to draw a gradient line (fading in/out) with Core Graphics/iPhone?

Other Resources

There's a tutorial showing how to draw icons with gradients on iOS here:

http://redartisan.com/2011/05/13/porting-iconapp-core-graphics

He's put the code on Github, complete with a UIView subclass that shows the (rather longwinded) way of making gradients using Core Graphics:

https://github.com/crafterm/IconApp/blob/master/IconApp/IconView.m

How to cut out a hole with radial gradient in view

You can play around a little bit and get

Sample Image

To achieve such result I prepared sample code in playground - just copy-paste it and try.

import UIKit
import PlaygroundSupport

class RadialGradientLayer: CALayer {

required override init() {
super.init()
needsDisplayOnBoundsChange = true
}

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

required override init(layer: Any) {
super.init(layer: layer)
}

//default colors
public var colors = [UIColor.red.cgColor, UIColor.clear.cgColor]

override func draw(in ctx: CGContext) {
ctx.saveGState()

let colorSpace = CGColorSpaceCreateDeviceRGB()
var locations = [CGFloat]()
for i in 0...colors.count-1 {
locations.append(CGFloat(i) / CGFloat(colors.count))
}
let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: locations)
let center = CGPoint(x: bounds.width / 2.0, y: bounds.height / 2.0)
let radius = min(bounds.width, bounds.height)
ctx.drawRadialGradient(gradient!, startCenter: center, startRadius: 0.0, endCenter: center, endRadius: radius, options: CGGradientDrawingOptions(rawValue: 0))
}
}

let view = UIView(frame: CGRect(x: 0, y: 0, width: 375, height: 300))
view.backgroundColor = UIColor.green

let label = UILabel(frame: view.bounds)
label.text = "test"
label.font = UIFont.systemFont(ofSize: 30)
label.textAlignment = .center
view.addSubview(label)

let gradientLayer = RadialGradientLayer()
gradientLayer.frame = view.bounds
gradientLayer.colors = [UIColor.clear.cgColor, UIColor.black.cgColor]
gradientLayer.setNeedsDisplay()

view.layer.addSublayer(gradientLayer)

view

iOS - Draw a view with gradient background

To begin with a path needs to be generated. You probably already have this but you have not provided any code for it although you mentioned "I am able to draw the black lines in the shape". So to begin with the code...

private func generatePath(withPoints points: [CGPoint], inFrame frame: CGRect) -> UIBezierPath? {
guard points.count > 2 else { return nil } // At least 3 points
let pointsInPolarCoordinates: [(angle: CGFloat, radius: CGFloat)] = points.map { point in
let radius = (point.x*point.x + point.y*point.y).squareRoot()
let angle = atan2(point.y, point.x)
return (angle, radius)
}
let maximumPointRadius: CGFloat = pointsInPolarCoordinates.max(by: { $1.radius > $0.radius })!.radius
guard maximumPointRadius > 0.0 else { return nil } // Not all points may be centered

let maximumFrameRadius = min(frame.width, frame.height)*0.5
let radiusScale = maximumFrameRadius/maximumPointRadius

let normalizedPoints: [CGPoint] = pointsInPolarCoordinates.map { polarPoint in
.init(x: frame.midX + cos(polarPoint.angle)*polarPoint.radius*radiusScale,
y: frame.midY + sin(polarPoint.angle)*polarPoint.radius*radiusScale)
}

let path = UIBezierPath()
path.move(to: normalizedPoints[0])
normalizedPoints[1...].forEach { path.addLine(to: $0) }
path.close()
return path
}

Here points are expected to be around 0.0. They are distributed so that they try to fill maximum space depending on given frame and they are centered on it. Nothing special, just basic math.

After a path is generated you may either use shape-layer approach or draw-rect approach. I will use the draw-rect:

You may subclass an UIView and override a method func draw(_ rect: CGRect). This method will be called whenever a view needs a display and you should NEVER call this method directly. So in order to redraw the view you simply call setNeedsDisplay on the view. Starting with code:

class GradientProgressView: UIView {

var points: [CGPoint]? { didSet { setNeedsDisplay() } }

override func draw(_ rect: CGRect) {
super.draw(rect)

guard let context = UIGraphicsGetCurrentContext() else { return }

let lineWidth: CGFloat = 5.0
guard let points = points else { return }
guard let path = generatePath(withPoints: points, inFrame: bounds.insetBy(dx: lineWidth, dy: lineWidth)) else { return }

drawGradient(path: path, context: context)
drawLine(path: path, lineWidth: lineWidth, context: context)
}

Nothing very special. The context is grabbed for drawing the gradient and for clipping (later). Other than that the path is created using the previous method and then passed to two rendering methods.

Starting with the line things get very simple:

private func drawLine(path: UIBezierPath, lineWidth: CGFloat, context: CGContext) {
UIColor.black.setStroke()
path.lineWidth = lineWidth
path.stroke()
}

there should most likely be a property for color but I just hardcoded it.

As for gradient things do get a bit more scary:

private func drawGradient(path: UIBezierPath, context: CGContext) {
context.saveGState()

path.addClip() // This will be discarded once restoreGState() is called
let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: [UIColor.blue, UIColor.green].map { $0.cgColor } as CFArray, locations: [0.0, 1.0])!
context.drawRadialGradient(gradient, startCenter: CGPoint(x: bounds.midX, y: bounds.midY), startRadius: 0.0, endCenter: CGPoint(x: bounds.midX, y: bounds.midY), endRadius: min(bounds.width, bounds.height), options: [])

context.restoreGState()
}

When drawing a radial gradient you need to clip it with your path. This is done by calling path.addClip() which uses a "fill" approach on your path and applies it to current context. This means that everything you draw after this call will be clipped to this path and outside of it nothing will be drawn. But you DO want to draw outside of it later (the line does) and you need to reset the clip. This is done by saving and restoring state on your current context calling saveGState and restoreGState. These calls are push-pop so for every "save" there should be a "restore". And you can nest this procedure (as it will be done when applying a progress).

Using just this code you should already be able to draw your full shape (as in with 100% progress). To give my test example I use it all in code like this:

class ViewController: UIViewController {

override func viewDidLoad() {
super.viewDidLoad()

let progressView = GradientProgressView(frame: .init(x: 30.0, y: 30.0, width: 280.0, height: 350.0))
progressView.backgroundColor = UIColor.lightGray // Just to debug
progressView.points = {
let count = 200
let minimumRadius: CGFloat = 0.9
let maximumRadius: CGFloat = 1.1

return (0...count).map { index in
let progress: CGFloat = CGFloat(index) / CGFloat(count)
let angle = CGFloat.pi * 2.0 * progress
let radius = CGFloat.random(in: minimumRadius...maximumRadius)
return .init(x: cos(angle)*radius, y: sin(angle)*radius)
}
}()
view.addSubview(progressView)
}

}

Adding a progress now only needs additional clipping. We would like to draw only within a certain angle. This should be straight forward by now:

Another property is added to the view:

var progress: CGFloat = 0.7 { didSet { setNeedsDisplay() } }

I use progress as value between 0 and 1 where 0 is 0% progress and 1 is 100% progress.

Then to create a clipping path:

private func createProgressClippingPath() -> UIBezierPath {
let endAngle = CGFloat.pi*2.0*progress

let maxRadius: CGFloat = max(bounds.width, bounds.height) // we simply need one that is large enough.
let path = UIBezierPath()
let center: CGPoint = .init(x: bounds.midX, y: bounds.midY)
path.move(to: center)
path.addArc(withCenter: center, radius: maxRadius, startAngle: 0.0, endAngle: endAngle, clockwise: true)
return path
}

This is simply a path from center and creating an arc from zero angle to progress angle.

Now to apply this additional clipping:

override func draw(_ rect: CGRect) {
super.draw(rect)

let actualProgress = max(0.0, min(progress, 1.0))
guard actualProgress > 0.0 else { return } // Nothing to draw

let willClipAsProgress = actualProgress < 1.0

guard let context = UIGraphicsGetCurrentContext() else { return }

let lineWidth: CGFloat = 5.0
guard let points = points else { return }
guard let path = generatePath(withPoints: points, inFrame: bounds.insetBy(dx: lineWidth, dy: lineWidth)) else { return }

if willClipAsProgress {
context.saveGState()
createProgressClippingPath().addClip()
}


drawGradient(path: path, context: context)
drawLine(path: path, lineWidth: lineWidth, context: context)

if willClipAsProgress {
context.restoreGState()
}
}

We really just want to apply clipping when progress is not full. And we want to discard all drawing when progress is at zero since everything would be clipped.

You can see that the start angle of the shape is toward right instead of facing upward. Let's apply some transformation to fix that:

progressView.transform = CGAffineTransform(rotationAngle: -.pi*0.5)

At this point the new view is capable of drawing and redrawing itself. You are free to use this in storyboard, you can add inspectables and make it designable if you will. As for the animation you are now only looking to animate a simple float value and assign it to progress. There are many ways to do that and I will do the laziest, which is using a timer:

@objc private func animateProgress() {
let duration: TimeInterval = 1.0
let startDate = Date()

Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] timer in
guard let self = self else {
timer.invalidate()
return
}

let progress = Date().timeIntervalSince(startDate)/duration

if progress >= 1.0 {
timer.invalidate()
}
self.progressView?.progress = max(0.0, min(CGFloat(progress), 1.0))
}
}

This is pretty much it. A full code that I used to play around with this:

class ViewController: UIViewController {

private var progressView: GradientProgressView?

override func viewDidLoad() {
super.viewDidLoad()

let progressView = GradientProgressView(frame: .init(x: 30.0, y: 30.0, width: 280.0, height: 350.0))
progressView.backgroundColor = UIColor.lightGray // Just to debug
progressView.transform = CGAffineTransform(rotationAngle: -.pi*0.5)
progressView.points = {
let count = 200
let minimumRadius: CGFloat = 0.9
let maximumRadius: CGFloat = 1.1

return (0...count).map { index in
let progress: CGFloat = CGFloat(index) / CGFloat(count)
let angle = CGFloat.pi * 2.0 * progress
let radius = CGFloat.random(in: minimumRadius...maximumRadius)
return .init(x: cos(angle)*radius, y: sin(angle)*radius)
}
}()
view.addSubview(progressView)
self.progressView = progressView

view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(animateProgress)))
}

@objc private func animateProgress() {
let duration: TimeInterval = 1.0
let startDate = Date()

Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] timer in
guard let self = self else {
timer.invalidate()
return
}

let progress = Date().timeIntervalSince(startDate)/duration

if progress >= 1.0 {
timer.invalidate()
}
self.progressView?.progress = max(0.0, min(CGFloat(progress), 1.0))
}
}

}

private extension ViewController {

class GradientProgressView: UIView {

var points: [CGPoint]? { didSet { setNeedsDisplay() } }
var progress: CGFloat = 0.7 { didSet { setNeedsDisplay() } }

override func draw(_ rect: CGRect) {
super.draw(rect)

let actualProgress = max(0.0, min(progress, 1.0))
guard actualProgress > 0.0 else { return } // Nothing to draw

let willClipAsProgress = actualProgress < 1.0

guard let context = UIGraphicsGetCurrentContext() else { return }

let lineWidth: CGFloat = 5.0
guard let points = points else { return }
guard let path = generatePath(withPoints: points, inFrame: bounds.insetBy(dx: lineWidth, dy: lineWidth)) else { return }

if willClipAsProgress {
context.saveGState()
createProgressClippingPath().addClip()
}


drawGradient(path: path, context: context)
drawLine(path: path, lineWidth: lineWidth, context: context)

if willClipAsProgress {
context.restoreGState()
}
}

private func createProgressClippingPath() -> UIBezierPath {
let endAngle = CGFloat.pi*2.0*progress

let maxRadius: CGFloat = max(bounds.width, bounds.height) // we simply need one that is large enough.
let path = UIBezierPath()
let center: CGPoint = .init(x: bounds.midX, y: bounds.midY)
path.move(to: center)
path.addArc(withCenter: center, radius: maxRadius, startAngle: 0.0, endAngle: endAngle, clockwise: true)
return path
}

private func drawGradient(path: UIBezierPath, context: CGContext) {
context.saveGState()

path.addClip() // This will be discarded once restoreGState() is called
let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: [UIColor.blue, UIColor.green].map { $0.cgColor } as CFArray, locations: [0.0, 1.0])!
context.drawRadialGradient(gradient, startCenter: CGPoint(x: bounds.midX, y: bounds.midY), startRadius: 0.0, endCenter: CGPoint(x: bounds.midX, y: bounds.midY), endRadius: min(bounds.width, bounds.height), options: [])

context.restoreGState()
}

private func drawLine(path: UIBezierPath, lineWidth: CGFloat, context: CGContext) {
UIColor.black.setStroke()
path.lineWidth = lineWidth
path.stroke()
}



private func generatePath(withPoints points: [CGPoint], inFrame frame: CGRect) -> UIBezierPath? {
guard points.count > 2 else { return nil } // At least 3 points
let pointsInPolarCoordinates: [(angle: CGFloat, radius: CGFloat)] = points.map { point in
let radius = (point.x*point.x + point.y*point.y).squareRoot()
let angle = atan2(point.y, point.x)
return (angle, radius)
}
let maximumPointRadius: CGFloat = pointsInPolarCoordinates.max(by: { $1.radius > $0.radius })!.radius
guard maximumPointRadius > 0.0 else { return nil } // Not all points may be centered

let maximumFrameRadius = min(frame.width, frame.height)*0.5
let radiusScale = maximumFrameRadius/maximumPointRadius

let normalizedPoints: [CGPoint] = pointsInPolarCoordinates.map { polarPoint in
.init(x: frame.midX + cos(polarPoint.angle)*polarPoint.radius*radiusScale,
y: frame.midY + sin(polarPoint.angle)*polarPoint.radius*radiusScale)
}

let path = UIBezierPath()
path.move(to: normalizedPoints[0])
normalizedPoints[1...].forEach { path.addLine(to: $0) }
path.close()
return path
}

}

}

Draw radial background on a UIBezierPath

Here's how you can make your original code work with minimal changes.

override func draw(_ rect: CGRect) {
// [...]
// your code untouched until this line:
bezierPath.close()

// change the rest of the function to:
bezierPath.fill()

UIGraphicsGetCurrentContext()!.setBlendMode(.sourceIn)
let colors = [UIColor.green.cgColor, UIColor.blue.cgColor] as CFArray
let gradient = CGGradient(colorsSpace: nil, colors: colors, locations: nil)
let endPosition = min(frame.width, frame.height) / 2
let center = CGPoint(x: bounds.size.width / 2, y: bounds.size.height / 2)
UIGraphicsGetCurrentContext()?.drawRadialGradient(gradient!, startCenter: center, startRadius: 0.0, endCenter: center, endRadius: endPosition, options: .drawsAfterEndLocation)
}

Result:

Result

The idea is to first draw the bezier with the rays. The color does not matter here, this is only to draw alpha.

Then we draw the gradient on top of the alpha using one of Quartz' peculiar blend modes: kCGBlendModeSourceIn (see Porter-Duff alpha compositing).

This mode draws anything on top of existing alpha, replacing only the pixel's color, leaving alpha as it was. It's basically like using the current drawing's alpha as a mask.



Related Topics



Leave a reply



Submit