Swift- How to find width/height of a Presentation Layer that is being scaled and rotated? - swift

So, I have a CALayer that is being rotated and scaled using CABasicAnimation. Since the layer is being rotated, frame returns the frame containing the highest to lowest x and y values(so this will be larger than the actual presented layer). And, while bounds would work if scaling does not occur, the bounds of the layer do not. Here is my code for my custom UIView subclass:
func rotateCircleView(duration: CFTimeInterval = 1.0) {
let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation")
rotateAnimation.fromValue = 0.0
rotateAnimation.toValue = CGFloat(M_PI * 2.0)
rotateAnimation.duration = duration
rotateAnimation.repeatCount = Float.infinity
self.layer.addAnimation(rotateAnimation, forKey: rotateKey)
}
func shrinkCircleView(duration: CFTimeInterval = 1.0, to: Double) {
let from = self.getCurrentAnimationScale()
let shrinkAnimation = CABasicAnimation(keyPath: "transform.scale")
shrinkAnimation.additive = true
shrinkAnimation.fromValue = from - to
shrinkAnimation.toValue = 0
shrinkAnimation.duration = duration
shrinkAnimation.removedOnCompletion = true
let scaleTransform = CATransform3DMakeScale(CGFloat(to), CGFloat(to), 1.0)
layer.transform = scaleTransform
layer.addAnimation(shrinkAnimation, forKey: "shrinkAnimation")
}
func getCurrentAnimationScale() -> Double{
if let temp = self.layer.presentationLayer(){
// return scale of presentationLayer
} else{
// return scale of self.layer
}
}

Related

CABasicAnimation to emulate a 'pulse' effect animation on a non-circle shape

I am using CBasicAnimation to create a pulsating effect on a button.
The effect pulses out the shape of a UIView, with border only.
While the animation works properly, I am not getting the desired effect using CABasicAnimation(keyPath: "transform.scale").
I am using an animation group with 3 animations: borderWidth, transform.scale and opacity.
class Pulsing: CALayer {
var animationGroup = CAAnimationGroup()
var initialPulseScale:Float = 1
var nextPulseAfter:TimeInterval = 0
var animationDuration:TimeInterval = 1.5
var numberOfPulses:Float = Float.infinity
override init(layer: Any) {
super.init(layer: layer)
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
init (numberOfPulses:Float = Float.infinity, position:CGPoint, pulseFromView:UIView, rounded: CGFloat) {
super.init()
self.borderColor = UIColor.black.cgColor
self.contentsScale = UIScreen.main.scale
self.opacity = 1
self.numberOfPulses = numberOfPulses
self.position = position
self.bounds = CGRect(x: 0, y: 0, width: pulseFromView.frame.width, height: pulseFromView.frame.height)
self.cornerRadius = rounded
DispatchQueue.global(qos: DispatchQoS.QoSClass.default).async {
self.setupAnimationGroup(view: pulseFromView)
DispatchQueue.main.async {
self.add(self.animationGroup, forKey: "pulse")
}
}
}
func borderWidthAnimation() -> CABasicAnimation {
let widthAnimation = CABasicAnimation(keyPath: "borderWidth")
widthAnimation.fromValue = 2
widthAnimation.toValue = 0.5
widthAnimation.duration = animationDuration
return widthAnimation
}
func createScaleAnimation (view:UIView) -> CABasicAnimation {
let scale = CABasicAnimation(keyPath: "transform.scale")
DispatchQueue.main.async {
scale.fromValue = view.layer.value(forKeyPath: "transform.scale")
}
scale.toValue = NSNumber(value: 1.1)
scale.duration = 1.0
scale.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
return scale
}
func createOpacityAnimation() -> CABasicAnimation {
let opacityAnimation = CABasicAnimation(keyPath: "opacity")
opacityAnimation.duration = animationDuration
opacityAnimation.fromValue = 1
opacityAnimation.toValue = 0
opacityAnimation.fillMode = .removed
return opacityAnimation
}
func setupAnimationGroup(view:UIView) {
self.animationGroup = CAAnimationGroup()
self.animationGroup.duration = animationDuration + nextPulseAfter
self.animationGroup.repeatCount = numberOfPulses
self.animationGroup.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.default)
self.animationGroup.animations = [createScaleAnimation(view: view), borderWidthAnimation(), createOpacityAnimation()]
}
}
class ViewController: UIViewController {
#IBOutlet weak var pulsingView: UIView!
let roundd:CGFloat = 20
override func viewDidLoad() {
super.viewDidLoad()
pulsingView.layer.cornerRadius = roundd
let pulse = Pulsing(
numberOfPulses: .greatestFiniteMagnitude,
position: CGPoint(x: pulsingView.frame.width/2,
y: pulsingView.frame.height/2)
, pulseFromView: pulsingView, rounded: roundd)
pulse.zPosition = -10
self.pulsingView.layer.insertSublayer(pulse, at: 0)
}
}
My problem is transform.scale is maintaining the aspect ratio of the UIView it's pulsating from during the animation.
How can I make the pulse grow so there's uniform spacing on both the height and the width? See screenshot.
Scaling the width and height by the same factor is going to result in unequal spacing around the edges. You need to increase the layer's width and height by the same value. This is an addition operation, not multiplication. Now, for this pulsating effect you need to animate the layer's bounds.
If you want the spacing between the edges to be dynamic, then pick a scale factor and apply it to a single dimension. Whether you choose the width or the the height doesn't matter so long as it's only applied to one. Let's say you choose the width to grow by a factor of 1.1. Compute your target width, then compute the delta.
let scaleFactor: CGFloat = 1.1
let targetWidth = view.bounds.size.width * scaleFactor
let delta = targetWidth - view.bounds.size.width
Once you have your delta, apply it to the layer's bounds in the x and the y dimension. Take advantage of the insetBy(dx:) method to compute the resulting rectangle.
let targetBounds = self.bounds.insetBy(dx: -delta / 2, dy: -delta / 2)
For clarity's sake, I've renamed your createScaleAnimation(view:) method to createExpansionAnimation(view:). Tying it all together we have:
func createExpansionAnimation(view: UIView) -> CABasicAnimation {
let anim = CABasicAnimation(keyPath: "bounds")
DispatchQueue.main.async {
let scaleFactor: CGFloat = 1.1
let targetWidth = view.bounds.size.width * scaleFactor
let delta = targetWidth - view.bounds.size.width
let targetBounds = self.bounds.insetBy(dx: -delta / 2, dy: -delta / 2)
anim.duration = 1.0
anim.fromValue = NSValue(cgRect: self.bounds)
anim.toValue = NSValue(cgRect: targetBounds)
}
return anim
}

Animation stops when UIView reappears on scrolling UITableView

I have an spinning UIView to show progress in each cell of a UITableView and I am using this function to animate UIViews:
func rotate360Degrees(duration: CFTimeInterval = 1.0) {
let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation")
rotateAnimation.fromValue = 0.0
rotateAnimation.toValue = CGFloat.pi * 2
rotateAnimation.duration = duration
rotateAnimation.repeatCount = Float.infinity
self.layer.add(rotateAnimation, forKey: nil)
}
It works fine when the cells appears first but when you scroll the UITableView and the cells disappear and after that they shows up by scrolling again, their animations are stopped. I tried calling the method for them again after reappearing but it didn't work. what is wrong with my code?
UITableViewCell objects are reusable and you need to restore the animation in prepareForReuse: or tableView(_:willDisplay:forRowAt:) method.
func getRotate360DegreesAnimation(duration: CFTimeInterval = 1.0) -> CABasicAnimation {
let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation")
rotateAnimation.fromValue = 0.0
rotateAnimation.toValue = .pi * 2
rotateAnimation.duration = duration
rotateAnimation.repeatCount = .infinity
return rotateAnimation
}
func restoreAnimation() {
let animation = getRotate360DegreesAnimation()
layer.removeAllAnimations()
layer.add(animation, forKey: nil)
}

Circle scale animation works a bit weird

I am animating my GMSMarker so that it pulse once in couple seconds.
func addWave()
{
// circleView.layer.cornerRadius = size / 2
//Scale
let scaleAnimation = CABasicAnimation(keyPath: "transform.scale")
scaleAnimation.fromValue = 1
scaleAnimation.toValue = zoom
//Opacity
let alphaAnimation = CABasicAnimation(keyPath: "opacity")
alphaAnimation.toValue = 0.0
//Corner radius
// let cornerRadiusAnimation = CABasicAnimation(keyPath: "cornerRadius")
// cornerRadiusAnimation.fromValue = size / 2
// cornerRadiusAnimation.toValue = (size * zoom)/2
//Animation Group
let animations: [CAAnimation] = [scaleAnimation, alphaAnimation]
let animationGroup = CAAnimationGroup()
animationGroup.duration = duration
animationGroup.animations = animations
animationGroup.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
animationGroup.repeatCount = 1
animationGroup.fillMode = kCAFillModeForwards
animationGroup.isRemovedOnCompletion = false
circleView.layer.add(animationGroup, forKey: "group")
}
The result looks like this:
And if I uncomment Corner radius section it looks like this:
So I need an advice.
Based on my observations, I think it must be a issue of wrong path of your CAShapeLayer hence the masking of the circle.
I just wrote a radar animation, I hope it might help you.
let scaleAnimation = CABasicAnimation(keyPath: "transform.scale")
scaleAnimation.fromValue = 0
scaleAnimation.toValue = 1
let alphaAnimation = CABasicAnimation(keyPath: "opacity")
alphaAnimation.fromValue = 1
alphaAnimation.toValue = 0
let animations = CAAnimationGroup()
animations.duration = 0.8
animations.repeatCount = Float.infinity
animations.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
animations.animations = [scaleAnimation, alphaAnimation]
circleView.layer.add(animations, forKey: "animations")
What I did was, I used two CAShapeLayer (one being the orange marker and other is the rader layer at the back of marker). The animations were applied on the radar layer.
radarLayer = CAShapeLayer()
radarLayer.frame = CGRect(x: 0, y: 0, width: self.frame.size.width, height: self.frame.size.height);
radarLayer.path = UIBezierPath(rect: radarLayer.frame).cgPath
radarLayer.fillColor = UIColor.orange.cgColor
radarLayer.position = CGPoint(x: self.frame.size.width/2, y: self.frame.size.height/2)
radarLayer.cornerRadius = radarLayer.frame.size.width/2
radarLayer.masksToBounds = true
radarLayer.opacity = 0
self.layer.addSublayer(radarLayer)
circleLayer = CAShapeLayer()
circleLayer.frame = CGRect(x: 0, y: 0, width: 16, height: 16);
circleLayer.path = UIBezierPath(rect: circleLayer.frame).cgPath
circleLayer.fillColor = UIColor.orange.cgColor
circleLayer.position = CGPoint(x: self.frame.size.width/2, y: self.frame.size.height/2)
circleLayer.cornerRadius = circleLayer.frame.size.width/2
circleLayer.masksToBounds = true
self.layer.addSublayer(circleLayer)
PS. I'm new to Swift, so enlighten me if I'm wrong somewhere :)
Example : http://i.imgur.com/v2jFWgw.jpg
The following in an excerpt from Google documentation on Markers:
The view behaves as if clipsToBounds is set to YES, regardless of its actual value. You can apply transforms that work outside the bounds, but the object you draw must be within the bounds of the object. All transforms/shifts are monitored and applied. In short: subviews must be contained within the view.
The consequence of that, at least for my case, was exactly the behaviour mentioned in the question: a squared circle animation. The image I wanted to add inside the marker, at the end of the animation, was bigger than the container, so once added to the marker, it was clipped to the bounds of it.
The following is what I did:
public var pulseImageView: UIImageView = {
let imageView = UIImageView(frame: CGRect(x: 0, y: 0, width: 300, height: 300))
imageView.image = PaletteElements.pulseLocation.value
imageView.contentMode = .center
let pulseAnimation = CABasicAnimation(keyPath: "transform.scale.xy")
pulseAnimation.repeatCount = Float.infinity
pulseAnimation.fromValue = 0
pulseAnimation.toValue = 2.0
pulseAnimation.isRemovedOnCompletion = false
let fadeOutAnimation = CABasicAnimation(keyPath: "opacity")
fadeOutAnimation.duration = 2.5
fadeOutAnimation.fromValue = 1.0
fadeOutAnimation.toValue = 0
fadeOutAnimation.repeatCount = Float.infinity
let animationGroup = CAAnimationGroup()
animationGroup.duration = 2.5
animationGroup.animations = [pulseAnimation, fadeOutAnimation]
animationGroup.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)
animationGroup.repeatCount = .greatestFiniteMagnitude
animationGroup.fillMode = CAMediaTimingFillMode.forwards
animationGroup.isRemovedOnCompletion = false
imageView.layer.add(animationGroup, forKey: "pulse")
return imageView
}()
I defined a var inside my helper class so I can retrieve the image whenever I need it. My original image, the pulse image, was 116x116, so I created the imageView as 300x300 with a contentMode = .center, so the small image was in the center and not stretched. I chose 300x300 because my animation scaled up the pulse image until a 2x its initial value (116x2), so I made room for the entire animation to be performed.
Finally I added it to as a Marker to the map:
let pulseMarker = GMSMarker(position: userLocation.coordinate)
pulseMarker.iconView = pulseImageView
pulseMarker.groundAnchor = CGPoint(x: 0.5, y: 0.5)
pulseMarker.map = googleMapView
Hope it can help.

CAShapelayer Duplicates

I'm trying to add a CAShapelayer once every 20ms to given x and y coordinates. I would like the shape to fade away over a second (like a tracer). The function I have created works, the shape is created in the correct location and fades away. But I am getting extra shapes left behind cluttering up the screen.
func shadowBall (x: CGFloat, y: CGFloat){
let xpos : CGFloat = ((self.frame.width/2) + x)
let ypos : CGFloat = ((self.frame.height/2) + y)
let shadowBall = CAShapeLayer()
let shadowBalllRadius :CGFloat = 4
let shadowBallPath : UIBezierPath = UIBezierPath(ovalInRect: CGRect(x: xpos, y: ypos, width: CGFloat(shadowBalllRadius*2), height: CGFloat(shadowBalllRadius*2)))
shadowBall.path = shadowBallPath.CGPath
shadowBall.fillColor = UIColor.clearColor().CGColor
shadowBall.strokeColor = UIColor.whiteColor().CGColor
shadowBall.lineWidth = 0.5
let animation: CABasicAnimation = CABasicAnimation(keyPath: "strokeColor")
animation.fromValue = UIColor.whiteColor().CGColor
animation.toValue = UIColor.clearColor().CGColor
animation.duration = 1.0;
animation.repeatCount = 0;
animation.removedOnCompletion = true
animation.additive = false
self.layer.addSublayer(shadowBall)
shadowBall.addAnimation(animation, forKey: "strokeColor")
}
The problem is that when the animation finishes, it restores the strokeColor to the original color. You should really set the strokeColor of the original shape layer to be clearColor(), that way, when you finish animating from whiteColor() to clearColor(), it will remain at clearColor().
You can also set the layer's fillMode to kCAFillModeForwards and set removedOnCompletion to false and that will have the layer preserve its "end of animation" state. But I personally would just set the strokeColor as outlined above, as using removedOnCompletion of true interferes with animationDidStop (see below).
Also, I might suggest that you also remove the layer once it's done with the animation so it doesn't continue to consume memory although it's no longer visible.
func shadowBall (x: CGFloat, y: CGFloat) {
let xpos: CGFloat = ((self.frame.width/2) + x)
let ypos: CGFloat = ((self.frame.height/2) + y)
let shadowBall = CAShapeLayer()
let shadowBalllRadius: CGFloat = 4
let shadowBallPath = UIBezierPath(ovalInRect: CGRect(x: xpos, y: ypos, width: CGFloat(shadowBalllRadius*2), height: CGFloat(shadowBalllRadius*2)))
shadowBall.path = shadowBallPath.CGPath
shadowBall.fillColor = UIColor.clearColor().CGColor
shadowBall.strokeColor = UIColor.clearColor().CGColor
shadowBall.lineWidth = 0.1
let animation = CABasicAnimation(keyPath: "strokeColor")
animation.fromValue = UIColor.whiteColor().CGColor
animation.toValue = UIColor.clearColor().CGColor
animation.duration = 1.0
animation.repeatCount = 0
animation.removedOnCompletion = true
animation.additive = false
animation.delegate = self
animation.setValue(shadowBall, forKey: "animationLayer")
self.layer.addSublayer(shadowBall)
shadowBall.addAnimation(animation, forKey: "strokeColor")
}
override func animationDidStop(anim: CAAnimation, finished flag: Bool) {
if let layer = anim.valueForKey("animationLayer") as? CALayer {
layer.removeFromSuperlayer()
}
}
See How to remove a CALayer-object from animationDidStop?

CABasicAnimation reverse(backwards)

I'm a bit struggling with this simple line animation. I figured out how to pause it, but what I need is to be able to reverse animation back to starting point from the moment I call function resetAnimation().
let pathAnimation = CABasicAnimation(keyPath: "strokeEnd")
let pathLayer = CAShapeLayer()
func lineAnimation() {
let path = UIBezierPath()
let screenWidth = self.view.bounds.width
let screenHeight = self.view.bounds.height
path.moveToPoint(CGPointMake(screenWidth, screenHeight / 2))
path.addLineToPoint(CGPointMake(screenWidth - screenWidth, screenHeight / 2))
self.pathLayer.frame = self.view.bounds
self.pathLayer.path = path.CGPath
self.pathLayer.strokeColor = UIColor.whiteColor().CGColor
self.pathLayer.fillColor = nil
self.pathLayer.lineWidth = 3.0
self.pathLayer.lineCap = kCALineCapRound
self.pathLayer.speed = 1
self.view.layer.addSublayer(pathLayer)
self.pathAnimation.duration = 5.0
self.pathAnimation.fromValue = 0.0
self.pathAnimation.toValue = 1.0
pathLayer.addAnimation(pathAnimation, forKey: "animate")
}
func pauseAnimation() {
let pausedTime = pathLayer.convertTime(CACurrentMediaTime(), fromLayer: nil)
pathLayer.speed = 0
pathLayer.timeOffset = pausedTime
}
func resetAnimation() {
}
You just need to create a new animation and remove the old one. Your starting point for the new animation will be the current value of that property in your presentation layer. I'd also recommend setting the frame of your shape layer to the bounds of the actual bezier shape instead of the entire view - its a good habit to be in when you start moving things around and scaling/rotating/etc. Otherwise you're gonna be faced with a bunch of funky conversions or anchor point changes.
Here's what I'd do:
let pathLayer = CAShapeLayer()
// first, separate your drawing code from your animation code.
// this way you can call animations without instantiating new objects
func drawLine() {
let path = UIBezierPath()
// draw your path with no position translation.. move the layer
path.moveToPoint(CGPointMake(view.bounds.width, 0))
path.addLineToPoint(CGPointMake(0, 0))
pathLayer.frame = path.bounds
// this line sets the position of the layer appropriately
pathLayer.position = view.bounds.width - pathLayer.bounds.width / 2
pathLayer.path = path.CGPath
pathLayer.strokeColor = UIColor.whiteColor().CGColor
pathLayer.fillColor = nil
pathLayer.lineWidth = 3.0
pathLayer.lineCap = kCALineCapRound
view.layer.addSublayer(pathLayer)
}
func lineAnimation() {
let pathAnimation = CABasicAnimation(keyPath: "strokeEnd")
pathAnimation.duration = 5.0
pathAnimation.fromValue = 0.0
pathAnimation.toValue = 1.0
pathLayer.addAnimation(pathAnimation, forKey: "strokeEnd")
}
func reverseAnimation() {
let revAnimation = CABasicAnimation(keyPath: "strokeEnd")
revAnimation.duration = 5.0
// every Core Animation has a 'presentation layer' that contains the animated changes
revAnimation.fromValue = pathLayer.presentationLayer()?.strokeEnd
revAnimation.toValue = 0.0
pathLayer.removeAllAnimations()
pathLayer.addAnimation(revAnimation, forKey: "strokeEnd")
}
Keep in mind you'll also want to set your properties so you retain the end values of the animation.
You could also use the autoreverses property of CAMediaTiming protocol, which is complied by CABasicAnimation