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);
}
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
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:
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
Swiftui Landmarks App Tutorial Screen Navigates Back When Toggle Favorite
Application Not Automatically Launching in Device and Getting Error on Xcode
Add a Watermark on Video After Merging Video and Audio Asset into One in Swift3 iOS
iPhone Different Screen Sizes in Flash? (Getting Black Bars)
How to Implement a Box or Gaussian Blur on iOS
Could Not Find Module for Target 'X86_64-Apple-Ios-Simulator'
How to Create Border in Uibutton
Set Custom Images to the Uibarbuttonitem But It Doesn't Show Any Image
What Is Build Object File Extension in iOS
Why Is This Code Not Recognising the Nsstring as Being Equal
Error: Cuicatalog: Invalid Asset Name Supplied: (Null), or Invalid Scale Factor:2.000000
Creating Thumbnail from Local Video in Swift
Xcode Debugger Doesn't Print Objects and Shows Nil, When They Aren'T
Changing View Controller When Segmented Control Changes
Cordova Run with iOS Error .. Error Code 65 for Command: Xcodebuild with Args:
Changed +Load Method Order in Xcode 7
How to Get Server Response Data in Nsurlsession Without Completion Block