Search code examples
iosswiftchartsuikit

UIKit Custom Line Chart View with gradient mask below lines


I have creating a custom UIKit view that is a line chart view and I want to have the choice of adding a gradient color below the lines. I have this so far so I am very close but I don't want the path to close at the bottom of the chart I want the line to just stop at the last data point. This is the code so far:

class LineChartView: UIView {
    var dataPoints: [CGFloat] = [] // Data points for your chart
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        backgroundColor = .clear
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func draw(_ rect: CGRect) {
        super.draw(rect)
        
        // Set up the bezier path
        let path = UIBezierPath()
        
        // Calculate the gap between each point
        let horizontalGap = rect.width / CGFloat(dataPoints.count - 1)
        
        // Start from the first data point
        var startPoint = CGPoint(x: 0, y: dataPoints[0])
        path.move(to: startPoint)
        
        // Connect the points with lines
        for i in 0..<dataPoints.count {
            let nextPoint = CGPoint(x: CGFloat(i) * horizontalGap, y: dataPoints[i])
            path.addLine(to: nextPoint)
            startPoint = nextPoint
        }

        // Add extra points to close the path and fill the area under the line
        path.addLine(to: CGPoint(x: rect.width, y: rect.height))
        path.addLine(to: CGPoint(x: 0, y: rect.height))
        
        let customBlueColor = UIColor.blue.withAlphaComponent(0.5)

        // Get the cgColor of the custom blue color
        let customBlueCGColor = customBlueColor.cgColor
        
        // Create a gradient layer
        let gradientLayer = CAGradientLayer()
        gradientLayer.frame = CGRect(x: 0, y: 0, width: rect.width, height: rect.height)
        gradientLayer.colors = [customBlueCGColor, UIColor.clear.cgColor]
        gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.0) // Start from the top
        gradientLayer.endPoint = CGPoint(x: 0.5, y: 1.0) // End at the bottom
        
        // Create a shape layer for the gradient mask
        let gradientMaskLayer = CAShapeLayer()
        gradientMaskLayer.path = path.cgPath
        
        // Add the gradient mask to the gradient layer
        gradientLayer.mask = gradientMaskLayer
        
        // Add the gradient layer to the view's layer
        layer.addSublayer(gradientLayer)
        
        // Create a shape layer for the line
        let lineLayer = CAShapeLayer()
        lineLayer.fillColor = UIColor.clear.cgColor
        lineLayer.strokeColor = UIColor.blue.cgColor // Set color
        lineLayer.lineWidth = 3.0
        lineLayer.lineCap = .round
        lineLayer.lineJoin = .round
        lineLayer.path = path.cgPath
        
        // Add the line layer to the view's layer
        layer.addSublayer(lineLayer)
                
        // Draw X-axis
        let xAxisLayer = CAShapeLayer()
        xAxisLayer.strokeColor = UIColor.black.cgColor
        xAxisLayer.lineWidth = 3.0
        let xAxisPath = UIBezierPath()
        
        xAxisPath.move(to: CGPoint(x: 0, y: bounds.height))
        xAxisPath.addLine(to: CGPoint(x: bounds.width, y: bounds.height))
        
        xAxisLayer.path = xAxisPath.cgPath
        layer.addSublayer(xAxisLayer)
        
        // Draw Y-axis
        let yAxisLayer = CAShapeLayer()
        yAxisLayer.strokeColor = UIColor.black.cgColor
        yAxisLayer.lineWidth = 3.0
        let yAxisPath = UIBezierPath()
        yAxisPath.move(to: CGPoint(x: 0, y: 0))
        yAxisPath.addLine(to: CGPoint(x: 0, y: bounds.height))
        yAxisLayer.path = yAxisPath.cgPath
        layer.addSublayer(yAxisLayer)
    }
}

Then if you want to put it in your view controller do the following:

class ViewController: UIViewController {
    let randomNumbers: [CGFloat] = Array(repeating: 0.0, count: 20).map { _ in CGFloat.random(in: 1..<200) }
    private lazy var lineChartView: LineChartView = {
        let view = LineChartView()
        view.dataPoints = randomNumbers
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        view.addSubview(lineChartView)
        configure()
    }
    
    private func configure() {
        NSLayoutConstraint.activate([
            lineChartView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            lineChartView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            lineChartView.heightAnchor.constraint(equalToConstant: 300),
            lineChartView.widthAnchor.constraint(equalToConstant: 400)
        ])
    }
}

enter image description here


Solution

  • First, don't do this in override func draw(_ rect: CGRect) ... that will be called many times, and you'll be adding many multiples of your shape layers.

    Add the layers - and set their non-changing properties - when the view is initialized. Then define and set the paths and mask in layoutSubviews().

    Second, the easiest way to get rid of the unwanted blue line segments is to create two paths:
        // for blue line
        let linePath = UIBezierPath()
        
        // for gradient filled area
        let gradPath = UIBezierPath()
        
    

    Build both paths with the same moveTo / lineTo code, but only add the last point and close the gradient path.

    Actually, you don't need two paths... I was looking at it thinking you were drawing the shapes in draw(), but you're using layers, so...

    Define the line path and assign it to the line layer, then close it and assign it to the mask layer.

    Here's your class with the above changes:

    class LineChartView: UIView {
        var dataPoints: [CGFloat] = [] // Data points for your chart
        
        // Create a gradient layer
        let gradientLayer = CAGradientLayer()
    
        // Create a shape layer for the line
        let lineLayer = CAShapeLayer()
    
        let xAxisLayer = CAShapeLayer()
        let yAxisLayer = CAShapeLayer()
    
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            backgroundColor = .clear
            
            let customBlueColor = UIColor.blue.withAlphaComponent(0.5)
            
            // Get the cgColor of the custom blue color
            let customBlueCGColor = customBlueColor.cgColor
            
            layer.addSublayer(gradientLayer)
            layer.addSublayer(lineLayer)
            layer.addSublayer(xAxisLayer)
            layer.addSublayer(yAxisLayer)
    
            // gradient layer properties
            gradientLayer.colors = [customBlueCGColor, UIColor.clear.cgColor]
            gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.0) // Start from the top
            gradientLayer.endPoint = CGPoint(x: 0.5, y: 1.0) // End at the bottom
            
            // line layer properties
            lineLayer.fillColor = UIColor.clear.cgColor
            lineLayer.strokeColor = UIColor.blue.cgColor // Set color
            lineLayer.lineWidth = 3.0
            lineLayer.lineCap = .round
            lineLayer.lineJoin = .round
    
            yAxisLayer.strokeColor = UIColor.black.cgColor
            yAxisLayer.lineWidth = 3.0
    
        }
        override func layoutSubviews() {
            super.layoutSubviews()
            
            let rect = bounds
            
            // for both blue line and gradient mask
            let linePath = UIBezierPath()
            
            // Calculate the gap between each point
            let horizontalGap = rect.width / CGFloat(dataPoints.count - 1)
            
            // Start from the first data point
            var startPoint = CGPoint(x: 0, y: dataPoints[0])
            linePath.move(to: startPoint)
            
            // Connect the points with lines
            for i in 0..<dataPoints.count {
                let nextPoint = CGPoint(x: CGFloat(i) * horizontalGap, y: dataPoints[i])
                linePath.addLine(to: nextPoint)
                startPoint = nextPoint
            }
    
            // set the line layer path
            lineLayer.path = linePath.cgPath
            
            // Now, add extra points to close the path and fill the area under the line
            //  but don't close the blue-line path
            linePath.addLine(to: CGPoint(x: rect.width, y: rect.height))
            linePath.addLine(to: CGPoint(x: 0, y: rect.height))
            
            // Create a shape layer for the gradient mask
            let gradientMaskLayer = CAShapeLayer()
            gradientMaskLayer.path = linePath.cgPath
            
            gradientLayer.frame = CGRect(x: 0, y: 0, width: rect.width, height: rect.height)
    
            // Add the gradient mask to the gradient layer
            gradientLayer.mask = gradientMaskLayer
            
            // Draw X-axis
            xAxisLayer.strokeColor = UIColor.black.cgColor
            xAxisLayer.lineWidth = 3.0
            let xAxisPath = UIBezierPath()
            
            xAxisPath.move(to: CGPoint(x: 0, y: bounds.height))
            xAxisPath.addLine(to: CGPoint(x: bounds.width, y: bounds.height))
            
            xAxisLayer.path = xAxisPath.cgPath
            
            // Draw Y-axis
            let yAxisPath = UIBezierPath()
            yAxisPath.move(to: CGPoint(x: 0, y: 0))
            yAxisPath.addLine(to: CGPoint(x: 0, y: bounds.height))
            yAxisLayer.path = yAxisPath.cgPath
        }
    }
    

    Now looks like this:

    enter image description here