2

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")
        
    }
lawicko
  • 7,246
  • 3
  • 37
  • 49

2 Answers2

0

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

lawicko
  • 7,246
  • 3
  • 37
  • 49
-1

Another approach, if you want to try it out...

Use a 1/4 "pie wedge" as a mask for the bordered, rounded-corner shape layer, and rotate that pie wedge layer...

Note that the "jumping" seen here is only because the recording is repeating.

One-quarter "pie wedge" shape layer:

When set as a mask:

Sample view subclass:

class SnakeView: UIView {
    
    let snakeLayer = CAShapeLayer()
    let mskLayer = CAShapeLayer()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder aDecoder: NSCoder) {
        super.init(coder:aDecoder)
        commonInit()
    }
    
    func commonInit() {
        self.backgroundColor = UIColor.orange.withAlphaComponent(0.2)
        layer.cornerRadius = 16
        layer.addSublayer(snakeLayer)
        
    }
    override func layoutSubviews() {
        super.layoutSubviews()
        
        snakeLayer.fillColor = UIColor.clear.cgColor
        snakeLayer.strokeColor = UIColor.red.cgColor
        snakeLayer.lineWidth = 12.0

        snakeLayer.path = UIBezierPath(roundedRect: bounds, cornerRadius: 16).cgPath

        // create a 1/4th "pie wedge" shape
        //  use bounds.width as the radius, so it will cover the corners
        let pth = UIBezierPath()
        pth.move(to: bounds.center)
        pth.addArc(withCenter: bounds.center, radius: bounds.width, startAngle: 0.0, endAngle: .pi * 0.5, clockwise: true)
        pth.close()
        
        mskLayer.path = pth.cgPath
        
        // any opaque color
        mskLayer.fillColor = UIColor.blue.cgColor
        // so it will rotate at the center
        mskLayer.frame = bounds
        
        // mask the rounded-rect bordered shape layer
        snakeLayer.mask = mskLayer

        //layer.addSublayer(mskLayer)
    
        doAnim()
        
    }
    
    func doAnim() {
        
        // rotate the mask layer
        let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation")
        rotateAnimation.fromValue = 0.0
        rotateAnimation.toValue = CGFloat(Double.pi * 2)
        rotateAnimation.isRemovedOnCompletion = false
        rotateAnimation.duration = 2.0
        rotateAnimation.repeatCount=Float.infinity
        mskLayer.add(rotateAnimation, forKey: nil)
        
    }

}
DonMag
  • 69,424
  • 5
  • 50
  • 86
  • Thanks for the reply, I'm not down-voting it but this is not what I'm looking for - it is too far from what you can see on the image I attached to my question. Also I can't see the images you attached. – lawicko Jun 23 '23 at 06:43
  • @lawicko - it was just another approach that *might* have been an option... thought it was close, but oh well :) (not sure why you couldn't see the images: https://imgur.com/a/GFUg5a3) – DonMag Jun 23 '23 at 11:46