CAShapeLayer() draws weird line/path
Asked Answered
S

1

2

I have a CAShapeLayer() with a gradient on top which is being animated, but somehow it looks like in the image below: image

How come it looks like this?

My code:

override func viewDidLayoutSubviews() {
    displayLine()
}

override func viewDidAppear(_ animated: Bool) {
    animateStroke()
}

func displayLine() {
    let trackLayer = CAShapeLayer()
    let rect = CGRect(x: topView.frame.width * 0.15, y: topView.frame.size.height / 1.5, width: topView.frame.width * 0.7, height: 2)
    let path = UIBezierPath(roundedRect: rect, cornerRadius: 1)

    trackLayer.path = path.cgPath
    trackLayer.strokeColor = UIColor.groupTableViewBackground.cgColor
    trackLayer.lineWidth = 3
    trackLayer.fillColor = UIColor.clear.cgColor

    shapeLayer.path = path.cgPath
    shapeLayer.strokeColor = UIColor.green.cgColor
    shapeLayer.lineWidth = 4
    shapeLayer.fillColor = UIColor.clear.cgColor
    shapeLayer.strokeEnd = 0

    topView.layer.addSublayer(trackLayer)
    topView.layer.addSublayer(shapeLayer)

    let color = UIColor(red: 11/255, green: 95/255, blue: 244/255, alpha: 1).cgColor
    let sndColor = UIColor(red: 255/255, green: 87/255, blue: 87/255, alpha: 1).cgColor

    gradient.colors = [color, sndColor]
    gradient.locations = [0.0, 1.0]
    gradient.startPoint = CGPoint(x: 0, y: 0)
    gradient.endPoint = CGPoint(x: 1, y: 0)
    gradient.frame = topView.bounds

    gradient.mask = shapeLayer
    topView.layer.addSublayer(gradient)
}

func animateStroke() {
    if !animated {
        animated = true

        let basicAnimation = CABasicAnimation(keyPath: "strokeEnd")
        var value: Double?

        let distance = currLeasingCar!.currentKm - currLeasing!.startKm
        value = Double(distance) / Double(finalKm)

        basicAnimation.toValue = value
        basicAnimation.duration = 1.5
        basicAnimation.fillMode = .forwards
        basicAnimation.isRemovedOnCompletion = false
        basicAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)

        shapeLayer.add(basicAnimation, forKey: "lineStrokeAnimation")
    }
}
Simonsimona answered 29/5, 2019 at 16:28 Comment(0)
B
5

The issue is that your path is a rounded rectangle. In the image you shared with us, it’s probably about 2-3% stroked. Change it to stroke 90% of the way, and you’ll see it trying to draw a wide and extremely short rounded rectangle, e.g.:

enter image description here

Instead, just make the path a line, and it will work as intended:

let path = UIBezierPath()

let bounds = topView.bounds
path.move(to:    CGPoint(x: bounds.minX + bounds.width * 0.15, y: bounds.minY + bounds.height / 1.5))
path.addLine(to: CGPoint(x: bounds.minX + bounds.width * 0.85, y: bounds.minY + bounds.height / 1.5))

You might also want to round the caps of your shape layers:

trackLayer.lineCap = .round  // or whatever you want
shapeLayer.lineCap = .round

And, of course, this change lost the 2 point height of your original path, so if you want to make these shape layer’s thicker, just increase their respective lineWidth values.


A couple of unrelated observations:

  • viewDidLayoutSubviews() and viewDidAppear(_:) should call their super implementations.

  • viewDidLayoutSubviews() can be called multiple times, so you don’t want to instantiate a new trackLayer every time. Or if you do, make sure to remove the prior one.

  • When adding subviews/sublayers, it is prudent to use bounds instead of frame. In this case it probably doesn’t matter, but in some cases you can get all sorts of weird problems because frame is in the view’s superview’s coordinate system, whereas bounds is the coordinate system of the view in question.


Personally, if you were to keep this code in the view controller, I’d suggest:

  • add the shape layers and gradient in viewDidLoad;
  • update the paths and the bounds of the gradient from viewDidLayoutSubviews;
  • I’d put these various shape layer and gradient methods in their own private extension.

Even better, all of this animation code doesn’t really belong in the app’s view controller at all, but rather a UIView subclass (or a child view controller).

Thus, perhaps:

@IBDesignable
public class GradientProgressView: UIView {

    private var shapeLayer: CAShapeLayer = {
        let shapeLayer = CAShapeLayer()
        shapeLayer.strokeColor = UIColor.green.cgColor
        shapeLayer.fillColor = UIColor.clear.cgColor
        shapeLayer.lineCap = .round
        return shapeLayer
    }()

    private var trackLayer: CAShapeLayer = {
        let trackLayer = CAShapeLayer()
        trackLayer.strokeColor = UIColor.groupTableViewBackground.cgColor
        trackLayer.fillColor = UIColor.clear.cgColor
        trackLayer.lineCap = .round
        return trackLayer
    }()

    private var gradient: CAGradientLayer = {
        let gradient = CAGradientLayer()

        let color = UIColor(red: 11/255, green: 95/255, blue: 244/255, alpha: 1).cgColor
        let sndColor = UIColor(red: 255/255, green: 87/255, blue: 87/255, alpha: 1).cgColor

        gradient.colors = [color, sndColor]
        gradient.locations = [0.0, 1.0]
        gradient.startPoint = CGPoint(x: 0, y: 0)
        gradient.endPoint = CGPoint(x: 1, y: 0)

        return gradient
    }()

    override init(frame: CGRect = .zero) {
        super.init(frame: frame)
        addSubLayers()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        addSubLayers()
    }

    override public func layoutSubviews() {
        super.layoutSubviews()
        updatePaths()
    }

    override public func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        setProgress(0.75, animated: false)
    }

    public func setProgress(_ progress: CGFloat, animated: Bool = true) {
        if animated {
            animateStroke(to: progress)
        } else {
            shapeLayer.strokeEnd = progress
        }
    }
}

private extension GradientProgressView {
    func addSubLayers() {
        layer.addSublayer(trackLayer)
        layer.addSublayer(shapeLayer)
        layer.addSublayer(gradient)
    }

    func updatePaths() {
        let lineWidth = bounds.height / 2
        trackLayer.lineWidth = lineWidth * 0.75
        shapeLayer.lineWidth = lineWidth

        let path = UIBezierPath()
        path.move(to:    CGPoint(x: bounds.minX + lineWidth / 2, y: bounds.midY))
        path.addLine(to: CGPoint(x: bounds.maxX - lineWidth / 2, y: bounds.midY))

        trackLayer.path = path.cgPath
        shapeLayer.path = path.cgPath

        gradient.frame = bounds
        gradient.mask = shapeLayer
    }

    func animateStroke(to progress: CGFloat) {
        let key = "lineStrokeAnimation"

        layer.removeAnimation(forKey: key)

        let basicAnimation = CABasicAnimation(keyPath: "strokeEnd")

        basicAnimation.toValue = progress
        basicAnimation.duration = 1.5
        basicAnimation.fillMode = .forwards
        basicAnimation.isRemovedOnCompletion = false
        basicAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)

        shapeLayer.add(basicAnimation, forKey: key)
    }
}

Then the view controller is merely:

class ViewController: UIViewController {

    @IBOutlet weak var gradientProgressView: GradientProgressView!

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        updateProgress()
    }

    ...
}

// MARK: - Progress related methods

private extension ViewController {
    func updateProgress() {
        let distance = currLeasingCar!.currentKm - currLeasing!.startKm
        let value = CGFloat(distance) / CGFloat(finalKm)
        gradientProgressView.setProgress(value)
    }
}
Below answered 29/5, 2019 at 17:38 Comment(3)
Thank you! Works fine - regarding viewDidLayoutSubviews(), where should the displayLine() be called instead?Simonsimona
@Tim - As a more advanced observation, I don’t believe that any of this code really belongs in a view controller, anyway. Better to make it its own class.Below
I used your code for project that I'm working on. It works great but I have a question about changing the CAShapeLayer colors. Would you be able to take a look at it. https://mcmap.net/q/1632949/-cashapelayer-with-different-colors/4833705. It's based off of your answer with some tweaksCrosscrosslet

© 2022 - 2024 — McMap. All rights reserved.