Search code examples
iosswiftanimationuibezierpathcabasicanimation

Dash the stroke of UIBezierPath without using the CAShapeLayer and animate this stroke


UIBezierPath only get dashed when used inside drawRect() method in UIView like so:

override func draw(_ rect: CGRect)
    {
        let  path = UIBezierPath()
        let  p0 = CGPoint(x: self.bounds.minX, y: self.bounds.midY)
        path.move(to: p0)
        let  p1 = CGPoint(x: self.bounds.maxX, y: self.bounds.midY)
        path.addLine(to: p1)

        let  dashes: [ CGFloat ] = [ 0.0, 16.0 ]
        path.setLineDash(dashes, count: dashes.count, phase: 0.0)
        path.lineWidth = 8.0
        path.lineCapStyle = .round
        UIColor.red.set()
        path.stroke()
    }

enter image description here

If I want to animate this line stroke, I'll be needing to use CAShapeLayer like so

override func draw(_ rect: CGRect)
    {
        let  path = UIBezierPath()
        let  p0 = CGPoint(x: self.bounds.minX, y: self.bounds.midY)
        path.move(to: p0)
        let  p1 = CGPoint(x: self.bounds.maxX, y: self.bounds.midY)
        path.addLine(to: p1)

        let  dashes: [ CGFloat ] = [ 0.0, 16.0 ]
        path.setLineDash(dashes, count: dashes.count, phase: 0.0)
        path.lineWidth = 8.0
        path.lineCapStyle = .round
        UIColor.red.set()
        path.stroke()

        let layer = CAShapeLayer()
        layer.path = path.cgPath
        layer.strokeColor = UIColor.black.cgColor
        layer.lineWidth = 3
        layer.fillColor = UIColor.clear.cgColor
        layer.lineJoin = kCALineCapButt
        self.layer.addSublayer(layer)
        animateStroke(layer: layer)
    }

    func animateStroke(layer:CAShapeLayer)
    {
        let pathAnimation = CABasicAnimation(keyPath: "strokeEnd")
        pathAnimation.duration = 10
        pathAnimation.fromValue = 0
        pathAnimation.toValue = 1
        pathAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
        layer.add(pathAnimation, forKey: "strokeEnd")
    }

The Black line of the CAShapeLayer got animated.

enter image description here

What I need is, to add dashed UIBezierpath to CAShapeLayer, so that I can animate it.

Note: I do not want to use CAShapeLayer's lineDashPattern method as I'm appending multiple paths some need to be dashed and some not.


Solution

  • You should not invoke animations from draw(_:). The draw(_:) is for rendering a single frame.

    You say you don't want to use lineDashPattern, but I personally would, using a different shape layer for each pattern. So, for example, here is an animation, stroking one path with no dash pattern, stroking the other with dash pattern, and just triggering the second upon the completion of the first:

    struct Stroke {
        let start: CGPoint
        let end: CGPoint
        let lineDashPattern: [NSNumber]?
    
        var length: CGFloat {
            return hypot(start.x - end.x, start.y - end.y)
        }
    }
    
    class CustomView: UIView {
    
        private var strokes: [Stroke]?
        private var strokeIndex = 0
        private let strokeSpeed = 200.0
    
        func startAnimation() {
            strokes = [
                Stroke(start: CGPoint(x: bounds.minX, y: bounds.midY),
                       end: CGPoint(x: bounds.midX, y: bounds.midY),
                       lineDashPattern: nil),
                Stroke(start: CGPoint(x: bounds.midX, y: bounds.midY),
                       end: CGPoint(x: bounds.maxX, y: bounds.midY),
                       lineDashPattern: [0, 16])
            ]
            strokeIndex = 0
    
            animateStroke()
        }
    
        private func animateStroke() {
            guard let strokes = strokes, strokeIndex < strokes.count else { return }
    
            let stroke = strokes[strokeIndex]
    
            let shapeLayer = CAShapeLayer()
            shapeLayer.lineCap = kCALineCapRound
            shapeLayer.lineDashPattern = strokes[strokeIndex].lineDashPattern
            shapeLayer.lineWidth = 8
            shapeLayer.strokeColor = UIColor.red.cgColor
            layer.addSublayer(shapeLayer)
    
            let path = UIBezierPath()
            path.move(to: stroke.start)
            path.addLine(to: stroke.end)
    
            shapeLayer.path = path.cgPath
    
            let animation = CABasicAnimation(keyPath: "strokeEnd")
            animation.fromValue = 0
            animation.toValue = 1
            animation.duration = Double(stroke.length) / strokeSpeed
            animation.delegate = self
            shapeLayer.add(animation, forKey: nil)
        }
    
    }
    
    extension CustomView: CAAnimationDelegate {
        func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
            guard flag else { return }
    
            strokeIndex += 1
            animateStroke()
        }
    }
    

    enter image description here

    If you really want to use the draw(_:) approach, you wouldn't use CABasicAnimation, but instead would probably use a CADisplayLink, repeatedly calling setNeedsDisplay(), and having a draw(_:) method that renders the view depending upon how much time has elapsed. But draw(_:) renders a single frame of the animation and should not initiate any CoreAnimation calls.


    If you really don't want to use shape layers, you can use the aforementioned CADisplayLink to update the percent complete based upon the elapsed time and desired duration, and draw(_:) only strokes as many of the individual paths as appropriate for any given moment in time:

    struct Stroke {
        let start: CGPoint
        let end: CGPoint
        let length: CGFloat                // in this case, because we're going call this a lot, let's make this stored property
        let lineDashPattern: [CGFloat]?
    
        init(start: CGPoint, end: CGPoint, lineDashPattern: [CGFloat]?) {
            self.start = start
            self.end = end
            self.lineDashPattern = lineDashPattern
            self.length = hypot(start.x - end.x, start.y - end.y)
        }
    }
    
    class CustomView: UIView {
    
        private var strokes: [Stroke]?
        private let duration: CGFloat = 3.0
        private var start: CFTimeInterval?
        private var percentComplete: CGFloat?
        private var totalLength: CGFloat?
    
        func startAnimation() {
            strokes = [
                Stroke(start: CGPoint(x: bounds.minX, y: bounds.midY),
                       end: CGPoint(x: bounds.midX, y: bounds.midY),
                       lineDashPattern: nil),
                Stroke(start: CGPoint(x: bounds.midX, y: bounds.midY),
                       end: CGPoint(x: bounds.maxX, y: bounds.midY),
                       lineDashPattern: [0, 16])
            ]
            totalLength = strokes?.reduce(0.0) { $0 + $1.length }
    
            start = CACurrentMediaTime()
            let displayLink = CADisplayLink(target: self, selector: #selector(handleDisplayLink(_:)))
            displayLink.add(to: .main, forMode: .commonModes)
        }
    
        @objc func handleDisplayLink(_ displayLink: CADisplayLink) {
            percentComplete = min(1.0, CGFloat(CACurrentMediaTime() - start!) / duration)
            if percentComplete! >= 1.0 {
                displayLink.invalidate()
                percentComplete = 1
            }
    
            setNeedsDisplay()
        }
    
        // Note, no animation is in the following routine. This just stroke your series of paths
        // until the total percent of the stroked path equals `percentComplete`. The animation is
        // achieved above, by updating `percentComplete` and calling `setNeedsDisplay`. This method
        // only draws a single frame of the animation.
    
        override func draw(_ rect: CGRect) {
            guard let totalLength = totalLength,
                let strokes = strokes,
                strokes.count > 0,
                let percentComplete = percentComplete else { return }
    
            UIColor.red.setStroke()
    
            // Don't get lost in the weeds here; the idea is to simply stroke my paths until the
            // percent of the lengths of all of the stroked paths reaches `percentComplete`. Modify
            // the below code to match whatever model you use for all of your stroked paths.
    
            var lengthSoFar: CGFloat = 0
            var percentSoFar: CGFloat = 0
            var strokeIndex = 0
            while lengthSoFar / totalLength < percentComplete && strokeIndex < strokes.count {
                let stroke = strokes[strokeIndex]
                let endLength = lengthSoFar + stroke.length
                let endPercent = endLength / totalLength
                let percentOfThisStroke = (percentComplete - percentSoFar) / (endPercent - percentSoFar)
                var end: CGPoint
                if percentOfThisStroke < 1 {
                    let angle = atan2(stroke.end.y - stroke.start.y, stroke.end.x - stroke.start.x)
                    let distance = stroke.length * percentOfThisStroke
                    end = CGPoint(x: stroke.start.x + distance * cos(angle),
                                  y: stroke.start.y + distance * sin(angle))
                } else {
                    end = stroke.end
                }
                let path = UIBezierPath()
                if let pattern = stroke.lineDashPattern {
                    path.setLineDash(pattern, count: pattern.count, phase: 0)
                }
                path.lineWidth = 8
                path.lineCapStyle = .round
                path.move(to: stroke.start)
                path.addLine(to: end)
                path.stroke()
    
                strokeIndex += 1
                lengthSoFar = endLength
                percentSoFar = endPercent
            }
        }
    }
    

    This achieves the identical effect as the first code snippet, though likely it isn't going to be anywhere near as efficient.