Drawing Gradient Over Image in iOS

Drawing gradient over image in ios

When you say "apply it over the image as a gradient", do you mean as a mask (revealing the image at the top, having it fade the image to transparent at the bottom)? If that's the case, you can apply that gradient as a mask, using CAGradientLayer:

CAGradientLayer *gradientMask = [CAGradientLayer layer];
gradientMask.frame = self.imageView.bounds;
gradientMask.colors = @[(id)[UIColor whiteColor].CGColor,
(id)[UIColor clearColor].CGColor];
self.imageView.layer.mask = gradientMask;

The above does a simple vertical gradient (because the default is vertical, linear gradient). But you asked about startPoint, endPoint, and locations. If for example, you wanted your mask applied horizontally, you would do:

gradientMask.startPoint = CGPointMake(0.0, 0.5);   // start at left middle
gradientMask.endPoint = CGPointMake(1.0, 0.5); // end at right middle

If you wanted to have two gradients, one at the first 10% and another at the last 10%, you'd do:

gradientMask.colors = @[(id)[UIColor clearColor].CGColor,
(id)[UIColor whiteColor].CGColor,
(id)[UIColor whiteColor].CGColor,
(id)[UIColor clearColor].CGColor];
gradientMask.locations = @[@0.0, @0.10, @0.90, @1.0];

If you want a simple gradient by itself (not as a mask), you'd create a view and then add the gradient layer to it:

CAGradientLayer *gradient = [CAGradientLayer layer];
gradient.frame = view.bounds;
gradient.colors = @[(id)[UIColor whiteColor].CGColor,
(id)[UIColor blackColor].CGColor];
[view.layer addSublayer:gradient];

See the CAGradientLayer class reference.

How to create an overlay color with a gradient in iOS

If you're looking for a more dynamic approach, then I would subclass CALayer instead of UIImageView. Therefore you'd want something like this:

@interface gradientImageLayer : CALayer

- (instancetype)initWithFrame:(CGRect)frame;

@end

@implementation gradientImageLayer

- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super init]) {

self.opaque = YES; // Best for performance, but you if you want the layer to have transparency, then remove.

UIImage *i = [UIImage imageNamed:@"foo2.png"]; // Replace with your image

self.frame = frame;

self.contentsScale = [UIScreen mainScreen].nativeScale;
self.contents = (__bridge id _Nullable)(i.CGImage);

// Your code for the CAGradientLayer was indeed correct.
CAGradientLayer *gradient = [CAGradientLayer layer];
gradient.frame = frame;

// Add whatever colors you want here.
UIColor *colorOne = [UIColor colorWithRed:1 green:1 blue:0 alpha:0.1];
UIColor *colorTwo = [UIColor colorWithRed:0 green:1 blue:1 alpha:0.2];

gradient.colors = @[(id)colorOne.CGColor, (id)colorTwo.CGColor]; // Literals read far nicer than a clunky [NSArray arrayWith.... ]

[self addSublayer:gradient];
}

return self;
}

@end

The downside to this approach is you are unable to apply different blend modes. The only solutions I've seen to applying a blend mode on a CALayer is through Core Graphics, but then you'd be better off with my original answer.

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
}

}

}

Adding gradient on background image of UITableViewCell

At David Shaw's suggestion, I commented out this line in configureCell()

  // backgroundImageView.kf.setImage(with: imageUrl)

and the gradient drew properly. I deduced I had a timing issue because I'm getting the image asynchronously.

I looked through the method signatures and saw there's one with a completion handler, so I used it instead and it worked:

  backgroundImageView.kf.setImage(with: imageUrl, completionHandler: { (image, error, cacheType, imageUrl) in
// do stuff
})

...so I did this.

drawing gradient over rectangle in swift

You need to specify a clipping path right before drawing the gradient.
In your case, create a rectangle path and call CGContextClip(context)

CGContextAddRect(context, CGRect(...))
CGContextClip(context)

CGContextDrawLinearGradient(context, gradient, startPoint, endPoint, 0)

The gradient will be clipped to the rectangle.

Draw gradient inside UIView draw() with swift

You can use @IBDesignable and @IBInspectable to configure the startColor and endColor properties from the Storyboard. Use CGGradient which specifies colors and locations instead of CAGradientLayer if you want to draw in draw(_ rect:) method. The Simple Gradient class and draw(_ rect: CGRect) function would look like this

import UIKit

@IBDesignable
class GradientView: UIView {

@IBInspectable var startColor: UIColor = UIColor(red: 4/255, green: 39/255, blue: 105/255, alpha: 1)
@IBInspectable var endColor: UIColor = UIColor(red: 1/255, green: 91/255, blue: 168/255, alpha: 1)

override func draw(_ rect: CGRect) {

let context = UIGraphicsGetCurrentContext()!
let colors = [startColor.cgColor, endColor.cgColor]

let colorSpace = CGColorSpaceCreateDeviceRGB()

let colorLocations: [CGFloat] = [0.0, 1.0]

let gradient = CGGradient(colorsSpace: colorSpace,
colors: colors as CFArray,
locations: colorLocations)!

let startPoint = CGPoint.zero
let endPoint = CGPoint(x: 0, y: bounds.height)
context.drawLinearGradient(gradient,
start: startPoint,
end: endPoint,
options: [CGGradientDrawingOptions(rawValue: 0)])
}
}

you can read more about it here and tutorial



Related Topics



Leave a reply



Submit