Animating CAShapeLayer according to button (using auto layout) - ios

My button is being resized by using auto layout. The button has a CAShapeLayer which it's frame is being set at layoutSubviews:
var initial = true
override func layoutSubviews() {
super.layoutSubviews()
if initial {
border.frame = layer.bounds
border.path = UIBezierPath(rect: bounds).CGPath
initial = false
}
}
Because of this, whenever the size of this button is changed, the layer is changed immediately without any animation (which hides the button animation itself).
I've tried to add an animation:
override func layoutSubviews() {
super.layoutSubviews()
func movePosition() {
let fromValue = border.position.x
let toValue = center.x
CATransaction.setDisableActions(true)
border.position.x = toValue
let positionAnimation = CABasicAnimation(keyPath: "position.x")
positionAnimation.fromValue = fromValue
positionAnimation.toValue = toValue
positionAnimation.duration = 0.5
border.addAnimation(positionAnimation, forKey: "position")
}
func changesWidth() {
let fromValue = border.frame.size.width
let toValue = layer.bounds.width
CATransaction.setDisableActions(true)
border.frame.size.width = toValue
let widthAnimation = CABasicAnimation(keyPath: "frame.size.width")
widthAnimation.fromValue = fromValue
widthAnimation.toValue = toValue
widthAnimation.duration = 0.5
border.addAnimation(widthAnimation, forKey: "width")
}
if initial {
border.frame = layer.bounds
border.path = UIBezierPath(rect: bounds).CGPath
initial = false
} else {
movePosition()
changesWidth()
}
}
But with this, the animation is totally messed up (and layoutSubview is called a few times initially so it messes the layers up at the start.
Is it right to be trying to animate the layer in layoutSubviews at all? Or should I calculate the widths manually and animate the layers from there?

Related

ios animation - display image/fill color from left to right with animating

I have a image, been added to CALayer.contents.
The image basically contains a word/letters, the requirement is to animate like showing up letters from left to right gradually, mean while animating the image positions as like initially 1st letter on the middle and eventually whole image been at central position.
Any suggestion on how to achieve this with Core animation? Code snippets will be greatly appreciated.
One thought I had is to set the image colour same as background colour at initial state(looks transparent), and fill image colour to desired colour from left to right with animation. And at the same time animate the image position.
You can do this by using a gradient layer as a mask, then animating the position of the image layer and the locations of the gradient.
Here's a quick example to get started:
class RevealView: UIView {
public var image: UIImage? {
didSet {
imgLayer.contents = image?.cgImage
}
}
public var duration: TimeInterval = 1.0
private let gradLayer = CAGradientLayer()
private let imgLayer = CALayer()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
clipsToBounds = true
layer.addSublayer(imgLayer)
imgLayer.contentsGravity = .resize
// white area shows through, clear area is "hidden"
gradLayer.colors = [UIColor.white.cgColor, UIColor.clear.cgColor]
// left-to-right gradient
gradLayer.startPoint = CGPoint(x: 0.0, y: 0.5)
gradLayer.endPoint = CGPoint(x: 1.0, y: 0.5)
// start locations so entire layer is masked
gradLayer.locations = [0.0, 0.0]
// set the mask
layer.mask = gradLayer
}
override func layoutSubviews() {
super.layoutSubviews()
// set image layer frame to view bounds
imgLayer.frame = bounds
// move it half-way to the right
imgLayer.position.x = bounds.maxX
// set gradient layer frame to view bounds
gradLayer.frame = bounds
}
public func doAnim() {
let imgAnim = CABasicAnimation(keyPath: "position.x")
let gradAnim = CABasicAnimation(keyPath: "locations")
// animate image layer from right-to-left
imgAnim.toValue = bounds.midX
// animate gradient from left-to-right
gradAnim.toValue = [NSNumber(value: 1.0), NSNumber(value: 2.0)]
imgAnim.duration = self.duration
gradAnim.duration = imgAnim.duration * 2.0
[imgAnim, gradAnim].forEach { anim in
anim.isRemovedOnCompletion = false
anim.fillMode = .forwards
anim.beginTime = CACurrentMediaTime()
}
CATransaction.begin()
CATransaction.setDisableActions(true)
imgLayer.add(imgAnim, forKey: nil)
gradLayer.add(gradAnim, forKey: nil)
CATransaction.commit()
}
}
class RevealVC: UIViewController {
let testView = RevealView()
override func viewDidLoad() {
super.viewDidLoad()
guard let img = UIImage(named: "mt") else {
fatalError("Could not load image!")
}
testView.image = img
// use longer or shorter animation duration if desired (default is 1.0)
//testView.duration = 3.0
testView.translatesAutoresizingMaskIntoConstraints = false
testView.backgroundColor = .clear
view.addSubview(testView)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
testView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
testView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
testView.widthAnchor.constraint(equalToConstant: img.size.width),
testView.heightAnchor.constraint(equalToConstant: img.size.height),
])
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
testView.doAnim()
}
}

Is the a way to have a timing function work on multiple animations in Swift?

I'm trying to recreate the Activity ring that Apple uses in their activity apps. Here is an image for those unaware.
Apple Progress Ring.
I've done a decent job of recreating it, something I especially struggled with was the overlapping shadow. In the end, the workaround I used was to split the ring into two parts, the first 75% and the last 25% so I could have the last 25% have a shadow and appear to overlap with itself.
Now that I have done this, the animation timing has become more difficult. I now have three animations that I need to take care of.
The first 75% of the ring
The last 25% of the ring
Rotating the ring if it surpasses 100%
Here is a video demonstrating this. Streamable Link.
For illustrative purposes, here is the last 25% coloured differently so you can visualise it.
As you can see, the timing of the animation is a bit janky. So I have my timings set as follows
If the ring is filled at 75% or less, it takes 2.25 seconds to fill with a timing function of ease out
If the ring is filled between 75 and 100%, the first 75% takes 1 second to fill and the last 25% takes 1.25 seconds to fill with a timing function of ease out.
If the ring is filled over 100%, the first 75% takes 1 second, the last 25% takes 1 second and the rotation for the ring also takes 1 second with a timing function of ease out.
My question is, is it possible to link these seperate CABasicAnimations so I can set a total time of 2.25 seconds as well as set a timing function for that group so timing is calculated dynamically for each animation and a timing function affects all three?
Here is my code so far, it consists of 3 animation functions.
percent = How much to fill the ring
gradientMaskPart1 = first 75% ring layer
gradientMaskPart2 = last 25% ring layer
containerLayer = layer that holds both gradientMaskParts and is rotated to simute the ring overlapping itself.
private func animateRing() {
let needsMultipleAnimations = percent <= 0.75 ? false : true
CATransaction.begin()
if needsMultipleAnimations { CATransaction.setCompletionBlock(ringEndAnimation) }
let basicAnimation = CABasicAnimation(keyPath: "strokeEnd")
basicAnimation.fromValue = currentFill
basicAnimation.toValue = percent > 0.75 ? 0.75 : percent
currentFill = Double(percent)
basicAnimation.fillMode = .forwards
basicAnimation.isRemovedOnCompletion = false
basicAnimation.duration = needsMultipleAnimations ? 1 : 2.25
basicAnimation.timingFunction =
needsMultipleAnimations ? .none : CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)
gradientMaskPart1.add(basicAnimation, forKey: "basicAnimation")
CATransaction.commit()
}
private func ringEndAnimation() {
let needsMultipleAnimations = percent <= 1 ? false : true
CATransaction.begin()
if needsMultipleAnimations { CATransaction.setCompletionBlock(rotateRingAnimation) }
let duration = needsMultipleAnimations ? 1 : 1.25
let timingFunction: CAMediaTimingFunction? =
needsMultipleAnimations ? .none : CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)
let basicAnimation = CABasicAnimation(keyPath: "strokeEnd")
basicAnimation.fromValue = 0
basicAnimation.toValue = percent <= 1 ? (percent-0.75)*4 : 1
basicAnimation.duration = duration
basicAnimation.fillMode = .forwards
basicAnimation.isRemovedOnCompletion = false
basicAnimation.timingFunction = timingFunction
self.gradientMaskPart2.isHidden = false
self.gradientMaskPart2.add(basicAnimation2, forKey: "basicAnimation")
CATransaction.commit()
}
private func rotateRingAnimation() {
let rotationAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotationAnimation.fromValue = 2*CGFloat.pi
rotationAnimation.toValue = 2*CGFloat.pi+((2*(percent-1)*CGFloat.pi))
rotationAnimation.duration = 1
rotationAnimation.fillMode = CAMediaTimingFillMode.forwards
rotationAnimation.isRemovedOnCompletion = false
rotationAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)
self.containerLayer.add(rotationAnimation, forKey: "rotation")
}
This solution demonstrates how to animate several layers synchronously with their dedicated CABasicAnimation by using CAAnimationGroup.
Create a container CALayer subclass for layers that should be animated synchronously. Let's call it TestLayer
Define the property for every layer you need to animate.
Override needsDisplay(forKey:) in the TestLayer to receive the display call when animation modifies the particular layer via keyPath.
Override init(layer:) in the TestLayer to assign your sublayers to the testLayer.presentation() layer that renders the animation of the TestLayer.
Here is the whole code with TestView that hosts TestLayer.
class TestLayer: CALayer {
#NSManaged #objc dynamic var layer1: CAGradientLayer
#NSManaged #objc dynamic var layer2: CAGradientLayer
override init() {
super.init()
let gradientLayer1 = CAGradientLayer()
let gradientLayer2 = CAGradientLayer()
gradientLayer1.colors = [UIColor.red.cgColor, UIColor.green.cgColor]
gradientLayer2.colors = [UIColor.green.cgColor, UIColor.red.cgColor]
gradientLayer1.startPoint = CGPoint(x: 0.5, y: 0.5)
gradientLayer1.endPoint = CGPoint(x: 1.0, y: 0.5)
gradientLayer2.startPoint = CGPoint(x: 0, y: 0.5)
gradientLayer2.endPoint = CGPoint(x: 0.5, y: 0.5)
addSublayer(gradientLayer1)
addSublayer(gradientLayer2)
layer1 = gradientLayer1
layer2 = gradientLayer2
}
override init(layer: Any) {
super.init(layer: layer)
if let testLayer = layer as? TestLayer {
layer1 = testLayer.layer1
layer2 = testLayer.layer2
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSublayers() {
var bounds1 = self.bounds
bounds1.size.height /= 2.0
var bounds2 = bounds1
bounds2.origin.y = bounds1.maxY
super.layoutSublayers()
layer1.frame = bounds1
layer2.frame = bounds2
}
override class func needsDisplay(forKey key: String) -> Bool {
if key == "layer1" || key == "layer2" {
return true
}
return super.needsDisplay(forKey: key)
}
}
--
class TestView: UIView {
override class var layerClass: AnyClass {
get {
return TestLayer.self
}
}
var testLayer: TestLayer {
get {
return layer as! TestLayer
}
}
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func startAnimation() {
let animLayer1 = CABasicAnimation(keyPath: "layer1.startPoint")
animLayer1.fromValue = CGPoint(x: 0.5, y: 0.5)
animLayer1.toValue = CGPoint(x: 0.0, y: 0.5)
animLayer1.duration = 1.0
animLayer1.autoreverses = true
let animLayer2 = CABasicAnimation(keyPath: "layer2.endPoint")
animLayer2.fromValue = CGPoint(x: 0.5, y: 0.5)
animLayer2.toValue = CGPoint(x: 1.0, y: 0.5)
animLayer2.duration = 1.0
animLayer2.autoreverses = true
let group = CAAnimationGroup()
group.duration = 1.0
group.autoreverses = true
group.repeatCount = Float.greatestFiniteMagnitude
group.animations = [animLayer1, animLayer2]
testLayer.add(group, forKey: "TestAnim")
}
}
If you want to test it, create a dummy view controller, and override its viewWillAppear like this:
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
let testView = TestView()
testView.frame = view.bounds
testView.translatesAutoresizingMaskIntoConstraints = true
testView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(testView)
testView.startAnimation()
}

Animate UIView's Layer with constrains (Auto Layout Animations)

I am working on project where I need to animate height of view which consist of shadow, gradient and rounded corners (not all corners).
So I have used layerClass property of view.
Below is simple example demonstration.
Problem is that, when I change height of view by modifying its constraint, it was resulting in immediate animation of layer class, which is kind of awkward.
Below is my sample code
import UIKit
class CustomView: UIView{
var isAnimating: Bool = false
override init(frame: CGRect) {
super.init(frame: frame)
self.setupView()
}
func setupView(){
self.layoutMargins = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
guard let layer = self.layer as? CAShapeLayer else { return }
layer.fillColor = UIColor.green.cgColor
layer.shadowColor = UIColor.black.cgColor
layer.shadowOffset = CGSize(width: 0.0, height: 2.0)
layer.shadowOpacity = 0.6
layer.shadowRadius = 5
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override class var layerClass: AnyClass {
return CAShapeLayer.self
}
override func layoutSubviews() {
super.layoutSubviews()
// While animating `myView` height, this method gets called
// So new bounds for layer will be calculated and set immediately
// This result in not proper animation
// check by making below condition always true
if !self.isAnimating{ //if true{
guard let layer = self.layer as? CAShapeLayer else { return }
layer.path = UIBezierPath(roundedRect: bounds, cornerRadius: 10).cgPath
layer.shadowPath = layer.path
}
}
}
class TestViewController : UIViewController {
// height constraint for animating height
var heightConstarint: NSLayoutConstraint?
var heightToAnimate: CGFloat = 200
lazy var myView: CustomView = {
let view = CustomView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .clear
return view
}()
lazy var mySubview: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .yellow
return view
}()
lazy var button: UIButton = {
let button = UIButton(frame: .zero)
button.translatesAutoresizingMaskIntoConstraints = false
button.setTitle("Animate", for: .normal)
button.setTitleColor(.black, for: .normal)
button.addTarget(self, action: #selector(self.animateView(_:)), for: .touchUpInside)
return button
}()
override func viewDidLoad() {
super.viewDidLoad()
self.view.backgroundColor = .white
self.view.addSubview(self.myView)
self.myView.addSubview(self.mySubview)
self.view.addSubview(self.button)
self.myView.leadingAnchor.constraint(equalTo: self.view.layoutMarginsGuide.leadingAnchor).isActive = true
self.myView.trailingAnchor.constraint(equalTo: self.view.layoutMarginsGuide.trailingAnchor).isActive = true
self.myView.topAnchor.constraint(equalTo: self.view.layoutMarginsGuide.topAnchor, constant: 100).isActive = true
self.heightConstarint = self.myView.heightAnchor.constraint(equalToConstant: 100)
self.heightConstarint?.isActive = true
self.mySubview.leadingAnchor.constraint(equalTo: self.myView.layoutMarginsGuide.leadingAnchor).isActive = true
self.mySubview.trailingAnchor.constraint(equalTo: self.myView.layoutMarginsGuide.trailingAnchor).isActive = true
self.mySubview.topAnchor.constraint(equalTo: self.myView.layoutMarginsGuide.topAnchor).isActive = true
self.mySubview.bottomAnchor.constraint(equalTo: self.myView.layoutMarginsGuide.bottomAnchor).isActive = true
self.button.centerXAnchor.constraint(equalTo: self.view.centerXAnchor).isActive = true
self.button.bottomAnchor.constraint(equalTo: self.view.layoutMarginsGuide.bottomAnchor, constant: -20).isActive = true
}
#objc func animateView(_ sender: UIButton){
CATransaction.begin()
CATransaction.setAnimationDuration(5.0)
CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut))
UIView.animate(withDuration: 5.0, animations: {
self.myView.isAnimating = true
self.heightConstarint?.constant = self.heightToAnimate
// this will call `myView.layoutSubviews()`
// and layer's new bound will set automatically
// this causing layer to be directly jump to height 200, instead of smooth animation
self.view.layoutIfNeeded()
}) { (success) in
self.myView.isAnimating = false
}
let shadowPathAnimation = CABasicAnimation(keyPath: #keyPath(CAShapeLayer.shadowPath))
let pathAnimation = CABasicAnimation(keyPath: #keyPath(CAShapeLayer.path))
let toValue = UIBezierPath(
roundedRect:CGRect(x: 0, y: 0, width: self.myView.bounds.width, height: heightToAnimate),
cornerRadius: 10
).cgPath
shadowPathAnimation.fromValue = self.myView.layer.shadowPath
shadowPathAnimation.toValue = toValue
pathAnimation.fromValue = (self.myView.layer as! CAShapeLayer).path
pathAnimation.toValue = toValue
self.myView.layer.shadowPath = toValue
(self.myView.layer as! CAShapeLayer).path = toValue
self.myView.layer.add(shadowPathAnimation, forKey: #keyPath(CAShapeLayer.shadowPath))
self.myView.layer.add(pathAnimation, forKey: #keyPath(CAShapeLayer.path))
CATransaction.commit()
}
}
While animating view, it will call its layoutSubviews() method, which will result into recalculating bounds of shadow layer.
So I checked if view is currently animating, then do not recalculate bounds of shadow layer.
Is this approach right ? or there is any better way to do this ?
I know it's a tricky question. Actually, you don't need to care about layoutSubViews at all. The key here is when you set the shapeLayer. If it's setup well, i.e. after the constraints are all working, you don't need to care that during the animation.
//in CustomView, comment out the layoutSubViews() and add updateLayer()
func updateLayer(){
guard let layer = self.layer as? CAShapeLayer else { return }
layer.path = UIBezierPath(roundedRect: layer.bounds, cornerRadius: 10).cgPath
layer.shadowPath = layer.path
}
// override func layoutSubviews() {
// super.layoutSubviews()
//
// // While animating `myView` height, this method gets called
// // So new bounds for layer will be calculated and set immediately
// // This result in not proper animation
//
// // check by making below condition always true
//
// if !self.isAnimating{ //if true{
// guard let layer = self.layer as? CAShapeLayer else { return }
//
// layer.path = UIBezierPath(roundedRect: bounds, cornerRadius: 10).cgPath
// layer.shadowPath = layer.path
// }
// }
in ViewController: add viewDidAppear() and remove other is animation block
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
myView.updateLayer()
}
#objc func animateView(_ sender: UIButton){
CATransaction.begin()
CATransaction.setAnimationDuration(5.0)
CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut))
UIView.animate(withDuration: 5.0, animations: {
self.heightConstarint?.constant = self.heightToAnimate
// this will call `myView.layoutSubviews()`
// and layer's new bound will set automatically
// this causing layer to be directly jump to height 200, instead of smooth animation
self.view.layoutIfNeeded()
}) { (success) in
self.myView.isAnimating = false
}
....
Then you are good to go. Have a wonderful day.
Below code also worked for me, As I want to use layout subviews without any flags.
UIView.animate(withDuration: 5.0, animations: {
let shadowPathAnimation = CABasicAnimation(keyPath: #keyPath(CAShapeLayer.shadowPath))
let pathAnimation = CABasicAnimation(keyPath: #keyPath(CAShapeLayer.path))
let toValue = UIBezierPath(
roundedRect:CGRect(x: 0, y: 0, width: self.myView.bounds.width, height: self.heightToAnimate),
cornerRadius: 10
).cgPath
shadowPathAnimation.fromValue = self.myView.layer.shadowPath
shadowPathAnimation.toValue = toValue
pathAnimation.fromValue = (self.myView.layer as! CAShapeLayer).path
pathAnimation.toValue = toValue
self.heightConstarint?.constant = self.heightToAnimate
self.myView.layoutIfNeeded()
self.myView.layer.shadowPath = toValue
(self.myView.layer as! CAShapeLayer).path = toValue
self.myView.layer.add(shadowPathAnimation, forKey: #keyPath(CAShapeLayer.shadowPath))
self.myView.layer.add(pathAnimation, forKey: #keyPath(CAShapeLayer.path))
CATransaction.commit()
})
And overriding layoutSubview as follows
override func layoutSubviews() {
super.layoutSubviews()
guard let layer = self.layer as? CAShapeLayer else { return }
layer.path = UIBezierPath(roundedRect: bounds, cornerRadius: 10).cgPath
layer.shadowPath = layer.path
}

Core Animation - modify animation property

I have animation
func startRotate360() {
let rotation : CABasicAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotation.fromValue = 0
rotation.toValue = Double.pi * 2
rotation.duration = 1
rotation.isCumulative = true
rotation.repeatCount = Float.greatestFiniteMagnitude
self.layer.add(rotation, forKey: "rotationAnimation")
}
What I want is ability to stop animation by setting its repeat count to 1, so it completes current rotation (simply remove animation is not ok because it looks not good)
I try following
func stopRotate360() {
self.layer.animation(forKey: "rotationAnimation")?.repeatCount = 1
}
But I get crash and in console
attempting to modify read-only animation
How to access writable properties ?
Give this a go. You can in fact change CAAnimations that are in progress. There are so many ways. This is the fastest/simplest. You could even stop the animation completely and resume it without the user even noticing.
You can see the start animation function along with the stop. The start animation looks similar to yours while the stop grabs the current rotation from the presentation layer and creates an animation to rotate until complete. I also smoothed out the duration to be a percentage of the time needed to complete based on current rotation z to full rotation based on the running animation. Then I remove the animation with the repeat count and add the new animation. You can see the view rotate smoothly to the final position and stop. You will get the idea. Drop it in and run it and see what you think. Hit the button to start and hit it again to see it finish rotation and stop.
import UIKit
class ViewController: UIViewController {
var animationView = UIView()
var button = UIButton()
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
animationView = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
animationView.backgroundColor = .green
animationView.center = view.center
self.view.addSubview(animationView)
let label = UILabel(frame: animationView.bounds)
label.text = "I Spin"
animationView.addSubview(label)
button = UIButton(frame: CGRect(x: 20, y: animationView.frame.maxY + 60, width: view.bounds.width - 40, height: 40))
button.setTitle("Animate", for: .normal)
button.setTitleColor(.blue, for: .normal)
button.addTarget(self, action: #selector(ViewController.pressed), for: .touchUpInside)
self.view.addSubview(button)
}
func pressed(){
if let title = button.titleLabel?.text{
let trans = CATransition()
trans.type = "rippleEffect"
trans.duration = 0.6
button.layer.add(trans, forKey: nil)
switch title {
case "Animate":
//perform animation
button.setTitle("Stop And Finish", for: .normal)
rotateAnimationRepeat()
break
default:
//stop and finish
button.setTitle("Animate", for: .normal)
stopAnimationAndFinish()
break
}
}
}
func rotateAnimationRepeat(){
//just to be sure because of how i did the project
animationView.layer.removeAllAnimations()
let rotation : CABasicAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotation.fromValue = 0
rotation.toValue = Double.pi * 2
rotation.duration = 0.5
rotation.repeatCount = Float.greatestFiniteMagnitude
//not doing cumlative
animationView.layer.add(rotation, forKey: "rotationAnimation")
}
func stopAnimationAndFinish(){
if let presentation = animationView.layer.presentation(){
if let currentRotation = presentation.value(forKeyPath: "transform.rotation.z") as? CGFloat{
var duration = 0.5
//smooth out duration for change
duration = Double((CGFloat(Double.pi * 2) - currentRotation))/(Double.pi * 2)
animationView.layer.removeAllAnimations()
let rotation : CABasicAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotation.fromValue = currentRotation
rotation.toValue = Double.pi * 2
rotation.duration = duration * 0.5
animationView.layer.add(rotation, forKey: "rotationAnimation")
}
}
}
}
Result:
2019 typical modern syntax
Setup the arc and the layer like this:
import Foundation
import UIKit
class RoundChaser: UIView {
private let lineThick: CGFloat = 10.0
private let beginFraction: CGFloat = 0.15
// where does the arc drawing begin?
// 0==top, .25==right, .5==bottom, .75==left
private lazy var arcPath: CGPath = {
let b = beginFraction * .pi * 2.0
return UIBezierPath(
arcCenter: bounds.centerOfCGRect(),
radius: bounds.width / 2.0 - lineThick / 2.0,
startAngle: .pi * -0.5 + b,
// recall that .pi * -0.5 is the "top"
endAngle: .pi * 1.5 + b,
clockwise: true
).cgPath
}()
private lazy var arcLayer: CAShapeLayer = {
let l = CAShapeLayer()
l.path = arcPath
l.fillColor = UIColor.clear.cgColor
l.strokeColor = UIColor.purple.cgColor
l.lineWidth = lineThick
l.lineCap = CAShapeLayerLineCap.round
l.strokeStart = 0
l.strokeEnd = 0
// if both are same, it is hidden. initially hidden
layer.addSublayer(l)
return l
}()
then initialization is this easy
open override func layoutSubviews() {
super.layoutSubviews()
arcLayer.frame = bounds
}
finally animation is easy
public func begin() {
CATransaction.begin()
let e : CABasicAnimation = CABasicAnimation(keyPath: "strokeEnd")
e.duration = 2.0
e.fromValue = 0
e.toValue = 1.0
// recall 0 will be our beginFraction, see above
e.repeatCount = .greatestFiniteMagnitude
self.arcLayer.add(e, forKey: nil)
CATransaction.commit()
}
}
Maybe this is not the best solution but it works, as you say you can not modify properties of the CABasicAnimation once is created, also we need to remove the rotation.repeatCount = Float.greatestFiniteMagnitude, if notCAAnimationDelegatemethodanimationDidStop` is never called, with this approach the animation can be stoped without any problems as you need
step 1: first declare a variable flag to mark as you need stop animation in your custom class
var needStop : Bool = false
step 2: add a method to stopAnimation after ends
func stopAnimation()
{
self.needStop = true
}
step 3: add a method to get your custom animation
func getRotate360Animation() ->CAAnimation{
let rotation : CABasicAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotation.fromValue = 0
rotation.toValue = Double.pi * 2
rotation.duration = 1
rotation.isCumulative = true
rotation.isRemovedOnCompletion = false
return rotation
}
step 4: Modify your startRotate360 func to use your getRotate360Animation() method
func startRotate360() {
let rotationAnimation = self.getRotate360Animation()
rotationAnimation.delegate = self
self.layer.add(rotationAnimation, forKey: "rotationAnimation")
}
step 5: Implement CAAnimationDelegate in your class
extension YOURCLASS : CAAnimationDelegate
{
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
if(anim == self.layer?.animation(forKey: "rotationAnimation"))
{
self.layer?.removeAnimation(forKey: "rotationAnimation")
if(!self.needStop){
let animation = self.getRotate360Animation()
animation.delegate = self
self.layer?.add(animation, forKey: "rotationAnimation")
}
}
}
}
This works and was tested
Hope this helps you

Having custom CAShapeLayer animate with the Button

I'm animating my button by changing a constraint of my auto layout and using an UIView animation block to animate it:
UIView.animateWithDuration(0.5, animations: { self.layoutIfNeeded() })
In this animation, only the width of the button is changing and the button itself is animating.
In my button, there's a custom CAShapeLayer. Is it possible to catch the animation of the button and add it to the layer so it animates together with the button?
What I've Tried:
// In my CustomButton class
override func actionForLayer(layer: CALayer, forKey event: String) -> CAAction? {
if event == "bounds" {
if let action = super.actionForLayer(layer, forKey: "bounds") as? CABasicAnimation {
let animation = CABasicAnimation(keyPath: event)
animation.fromValue = border.path
animation.toValue = UIBezierPath(rect: bounds).CGPath
// Copy values from existing action
border.addAnimation(animation, forKey: nil) // border is my CAShapeLayer
}
return super.actionForLayer(layer, forKey: event)
}
// In my CustomButton class
override func layoutSubviews() {
super.layoutSubviews()
border.frame = layer.bounds
let fromValue = border.path
let toValue = UIBezierPath(rect: bounds).CGPath
CATransaction.setDisableActions(true)
border.path = toValue
let animation = CABasicAnimation(keyPath: "path")
animation.fromValue = fromValue
animation.toValue = toValue
animation.duration = 0.5
border.addAnimation(animation, forKey: "animation")
}
Nothing is working, and I've been struggling for days..
CustomButton:
class CustomButton: UIButton {
let border = CAShapeLayer()
init() {
super.init(frame: CGRectZero)
translatesAutoresizingMaskIntoConstraints = false
border.fillColor = UIColor.redColor().CGColor
layer.insertSublayer(border, atIndex: 0)
}
// override func layoutSubviews() {
// super.layoutSubviews()
//
// border.frame = layer.bounds
// border.path = UIBezierPath(rect: bounds).CGPath
// }
override func layoutSublayersOfLayer(layer: CALayer) {
super.layoutSublayersOfLayer(layer)
border.frame = self.bounds
border.path = UIBezierPath(rect: bounds).CGPath
}
}
All you have to do is resize also the sublayers when the backing layer of your view is resized. Because of implicit animation the change should be animated. So all you need to do is basically to set this in you custom view class:
override func layoutSublayersOfLayer(layer: CALayer!) {
super.layoutSublayersOfLayer(layer)
border.frame = self.bounds
}
Updated
I had some time to play with the animation and it seems to work for me now. This is how it looks like:
class TestView: UIView {
let border = CAShapeLayer()
init() {
super.init(frame: CGRectZero)
translatesAutoresizingMaskIntoConstraints = false
border.backgroundColor = UIColor.redColor().CGColor
layer.insertSublayer(border, atIndex: 0)
border.frame = CGRect(x: 0, y:0, width: 60, height: 60)
backgroundColor = UIColor.greenColor()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSublayersOfLayer(layer: CALayer) {
super.layoutSublayersOfLayer(layer)
CATransaction.begin();
CATransaction.setAnimationDuration(10.0);
border.frame.size.width = self.bounds.size.width
CATransaction.commit();
}
}
And I use it like this:
var tview: TestView? = nil
override func viewDidLoad() {
super.viewDidLoad()
tview = TestView();
tview!.frame = CGRect(x: 100, y: 100, width: 60, height: 60)
view.addSubview(tview!)
}
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)
self.tview!.frame.size.width = 200
}
The issue is that the frame property of CALayer is not animable. The docs say:
Note:Note
The frame property is not directly animatable. Instead you should animate the appropriate combination of the bounds, anchorPoint and position properties to achieve the desired result.
If you didn't solve your problem yet or for others that might have it I hope this helps.

Resources