Search code examples
swiftanimationcore-animationcabasicanimation

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 of button


Solution

  • 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
    }