2

I have a scaling/pulsing animation that is working on cgPaths in a for loop as below. This code works but only when you append the animatedLayers to the circleLayer path when the circleLayer has already been added to the subLayer and this creates a static circle (glitch-like) before the animation (DispatchQueue) starts...

...
self.layer.addSublayer(animatedLayer)
animatedLayers.append(animatedLayer)
...

enter image description here

Is it possible to add a CAShapeLayer with arguments to a subLayer? If not, any recommended alternative?

import UIKit
import Foundation

@IBDesignable
class AnimatedCircleView: UIView {

    // MARK: - Initializers

    var animatedLayers = [CAShapeLayer]()

    // MARK: - Methods

    override func draw(_ rect: CGRect) {

        // Animated circle
        for _ in 0...3 {
            let animatedPath = UIBezierPath(arcCenter: .zero, radius: self.layer.bounds.size.width / 2.3,
            startAngle: 0, endAngle: 2 * CGFloat.pi, clockwise: true)
            let animatedLayer = CAShapeLayer()

            animatedLayer.path = animatedPath.cgPath
            animatedLayer.strokeColor = UIColor.black.cgColor
            animatedLayer.lineWidth = 0
            animatedLayer.fillColor = UIColor.gray.cgColor
            animatedLayer.lineCap = CAShapeLayerLineCap.round
            animatedLayer.position = CGPoint(x: self.layer.bounds.size.width / 2, y: self.layer.bounds.size.width / 2)
            self.layer.addSublayer(animatedLayer)
            animatedLayers.append(animatedLayer)
        }

        // Dispatch animation for circle _ 0...3
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            self.animateCircle(index: 0)
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
                self.animateCircle(index: 1)
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                    self.animateCircle(index: 2)
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
                        self.animateCircle(index: 3)
                    }
                }
            }
        }
    }


    func animateCircle(index: Int) {

        let scaleAnimation = CABasicAnimation(keyPath: "transform.scale")
        scaleAnimation.duration = 1.8
        scaleAnimation.fromValue = 0
        scaleAnimation.toValue = 1
        scaleAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)
        scaleAnimation.repeatCount = Float.infinity
        animatedLayers[index].add(scaleAnimation, forKey: "scale")

        let opacityAnimation = CABasicAnimation(keyPath: #keyPath(CALayer.opacity))
        opacityAnimation.duration = 1.8
        opacityAnimation.fromValue = 0.7
        opacityAnimation.toValue = 0
        opacityAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)
        opacityAnimation.repeatCount = Float.infinity
        animatedLayers[index].add(opacityAnimation, forKey: "opacity")
    }
}
Rob
  • 415,655
  • 72
  • 787
  • 1,044
axelmukwena
  • 779
  • 7
  • 24

2 Answers2

1

The key issue is that your animations start, with staggered delays, between in 0.1 and 1.0 seconds. Until that last animation starts, that layer is just sitting there, full sized and at 100% opacity.

Since you are animating the transform scale from 0 to 1, I’d suggest setting the starting transform to 0 (or changing the opacity to 0). Then you won’t see them sitting there until their respective animations start.

A few other observations:

  • The draw(_:) is not the right place to add layers, start animations, etc. This method may be called multiple times and should represent the view at a given point in time. I’d retire draw(_:) is it’s not the right place to start this and you don’t need this method at all.

  • You start your first animation after 0.1 seconds. Why not start it immediately?

  • You should handle frame adjustments by deferring the setting of the path and position properties until layoutSubviews.

Thus:

@IBDesignable
class AnimatedCircleView: UIView {
    private var animatedLayers = [CAShapeLayer]()

    override init(frame: CGRect = .zero) {
        super.init(frame: frame)
        configure()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        configure()
    }

    override func layoutSubviews() {
        super.layoutSubviews()

        let path = UIBezierPath(arcCenter: .zero, radius: bounds.width / 2.3, startAngle: 0, endAngle: 2 * .pi, clockwise: true)

        for animatedLayer in animatedLayers {
            animatedLayer.path = path.cgPath
            animatedLayer.position = CGPoint(x: bounds.midX, y: bounds.midY)
        }
    }
}

// MARK: - Methods

private extension AnimatedCircleView {
    func configure() {
        for _ in 0...3 {
            let animatedLayer = CAShapeLayer()
            animatedLayer.strokeColor = UIColor.black.cgColor
            animatedLayer.lineWidth = 0
            animatedLayer.fillColor = UIColor.gray.cgColor
            animatedLayer.lineCap = .round
            animatedLayer.transform = CATransform3DMakeScale(0, 0, 1)
            layer.addSublayer(animatedLayer)
            animatedLayers.append(animatedLayer)
        }

        self.animateCircle(index: 0)
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            self.animateCircle(index: 1)
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
                self.animateCircle(index: 2)
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                    self.animateCircle(index: 3)
                }
            }
        }
    }

    func animateCircle(index: Int) {
        let scaleAnimation = CABasicAnimation(keyPath: "transform.scale")
        scaleAnimation.duration = 1.8
        scaleAnimation.fromValue = 0
        scaleAnimation.toValue = 1
        scaleAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)
        scaleAnimation.repeatCount = .greatestFiniteMagnitude
        animatedLayers[index].add(scaleAnimation, forKey: "scale")

        let opacityAnimation = CABasicAnimation(keyPath: #keyPath(CALayer.opacity))
        opacityAnimation.duration = 1.8
        opacityAnimation.fromValue = 0.7
        opacityAnimation.toValue = 0
        opacityAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)
        opacityAnimation.repeatCount = .greatestFiniteMagnitude
        animatedLayers[index].add(opacityAnimation, forKey: "opacity")
    }
}
Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • Thank you for the solution, setting the dispatch delay was the issue. And thanks for the setup and structural guidelines too. – axelmukwena Mar 30 '20 at 19:45
  • Ow yeah, I usually just vote up, and so I thought the system automatically marks a specific answer correct based on the number of votes it gained. Thanks for that. – axelmukwena Mar 30 '20 at 20:03
0

Have you tried starting with the fillcolor as clear then turning it to grey at the start of the animation?

    // Animated circle
    for _ in 0...3 {
        let animatedPath = UIBezierPath(arcCenter: .zero, radius: self.layer.bounds.size.width / 2.3,
        startAngle: 0, endAngle: 2 * CGFloat.pi, clockwise: true)
        let animatedLayer = CAShapeLayer()

        animatedLayer.path = animatedPath.cgPath
        animatedLayer.strokeColor = UIColor.black.cgColor
        animatedLayer.lineWidth = 0
        animatedLayer.fillColor = UIColor.clear.cgColor
        animatedLayer.lineCap = CAShapeLayerLineCap.round
        animatedLayer.position = CGPoint(x: self.layer.bounds.size.width / 2, y: self.layer.bounds.size.width / 2)
        self.layer.addSublayer(animatedLayer)
        animatedLayers.append(animatedLayer)
    }

    // Dispatch animation for circle _ 0...3
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {

        self.animateCircle(index: 0)
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
            self.animateCircle(index: 1)
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                self.animateCircle(index: 2)
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
                    self.animateCircle(index: 3)
                }
            }
        }
    }
}
HalR
  • 11,411
  • 5
  • 48
  • 80
  • I'm getting the error `Use of unresolved identifier 'animatedLayer'`. Is that because the statement is out of the for loop? – axelmukwena Mar 30 '20 at 17:17
  • Yeah, that was way off by me. You should leave that line off completely. Your animation appears to be setting the color for the circle anyway. If its not setting the color as part of the animation-- if its only playing with translucencies/alpha, set the color for the circle in the animation. – HalR Mar 30 '20 at 18:33