Search code examples
iosswiftcore-graphicscore-animationios-animations

Snake like animation on the rounded rectangle path


I'm trying to implement an animation similar to what you can see on the image: enter image description here

I'm using the Core Graphics and Core Animation with UIBezierPath to achieve this, but the problem seems to be with the start and the end of the CGPath (strokeStart always needs to be smaller than strokeEnd so the snake will not animate through the point where the path closes). After spending way too much time on this I begin to think that perhaps I'm using wrong tools for the job, any tips are welcome.

Here is the code sample I used for generating the image:

func animate() {
        let centerRectInRect = {(rect: CGRect, bounds: CGRect) -> CGRect in
            return CGRect(x: bounds.origin.x + ((bounds.width - rect.width) / 2.0),
                          y: bounds.origin.y + ((bounds.height - rect.height) / 2.0),
                          width: rect.width,
                          height: rect.height)
        }
        
        
        let shapeLayer = CAShapeLayer()
        shapeLayer.frame = centerRectInRect(CGRect(x: 0.0, y: 0.0, width: 200.0, height: 200.0), self.view.bounds)
        self.view.layer.addSublayer(shapeLayer)
        
        shapeLayer.strokeStart = 0.0
        shapeLayer.strokeEnd = 1.0
        shapeLayer.fillColor = UIColor.clear.cgColor
        shapeLayer.strokeColor = UIColor.red.cgColor
        shapeLayer.fillColor = UIColor.orange.withAlphaComponent(0.2).cgColor
        shapeLayer.lineWidth = 12.0
        
        let rect = shapeLayer.bounds
        let path = UIBezierPath(roundedRect: rect, byRoundingCorners: .allCorners, cornerRadii: CGSize(width: 16, height: 16))
        path.append(UIBezierPath(roundedRect: rect, byRoundingCorners: .allCorners, cornerRadii: CGSize(width: 16, height: 16)))
        shapeLayer.path = path.cgPath

        let strokeStartAnim = CAKeyframeAnimation(keyPath: "strokeStart")
        strokeStartAnim.values = [0, 1]
        strokeStartAnim.keyTimes = [0, 1]
        strokeStartAnim.duration = 12.0
        strokeStartAnim.beginTime = 1.0
        strokeStartAnim.repeatCount = .infinity
        strokeStartAnim.calculationMode = .paced
        
        let strokeEndAnim = CAKeyframeAnimation(keyPath: "strokeEnd")
        strokeEndAnim.values = [0, 1]
        strokeEndAnim.keyTimes = [0, 1]
        strokeEndAnim.duration = 12.0
        strokeEndAnim.repeatCount = .infinity
        strokeEndAnim.calculationMode = .paced

        let groupAnim = CAAnimationGroup()
        groupAnim.animations = [strokeStartAnim, strokeEndAnim]
        groupAnim.isRemovedOnCompletion = false
        groupAnim.fillMode = .forwards
        groupAnim.duration = .greatestFiniteMagnitude
        shapeLayer.add(groupAnim, forKey: "AnimateSnake")
        
    }

Solution

  • I finally managed to implement what I wanted. I don't think it's possible to do it with just one layer, so I used 2 layers and rotated the second layer 180 degrees, then synchronized the animations so that they overlap giving the effect that only one stroke is animated. Bonus - line cap can be selected from CAShapeLayerLineCap.

    import UIKit
    
    class RoundedRectActivityIndicator: UIView {
        private var shapeLayer: CAShapeLayer!
        private var progressLayer1: CAShapeLayer!
        private var progressLayer2: CAShapeLayer!
        
        let cornerRadius: CGFloat
        let lineWidth: CGFloat
        private(set) var segmentLength: CGFloat
        let lineCap: CAShapeLayerLineCap
        
        private var readyForDrawing = false
        private(set) var isAnimating = false
        
        private var strokeColor: UIColor
        private var strokeBackgroundColor: UIColor
        private var animationDuration: CFTimeInterval
        private var timeOffset: CFTimeInterval
        private var automaticStart: Bool
        
        required init(strokeColor: UIColor,
                      strokeBackgroundColor: UIColor = .clear,
                      cornerRadius: CGFloat = 0.0,
                      lineWidth: CGFloat = 4.0,
                      lineCap: CAShapeLayerLineCap = .round,
                      segmentLength: CGFloat = 0.4,
                      duration: CFTimeInterval,
                      timeOffset: CFTimeInterval = 0.0,
                      automaticStart: Bool = true) {
            self.strokeColor = strokeColor
            self.strokeBackgroundColor = strokeBackgroundColor
            self.cornerRadius = cornerRadius
            self.lineWidth = lineWidth
            self.lineCap = lineCap
            self.segmentLength = segmentLength
            self.animationDuration = duration
            self.timeOffset = timeOffset
            self.automaticStart = automaticStart
            super.init(frame: CGRect.zero)
        }
        
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
        
        override func layoutSubviews() {
            super.layoutSubviews()
            if !readyForDrawing {
                firstTimeSetup()
            }
            if !isAnimating && automaticStart {
                startAnimating()
            }
        }
        
        private func firstTimeSetup() {
            shapeLayer = newShapeLayer(rectangle: bounds)
            shapeLayer.strokeColor = strokeBackgroundColor.cgColor
            shapeLayer.strokeStart = 0
            shapeLayer.strokeEnd = 1
            layer.addSublayer(shapeLayer)
            
            progressLayer1 = newShapeLayer(rectangle: bounds, lineCap: lineCap)
            progressLayer1.strokeColor = strokeColor.cgColor
            
            progressLayer2 = newShapeLayer(rectangle: bounds, lineCap: lineCap, rotation: 180)
            progressLayer2.strokeColor = strokeColor.cgColor
            
            readyForDrawing = true
        }
        
        private func newShapeLayer(rectangle: CGRect,
                                   fillColor: UIColor = .clear,
                                   lineCap: CAShapeLayerLineCap = .butt,
                                   rotation: CGFloat = 0) -> CAShapeLayer {
            let layer = CAShapeLayer()
            let path = newPath(rectangle: rectangle, cornerRadius: cornerRadius, rotation: rotation)
            layer.path = path.cgPath
            layer.lineWidth = lineWidth
            layer.fillColor = fillColor.cgColor
            layer.lineCap = lineCap
            return layer
        }
        
        private func newPath(rectangle: CGRect, cornerRadius: CGFloat, rotation: CGFloat = 0) -> UIBezierPath {
            let path = UIBezierPath(roundedRect: rectangle, cornerRadius: cornerRadius)
            path.rotate(degree: rotation)
            return path
        }
    
        func startAnimating() {
            isAnimating = true
            
            layer.addSublayer(progressLayer1)
            
            progressLayer2.strokeStart = 0
            progressLayer2.strokeEnd = 0
            layer.addSublayer(progressLayer2)
            
            let strokeEndAnimation1 = CAKeyframeAnimation(keyPath: "strokeEnd")
            strokeEndAnimation1.values = [0, 1]
            strokeEndAnimation1.keyTimes = [0, 1]
            
            let strokeStartAnimation1 = CAKeyframeAnimation(keyPath: "strokeStart")
            strokeStartAnimation1.values = [0, 1]
            strokeStartAnimation1.keyTimes = [0, 1]
            strokeStartAnimation1.beginTime = animationDuration * segmentLength
            
            let animationGroup1 = CAAnimationGroup()
            animationGroup1.animations = [strokeEndAnimation1, strokeStartAnimation1]
            animationGroup1.isRemovedOnCompletion = false
            animationGroup1.duration = animationDuration
            animationGroup1.fillMode = .forwards
            animationGroup1.repeatCount = .infinity
            animationGroup1.timeOffset = timeOffset
            progressLayer1.add(animationGroup1, forKey: "animationGroup1")
            
            let strokeEndAnimation2 = CAKeyframeAnimation(keyPath: "strokeEnd")
            strokeEndAnimation2.values = [0, 1]
            strokeEndAnimation2.keyTimes = [0, 1]
            
            let strokeStartAnimation2 = CAKeyframeAnimation(keyPath: "strokeStart")
            strokeStartAnimation2.values = [0, 1]
            strokeStartAnimation2.keyTimes = [0, 1]
            strokeStartAnimation2.beginTime = animationDuration * segmentLength
            
            let animationGroup2 = CAAnimationGroup()
            animationGroup2.animations = [strokeEndAnimation2, strokeStartAnimation2]
            animationGroup2.isRemovedOnCompletion = false
            animationGroup2.duration = animationDuration
            animationGroup2.fillMode = .forwards
            animationGroup2.repeatCount = .infinity
            animationGroup2.beginTime = CACurrentMediaTime() + animationDuration / 2
            animationGroup2.timeOffset = timeOffset
            progressLayer2.add(animationGroup2, forKey: "animationGroup2")
        }
    
        func completeProgress() {
            progressLayer1.removeAllAnimations()
            progressLayer2.removeAllAnimations()
            progressLayer1.strokeStart = 0
            progressLayer1.strokeEnd = 1
        }
    }
    
    extension UIBezierPath {
        func rotate(degree: CGFloat) {
            let bounds: CGRect = self.cgPath.boundingBox
            let center = CGPoint(x: bounds.midX, y: bounds.midY)
            
            let radians = degree / 180.0 * .pi
            var transform: CGAffineTransform = .identity
            transform = transform.translatedBy(x: center.x, y: center.y)
            transform = transform.rotated(by: radians)
            transform = transform.translatedBy(x: -center.x, y: -center.y)
            self.apply(transform)
        }
    }
    

    You use it like this:

    import UIKit
    
    class ViewController: UIViewController {
    
        override func viewDidLoad() {
            super.viewDidLoad()
            let segmentLenth = 0.4
            let duration: CFTimeInterval = 10
            let timeOffset: CFTimeInterval = segmentLenth * duration // or 0 if you don't mind it starting from the top left
            
            let indicator = RoundedRectActivityIndicator(strokeColor: .red,
                                                         strokeBackgroundColor: .orange.withAlphaComponent(0.2),
                                                         cornerRadius: 6,
                                                         lineWidth: 6,
                                                         segmentLength: segmentLenth,
                                                         duration: duration,
                                                         timeOffset: timeOffset)
            indicator.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(indicator)
            NSLayoutConstraint.activate([
                indicator.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 50),
                indicator.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -50),
                indicator.topAnchor.constraint(equalTo: view.topAnchor, constant: 350),
                indicator.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -350)
            ])
            
    //        DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(8)) {
    //            indicator.completeProgress()
    //        }
        }
    }
    

    Result:

    enter image description here