I'm trying to implement an animation similar to what you can see on the image:
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")
}
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: