iosswiftcalayeruicolorcagradientlayer

iOS - Draw a view with gradient background


I have attached a rough sketch. The lines are deformed as the sketch was drawn manually just to illustrate the concept.

As seen in the sketch, I have a list of points that has to be drawn on the view automatically (it is irregular shape), clockwise with some delay (0.1 seconds) to see the progress visually.

Sketch would illustrate 70% approximate completion of the shape.

enter image description here

As the view draws, I have to maintain the background gradient. As seen in the sketch, Start point and the Current point are never connected directly. The colour must be filled only between Start point -> Centre point -> Current point.

Coming to the gradient part, there are two colours. Turquoise colour concentrated at the centre and the colour gets lighter to white as it moves away from the centre point.

How would I implement this in iOS? I am able to draw the black lines in the shape, but, I am unable to fill the colour. And gradient, I have no idea at all.


Solution

  • 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
            }
            
        }
        
    }