1

I am drawing a line using UIBezierPath and CAShapeLayer. I need to add gradient to the line as top colour to white and bottom colour to red. I followed few solutions from StackOverlow, no luck. Below is my code.

private func drawLine(start: CGPoint, toPoint end: CGPoint) {

    //design the path
    let path = UIBezierPath()
    path.move(to: start)
    path.addLine(to: end)

    //design path in layer
    let shapeLayer = CAShapeLayer()
    shapeLayer.path = path.cgPath
    shapeLayer.strokeColor = UIColor.red.cgColor
    shapeLayer.lineWidth = 2.0
    self.layer.addSublayer(shapeLayer)
    
    let gradient = CAGradientLayer()
    gradient.frame = path.bounds
    gradient.colors = [UIColor.white.cgColor, UIColor.red.cgColor]

    let shapeMask = CAShapeLayer()
    shapeMask.path = path.cgPath
    gradient.mask = shapeMask
    
    self.layer.addSublayer(gradient)
    
}

Another try from referring StackOverflow was

private func drawLine(start: CGPoint, toPoint end: CGPoint) {

    //design the path
    let path = UIBezierPath()
    path.move(to: start)
    path.addLine(to: end)

    //design path in layer
    let shapeLayer = CAShapeLayer()
    shapeLayer.path = path.cgPath
    shapeLayer.strokeColor = UIColor.red.cgColor
    shapeLayer.lineWidth = 2.0
    self.layer.addSublayer(shapeLayer)
    
    addGradientLayer(to: shapeLayer, path: path)
}

private func addGradientLayer(to layer: CALayer, path: UIBezierPath) {

    let gradientMask = CAShapeLayer()
    gradientMask.contentsScale = UIScreen.main.scale
    gradientMask.strokeColor = UIColor.white.cgColor
    gradientMask.path = path.cgPath

    let gradientLayer = CAGradientLayer()
    gradientLayer.mask = gradientMask
    gradientLayer.frame = layer.frame
    gradientLayer.contentsScale = UIScreen.main.scale
    gradientLayer.colors = [UIColor.white.cgColor, UIColor.red.cgColor]

    layer.addSublayer(gradientLayer)
}

Both just draws the red line (stroke).

iOS
  • 3,526
  • 3
  • 37
  • 82

1 Answers1

1

The short answer to your problem is most likely that you need to call cgPath.copy(strokingWithWidth: 3.0, lineCap: .butt, lineJoin: .bevel, miterLimit: 1.0) to convert your stroking path on mask to fill path.

But to break this down step by step...

First, you are drawing a gradient, which is masked by a line. Not drawing a line that has applied gradient. So only one layer needs to be added and it needs to be a gradient layer. So a first step should draw a rectangle with a gradient:

func drawLine(start: CGPoint, toPoint end: CGPoint) {
    let path = UIBezierPath()
    path.move(to: start)
    path.addLine(to: end)
    
    let gradientLayer = CAGradientLayer()
    gradientLayer.frame = path.bounds
    gradientLayer.colors = [UIColor.white.cgColor, UIColor.red.cgColor]
    
    self.layer.addSublayer(gradientLayer)
}

Now that gradient is visible lets apply some mask to it. A circle should be an easy task:

func drawLine(start: CGPoint, toPoint end: CGPoint) {
    let path = UIBezierPath()
    path.move(to: start)
    path.addLine(to: end)
    
    let gradientLayer = CAGradientLayer()
    gradientLayer.frame = path.bounds
    gradientLayer.colors = [UIColor.white.cgColor, UIColor.red.cgColor]
    gradientLayer.mask = {
        let layer = CAShapeLayer()
        layer.path = UIBezierPath(ovalIn: path.bounds).cgPath
        return layer
    }()
    
    self.layer.addSublayer(gradientLayer)
}

if you do this and you used some non-zero starting coordinates of the path then you will see that the circle is cut off. The path that is being masked must be in gradient layer coordinates so path.bounds needs to be corrected to

    gradientLayer.mask = {
        let layer = CAShapeLayer()
        layer.path = UIBezierPath(ovalIn: CGRect(x: 0.0, y: 0.0, width: path.bounds.width, height: path.bounds.height)).cgPath
        return layer
    }()

now that coordinates are OK let's convert this to a line. It gets a little bit more complicated because we need to subtract the origin of original path:

func drawLine(start: CGPoint, toPoint end: CGPoint) {
    let path = UIBezierPath()
    path.move(to: start)
    path.addLine(to: end)
    
    let gradientLayer = CAGradientLayer()
    gradientLayer.frame = path.bounds
    gradientLayer.colors = [UIColor.white.cgColor, UIColor.red.cgColor]
    gradientLayer.mask = {
        let layer = CAShapeLayer()
        let lineStart = CGPoint(x: start.x - path.bounds.minX,
                               y: start.y - path.bounds.minY)
        let lineEnd = CGPoint(x: end.x - path.bounds.minX,
                               y: end.y - path.bounds.minY)
        let path = UIBezierPath()
        path.move(to: lineStart)
        path.addLine(to: lineEnd)
        layer.path = path.cgPath
        return layer
    }()
    
    self.layer.addSublayer(gradientLayer)
}

there are other ways to move the path. A transform could be used just as well. So if this approach is not nice enough for you please choose whichever you wish.

However, this now draws nothing at all. And the reason is that when using mask a fill approach is always used. And filling a line does nothing as it has no area. You could use multiple lines to draw a custom shape for instance. And in your case you could use 4 lines which would represent a rectangle which is the line you expect to draw.

However, drawing lines is a pain so thankfully there is a not-very-intuitively designed API which converts your stroked path to a fill path. So in your case it converts a line into a rectangle with a defined width (and other properties which are only needed for more complex strokes). You can use

public func copy(strokingWithWidth lineWidth: CGFloat, lineCap: CGLineCap, lineJoin: CGLineJoin, miterLimit: CGFloat, transform: CGAffineTransform = .identity) -> CGPath

The code is not documented but in online documentation you may find

Discussion The new path is created so that filling the new path draws the same pixels as stroking the original path with the specified line style.

Now the end result should look something like this:

func drawLine(start: CGPoint, toPoint end: CGPoint) {
    let path = UIBezierPath()
    path.move(to: start)
    path.addLine(to: end)
    
    let gradientLayer = CAGradientLayer()
    gradientLayer.frame = path.bounds
    gradientLayer.colors = [UIColor.white.cgColor, UIColor.red.cgColor]
    gradientLayer.mask = {
        let layer = CAShapeLayer()
        let lineStart = CGPoint(x: start.x - path.bounds.minX,
                               y: start.y - path.bounds.minY)
        let lineEnd = CGPoint(x: end.x - path.bounds.minX,
                               y: end.y - path.bounds.minY)
        let path = UIBezierPath()
        path.move(to: lineStart)
        path.addLine(to: lineEnd)
        layer.path = path.cgPath.copy(strokingWithWidth: 3.0, lineCap: .butt, lineJoin: .bevel, miterLimit: 1.0)
        return layer
    }()
    
    self.layer.addSublayer(gradientLayer)
}

I hardcoded line width to 3.0 but you are probably best adding this as a method parameter or as view property.

To play around with different setup and see results quickly from this answer you can create a new project and replace your view controller code to the following:

class ViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(onTap)))
        view.addSubview({
            let label = UILabel(frame: CGRect(x: 20.0, y: 70.0, width: 300.0, height: 20.0))
            label.text = "Tap to toggle through drawing types"
            return label
        }())
    }
    
    private var instance: Int = 0
    private var currentView: UIView?
    
    @objc private func onTap() {
        currentView?.removeFromSuperview()
        let view = MyView(frame: CGRect(x: 30.0, y: 100.0, width: 100.0, height: 200.0))
        currentView = view
        view.backgroundColor = .lightGray
        self.view.addSubview(view)
        view.drawLine(type: instance, start: CGPoint(x: 90.0, y: 10.0), toPoint: .init(x: 10.0, y: 150.0))
        instance += 1
    }
    
}

private extension ViewController {

    class MyView: UIView {
        
        func drawLine(type: Int, start: CGPoint, toPoint end: CGPoint) {
            let methods = [drawLine1, drawLine2, drawLine3, drawLine4, drawLine5]
            methods[type%methods.count](start, end)
        }
        
        private func drawLine1(start: CGPoint, toPoint end: CGPoint) {
            let path = UIBezierPath()
            path.move(to: start)
            path.addLine(to: end)
            
            let gradientLayer = CAGradientLayer()
            gradientLayer.frame = path.bounds
            gradientLayer.colors = [UIColor.white.cgColor, UIColor.red.cgColor]
            
            self.layer.addSublayer(gradientLayer)
        }
        
        private func drawLine2(start: CGPoint, toPoint end: CGPoint) {
            let path = UIBezierPath()
            path.move(to: start)
            path.addLine(to: end)
            
            let gradientLayer = CAGradientLayer()
            gradientLayer.frame = path.bounds
            gradientLayer.colors = [UIColor.white.cgColor, UIColor.red.cgColor]
            gradientLayer.mask = {
                let layer = CAShapeLayer()
                layer.path = UIBezierPath(ovalIn: path.bounds).cgPath
                return layer
            }()
            
            self.layer.addSublayer(gradientLayer)
        }
        
        private func drawLine3(start: CGPoint, toPoint end: CGPoint) {
            let path = UIBezierPath()
            path.move(to: start)
            path.addLine(to: end)
            
            let gradientLayer = CAGradientLayer()
            gradientLayer.frame = path.bounds
            gradientLayer.colors = [UIColor.white.cgColor, UIColor.red.cgColor]
            gradientLayer.mask = {
                let layer = CAShapeLayer()
                layer.path = UIBezierPath(ovalIn: CGRect(x: 0.0, y: 0.0, width: path.bounds.width, height: path.bounds.height)).cgPath
                return layer
            }()
            
            self.layer.addSublayer(gradientLayer)
        }
        
        private func drawLine4(start: CGPoint, toPoint end: CGPoint) {
            let path = UIBezierPath()
            path.move(to: start)
            path.addLine(to: end)
            
            let gradientLayer = CAGradientLayer()
            gradientLayer.frame = path.bounds
            gradientLayer.colors = [UIColor.white.cgColor, UIColor.red.cgColor]
            gradientLayer.mask = {
                let layer = CAShapeLayer()
                let lineStart = CGPoint(x: start.x - path.bounds.minX,
                                        y: start.y - path.bounds.minY)
                let lineEnd = CGPoint(x: end.x - path.bounds.minX,
                                      y: end.y - path.bounds.minY)
                let path = UIBezierPath()
                path.move(to: lineStart)
                path.addLine(to: lineEnd)
                layer.path = path.cgPath
                return layer
            }()
            
            self.layer.addSublayer(gradientLayer)
        }
        
        private func drawLine5(start: CGPoint, toPoint end: CGPoint) {
            let path = UIBezierPath()
            path.move(to: start)
            path.addLine(to: end)
            
            let gradientLayer = CAGradientLayer()
            gradientLayer.frame = path.bounds
            gradientLayer.colors = [UIColor.white.cgColor, UIColor.red.cgColor]
            gradientLayer.mask = {
                let layer = CAShapeLayer()
                let lineStart = CGPoint(x: start.x - path.bounds.minX,
                                        y: start.y - path.bounds.minY)
                let lineEnd = CGPoint(x: end.x - path.bounds.minX,
                                      y: end.y - path.bounds.minY)
                let path = UIBezierPath()
                path.move(to: lineStart)
                path.addLine(to: lineEnd)
                layer.path = path.cgPath.copy(strokingWithWidth: 3.0, lineCap: .butt, lineJoin: .bevel, miterLimit: 1.0)
                return layer
            }()
            
            self.layer.addSublayer(gradientLayer)
        }
        
    }
    
}
Matic Oblak
  • 16,318
  • 3
  • 24
  • 43