iOS
iOS

Reputation: 3616

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.

Upvotes: 0

Views: 722

Answers (2)

Duncan C
Duncan C

Reputation: 131418

Matic wrote a War and Peace length answer to your question using drawRect to create the animation you are after. While impressive, I don't recommend that approach. When you implement drawRect, you do all the drawing on a single core, using the main processor on the main thread. You don't take advantage of the hardware-accelerated rendering in iOS at all.

Instead, I would suggest using Core Animation and CALayers.

It looks to me like your animation is a classic "clock wipe" animation, where you animate your image into view as if you are painting it with the sweep second hand of a clock.

I created just such an animation quite a few years ago in Objective-C, and have since updated it to Swift. See this link for a description and a link to a Github project. The effect looks like this:

enter image description here

You'd then need to create the image you are after and install it as the contents of a view, and then use the clock wipe animation code to reveal it. I didn't look too closely at Matic's answer, but it appears to explain how to draw an image that looks like yours.

Upvotes: 0

Matic Oblak
Matic Oblak

Reputation: 16774

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

Upvotes: 4

Related Questions