Loading Indicator using CABasicAnimation - swift

How can I make this loading indicator using CABasicAnimation?
I was trying to do like this
but it's working not correctly, I am not sure that adding four layers with specific paths and animations is a good idea. I need to make exactly the same I see on the picture. I hope, it's doable using only one layer with some magic
private func setupView() {
let firstLayer = createLayer()
let secondLayer = createLayer()
let thirdLayer = createLayer()
let fourthLayer = createLayer()
firstLayer.path = createBezierPath(
layerWidth: firstLayer.lineWidth,
startAngle: 0,
endAngle: 0.25
).cgPath
secondLayer.path = createBezierPath(
layerWidth: secondLayer.lineWidth,
startAngle: 0.25,
endAngle: 0.5
).cgPath
thirdLayer.path = createBezierPath(
layerWidth: thirdLayer.lineWidth,
startAngle: 0.5,
endAngle: 0.75
).cgPath
fourthLayer.path = createBezierPath(
layerWidth: thirdLayer.lineWidth,
startAngle: 0.75,
endAngle: 1
).cgPath
firstLayer.add(createAnimation(), forKey: "firstLayer")
secondLayer.add(createAnimation(), forKey: "secondLayer")
thirdLayer.add(createAnimation(), forKey: "thirdLayer")
fourthLayer.add(createAnimation(), forKey: "thirdLayer")
self.layer.addSublayer(firstLayer)
self.layer.addSublayer(secondLayer)
self.layer.addSublayer(thirdLayer)
self.layer.addSublayer(fourthLayer)
let animation = CABasicAnimation(keyPath: "transform.rotation.z")
animation.beginTime = 0
animation.duration = 1
animation.fromValue = CGFloat.angle(progress: 0)
animation.toValue = CGFloat.angle(progress: 0.25)
animation.timingFunction = CAMediaTimingFunction(name: .easeIn)
animation.fillMode = .forwards
animation.repeatDuration = .infinity
self.layer.add(animation, forKey: "mainAnim")
}
private func createBezierPath(
layerWidth: CGFloat,
startAngle: CGFloat,
endAngle: CGFloat
) -> UIBezierPath {
return .init(
arcCenter: CGPoint(x: bounds.width / 2, y: bounds.height / 2),
radius: (bounds.height - layerWidth) / 2,
startAngle: .angle(progress: startAngle),
endAngle: .angle(progress: endAngle),
clockwise: true
)
}
private func createAnimation() -> CABasicAnimation {
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.beginTime = 0
animation.duration = 1
animation.fromValue = 0
animation.toValue = 1
animation.timingFunction = CAMediaTimingFunction(name: .easeIn)
animation.fillMode = .forwards
animation.isRemovedOnCompletion = false
animation.repeatDuration = .infinity
return animation
}
private func createLayer() -> CAShapeLayer {
let shapeLayer = CAShapeLayer()
shapeLayer.strokeColor = UIColor.orange.cgColor
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.lineWidth = 5
shapeLayer.lineCap = .round
shapeLayer.strokeEnd = 0
return shapeLayer
}
private extension CGFloat {
static var initialAngle: CGFloat = -(.pi / 2)
static func angle(progress: CGFloat) -> CGFloat {
.pi * 2 * progress + .initialAngle
}
}

I'd use a motion design tool to recreate that. For example, I think you could create this animation with the free version of Flow, which can export Swift code or Lottie files.

Related

Swift Animate Mask Transition

How can you animate a mask moving down X pixels?
I have a circular mask applied to a black view that creates a spotlight effect. I want to animate the mask transitioning down 100 pixels, but using UIView.animate { mask.transform = CATransform3DTranslate(.init(), 0, 100, 0) } instantly moves it instead of animating the transition.
Here's the mask code:
private lazy var mask: CAShapeLayer = {
let mask = CAShapeLayer()
let path = CGMutablePath()
path.addArc(center: CGPoint(x: 0, y: 0), radius: 40, startAngle: 0, endAngle: 2.0 * .pi, clockwise: false)
path.addRect(CGRect(origin: .zero, size: view.frame.size))
mask.backgroundColor = UIColor.black.cgColor
mask.path = path
mask.fillRule = .evenOdd
return mask
}()
overlayView.layer.mask = mask
How would you animate this moving down 100 pixels?
To get the spotlight effect, you'll want to create paths that modify the arc shape and animate between them.
func spotlightPath(arcCenter: CGPoint) -> CGPath {
let path = CGMutablePath()
// move the spotlight
path.addArc(center: CGPoint(x: arcCenter.x, y: arcCenter.y), radius: 40, startAngle: 0, endAngle: 2.0 * .pi, clockwise: false)
path.addRect(CGRect(origin: .zero, size: view.frame.size))
return path
}
let startPath = spotlightPath(arcCenter: .zero)
And then later
let endPath = spotlightPath(arcCenter: CGPoint(x: 40, y: 100))
let animation = CABasicAnimation(keyPath: "path")
animation.duration = 1
animation.isRemovedOnCompletion = false
animation.fillMode = .forwards
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
animation.toValue = endPath
mask.add(animation, forKey: "path")
Try this Core animation block. CALayer animation is not supported by UIView animation block. To animate mask property we use the path key.
let anim = CABasicAnimation(keyPath: "path")
anim.toValue = createPath(center: .init(x: 100, y: 100))
anim.duration = 1
anim.fillMode = .forwards
anim.timingFunction = CAMediaTimingFunction.init(name: .easeInEaseOut)
anim.isRemovedOnCompletion = false
mask.add(anim, forKey: anim.keyPath)

Swift pulse animation non circle

I'm trying to create pulse animation rectangular shape
I have code:
func createPulse() {
for _ in 0...3 {
let circularPath = UIBezierPath(arcCenter: .zero, radius: view.bounds.size.height/2, startAngle: 0, endAngle: 2 * .pi, clockwise: true)
let pulseLayer = CAShapeLayer()
pulseLayer.path = circularPath.cgPath
pulseLayer.lineWidth = 2
pulseLayer.fillColor = UIColor.clear.cgColor
pulseLayer.lineCap = CAShapeLayerLineCap.round
pulseLayer.position = CGPoint(x: binauralBackView.frame.size.width/2, y: binauralBackView.frame.size.height/2)
pulseLayer.cornerRadius = binauralBackView.frame.width/2
binauralBackView.layer.insertSublayer(pulseLayer, at: 0)
pulseLayers.append(pulseLayer)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.animatePulse(index: 0)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
self.animatePulse(index: 1)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.animatePulse(index: 2)
}
}
}
}
func animatePulse(index: Int) {
pulseLayers[index].strokeColor = #colorLiteral(red: 0.7215686275, green: 0.5137254902, blue: 0.7647058824, alpha: 1).cgColor
let scaleAnimation = CABasicAnimation(keyPath: "transform.scale")
scaleAnimation.duration = 2
scaleAnimation.fromValue = 0.0
scaleAnimation.toValue = 0.9
scaleAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)
scaleAnimation.repeatCount = .greatestFiniteMagnitude
pulseLayers[index].add(scaleAnimation, forKey: "scale")
let opacityAnimation = CABasicAnimation(keyPath: #keyPath(CALayer.opacity))
opacityAnimation.duration = 2
opacityAnimation.fromValue = 0.9
opacityAnimation.toValue = 0.0
opacityAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)
opacityAnimation.repeatCount = .greatestFiniteMagnitude
pulseLayers[index].add(opacityAnimation, forKey: "opacity")
}
and have result
circle result
I need to create rectangular like this (like border of image):
rectangular
How can change circle to rectangular?
Output:
Note: You can add CABasicAnimation for opacity too in animationGroup if this solution works for you.
extension UIView {
/// animate the border width
func animateBorderWidth(toValue: CGFloat, duration: Double) -> CABasicAnimation {
let widthAnimation = CABasicAnimation(keyPath: "borderWidth")
widthAnimation.fromValue = layer.borderWidth
widthAnimation.toValue = toValue
widthAnimation.duration = duration
return widthAnimation
}
/// animate the scale
func animateScale(toValue: CGFloat, duration: Double) -> CABasicAnimation {
let scaleAnimation = CABasicAnimation(keyPath: "transform.scale")
scaleAnimation.fromValue = 1.0
scaleAnimation.toValue = toValue
scaleAnimation.duration = duration
scaleAnimation.repeatCount = .infinity
scaleAnimation.isRemovedOnCompletion = false
scaleAnimation.timingFunction = CAMediaTimingFunction(name: .easeOut)
return scaleAnimation
}
func pulsate(animationDuration: CGFloat) {
var animationGroup = CAAnimationGroup()
animationGroup = CAAnimationGroup()
animationGroup.duration = animationDuration
animationGroup.repeatCount = Float.infinity
let newLayer = CALayer()
newLayer.bounds = CGRect(x: 0, y: 0, width: self.frame.width, height: self.frame.height)
newLayer.cornerRadius = self.frame.width/2
newLayer.position = CGPoint(x: self.frame.width/2, y: self.frame.height/2)
newLayer.cornerRadius = self.frame.width/2
animationGroup.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.default)
animationGroup.animations = [animateScale(toValue: 6.0, duration: 2.0),
animateBorderWidth(toValue: 1.2, duration: animationDuration)]
newLayer.add(animationGroup, forKey: "pulse")
self.layer.cornerRadius = self.frame.width/2
self.layer.insertSublayer(newLayer, at: 0)
}
}
Usage:
class ViewController: UIViewController {
#IBOutlet weak var pulseView: UIView!
override func viewDidLoad() {
super.viewDidLoad()
pulseView.pulsate(animationDuration: 1.0)
}
}

CABasicAnimation around Circle goes too Far

I am looking to have an animated outer layer to a circle that will show progress. What I have below is able to do that; however, the ending animation of the outer fill goes too far. In my example I would like it go from the 12:00 position to the 6:00 position or 50% of the way around the circle. In practice it goes to about the 8:00 position.
class CircleView: UIView {
private let progressLayer = CAShapeLayer()
private var progressColor:UIColor!
required convenience init(progressColor:UIColor) {
self.init(frame:.zero)
self.progressColor = progressColor
}
override init(frame: CGRect) {
super.init(frame: frame)
self.translatesAutoresizingMaskIntoConstraints = false
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
override func draw(_ rect: CGRect) {
setup()
}
private func setup() {
let circleLayer = CAShapeLayer()
let center = CGPoint(x: self.bounds.size.width / 2, y: self.bounds.size.height / 2)
let path = UIBezierPath(arcCenter: center, radius: self.frame.width / 2, startAngle: -CGFloat.pi / 2, endAngle: CGFloat.pi * 2, clockwise: true)
circleLayer.path = path.cgPath
circleLayer.strokeColor = UIColor.gray.cgColor
circleLayer.fillColor = UIColor.white.cgColor
circleLayer.lineCap = CAShapeLayerLineCap.round
circleLayer.lineWidth = 20
circleLayer.masksToBounds = false
self.layer.addSublayer(circleLayer)
progressLayer.path = path.cgPath
progressLayer.strokeColor = progressColor.cgColor
progressLayer.fillColor = UIColor.clear.cgColor
progressLayer.lineCap = CAShapeLayerLineCap.round
progressLayer.lineWidth = 20
circleLayer.addSublayer(progressLayer)
}
func animateProgress(percentComplete:Double) {
let progressAnimation = CABasicAnimation(keyPath: "strokeEnd")
progressAnimation.fromValue = 0
progressAnimation.toValue = 0.5
progressAnimation.duration = 2
progressAnimation.fillMode = .forwards
progressAnimation.isRemovedOnCompletion = false
progressLayer.add(progressAnimation, forKey: "strokeEnd")
}
If I change to the above line to progressAnimation.toValue = 0.4 it will show the 12-6 I am looking for. I am not understanding why it would be 0.4 versus 0.5?
It will be OK to fix the angle .
Turn
let path = UIBezierPath(arcCenter: center, radius: self.frame.width / 2, startAngle: -CGFloat.pi / 2, endAngle: CGFloat.pi * 2, clockwise: true)
to
let path = UIBezierPath(arcCenter: center, radius: self.frame.width / 2, startAngle: -CGFloat.pi / 2, endAngle: 1.5 * CGFloat.pi, clockwise: true)
A circle is of 2 * CGFloat.pi, not 2.5 * CGFloat.pi.
startAngle: -CGFloat.pi / 2, endAngle: CGFloat.pi * 2 is 2.5 * CGFloat.pi.
2 * CGFloat.pi * 0.5 equals 2.5 * CGFloat.pi * 0.4

Resize radius from 0 to 100 with CABasicAnimation

I want to animate a circle. The circle radius should grow from 0 to 100. I tried it with the transform.scale animation. But of course it is not possible to scale a circle with a radius of 0. When I set the radius of the circle to 1 the circle is visible although it shouldn't be at the beginning of the animation.
minimal example:
let circleShape = CAShapeLayer()
let circlePath = UIBezierPath(arcCenter: .zero, radius: 0, startAngle: CGFloat(0), endAngle: CGFloat(Double.pi*2), clockwise: true)
circleShape.path = circlePath.cgPath
circleShape.fillColor = UIColor.brown.cgColor
let circleAnimation = CABasicAnimation(keyPath: "transform.scale")
circleAnimation.fromValue = 0
circleAnimation.toValue = 100
circleAnimation.duration = 1.9
circleAnimation.beginTime = CACurrentMediaTime() + 1.5
circleShape.add(circleAnimation, forKey: nil)
At the end of the animation the radius should stay at the new value.
EDIT:
Thanks to #Rob mayoff
Final code:
let circleShape = CAShapeLayer()
let circlePath = UIBezierPath(arcCenter: .zero, radius: 400, startAngle: CGFloat(0), endAngle: CGFloat(Double.pi*2), clockwise: true)
circleShape.path = circlePath.cgPath
circleShape.fillColor = UIColor.brown.cgColor
let circleAnimation = CABasicAnimation(keyPath: "transform.scale")
circleAnimation.fromValue = 0.0000001
circleAnimation.toValue = 1
circleAnimation.duration = 1.9
circleAnimation.beginTime = CACurrentMediaTime() + 1.5
circleShape.add(circleAnimation, forKey: nil)
circleAnimation.fillMode = .backwards
Probably what you want to do is set the radius of circlePath to your final radius:
let circlePath = UIBezierPath(arcCenter: .zero, radius: 0, startAngle: CGFloat(0), endAngle: CGFloat(Double.pi*2), clockwise: true)
circlePath.clone()
And then animate from a near-zero number to 1.0:
circleAnimation.fromValue = 0.0001
circleAnimation.toValue = 1.0
If you want to have the animation start in the future (by setting beginTime), then you also probably want to set the fill mode so that the fromValue is applied to the layer until the animation starts:
circleAnimation.fillMode = .backwards
You should use a radius greater 0. Then you can scale it down with circleShape.transform = CATransform3DMakeScale(0.0, 0.0, 0.0)
before adding the layer. And then you could start the animation:
let circleShape = CAShapeLayer()
let center = CGPoint(x: view.frame.width / 2, y: view.frame.height / 2)
let circlePath = UIBezierPath(arcCenter: center, radius: 100.0, startAngle: CGFloat(0), endAngle: CGFloat(Double.pi*2), clockwise: true)
circleShape.transform = CATransform3DMakeScale(0.0, 0.0, 0.0)
circleShape.path = circlePath.cgPath
circleShape.fillColor = UIColor.brown.cgColor
let circleAnimation = CABasicAnimation(keyPath: "transform.scale")
circleAnimation.fromValue = 0
circleAnimation.toValue = 1
circleAnimation.duration = 5.9
circleAnimation.beginTime = CACurrentMediaTime() + 1.5
circleShape.add(circleAnimation, forKey: nil)
view.layer.addSublayer(circleShape)

How to animate drawing shape fit in 60 second?

I'm new to Swift and I don't have too much information about it.I would like to create animated clock with filling 'CAShapeLayer' path in time interval than tried to make it with 'CABasicAnimation' in 60 seconds. The shape fills in 49 sec and animation finish fit in 60 seconds, so it's not working as desired. Than I changed animation.byValue = 0.1 but the result is same. Some one have an idea about this issue?
My code is :
var startAngle = -CGFloat.pi / 2
var endAngle = CGFloat.pi * 2
var shapeLayer = CAShapeLayer()
override func viewDidLoad() {
super.viewDidLoad()
let center = view.center
let circlePath = UIBezierPath(arcCenter: center, radius: 100, startAngle: startAngle, endAngle: endAngle, clockwise: true)
shapeLayer.path = circlePath.cgPath
shapeLayer.lineWidth = 20
shapeLayer.strokeColor = UIColor.lightGray.cgColor
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.lineCap = kCALineCapRound
shapeLayer.strokeEnd = 0
animate(layer: shapeLayer) //Animation func
view.layer.addSublayer(shapeLayer)
}
private func animate(layer: CAShapeLayer) {
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = 0.0
animation.toValue = 1.0
animation.duration = 60.0 //I think is counting by seconds?
animation.repeatCount = .infinity
animation.fillMode = kCAFillModeForwards
layer.add(animation, forKey: "strokeEndAnimation")
}
The problem is with the way you made the BezierPath.
Try this instead:
let circlePath = UIBezierPath(arcCenter: .zero, radius: 100, startAngle: 0, endAngle: 2 * CGFloat.pi, clockwise: true)
shapeLayer.path = circlePath.cgPath
shapeLayer.lineWidth = 20
shapeLayer.strokeColor = UIColor.lightGray.cgColor
shapeLayer.strokeStart = 0
shapeLayer.strokeEnd = 0
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.lineCap = kCALineCapRound
shapeLayer.strokeEnd = 0
shapeLayer.position = self.view.center
shapeLayer.transform = CATransform3DRotate(CATransform3DIdentity, -CGFloat.pi / 2, 0, 0, 1)
The arcCenter must be at .zero and set the shape's center as the center of the view. Your circle will start animating from the right most point though, therefore add a CATransform to rotate the shape 90 degrees counterclockwise.