Search code examples
iosswiftcabasicanimation

Custom UIView: animate subLayers with delay


I want to create a custom UIView subclass representing a bunch of stars on a dark-blue sky.

Therefore, I created this view:

import UIKit

class ConstellationView: UIView {

    // MARK: - Properties
    @IBInspectable var numberOfStars: Int = 80
    @IBInspectable var animated: Bool = false

    // Private properties
    private var starsToDraw = [CAShapeLayer]()

    // Layers
    private let starsLayer = CAShapeLayer()



    // MARK: - Drawing
    //    override func drawRect(rect: CGRect) {
    override func layoutSubviews() {

        // Generate stars
        drawStars(rect: self.bounds)

    }



    /// Generate stars
    func drawStars(rect: CGRect) {

        let width = rect.size.width
        let height = rect.size.height
        let screenBounds = UIScreen.main.bounds

        // Create the stars and store them in starsToDraw array
        for _ in 0 ..< numberOfStars {
            let x = randomFloat() * width
            let y = randomFloat() * height
            // Calculate the thinness of the stars as a percentage of the screen resolution
            let thin: CGFloat = max(screenBounds.width, screenBounds.height) * 0.003 * randomFloat()
            let starLayer = CAShapeLayer()
            starLayer.path = UIBezierPath(ovalIn: CGRect(x: x, y: y, width: thin, height: thin)).cgPath
            starLayer.fillColor = UIColor.white.cgColor
            starsToDraw.append(starLayer)
        }


        // Define a fade animation
        let appearAnimation = CABasicAnimation(keyPath: "opacity")
        appearAnimation.fromValue = 0.2
        appearAnimation.toValue = 1
        appearAnimation.duration = 1
        appearAnimation.fillMode = kCAFillModeForwards

        // Add the animation to each star (if animated)
        for (index, star) in starsToDraw.enumerated() {

            if animated {
                // Add 1 s between each animation
                appearAnimation.beginTime = CACurrentMediaTime() + TimeInterval(index)
                star.add(appearAnimation, forKey: nil)
            }

            starsLayer.insertSublayer(star, at: 0)
        }

        // Add the stars layer to the view layer
        layer.insertSublayer(starsLayer, at: 0)
    }


    private func randomFloat() -> CGFloat {
        return CGFloat(arc4random()) / CGFloat(UINT32_MAX)
    }

}

It works quite well, here is the result: enter image description here

However, I'd like to have it animated, that is, each one of the 80 stars should appear one after the other, with a 1 second delay.

I tried to increase the beginTimeof my animation, but it does not seem to do the trick. I checked with drawRect or layoutSubviews, but there is no difference.

Could you help me ?

Thanks

PS: to reproduce my app, just create a new single view app in XCode, create a new file with this code, and set the ViewController's view as a ConstellationView, with a dark background color. Also set the animated property to true, either in Interface Builder, or in the code.

PPS: this is in Swift 3, but I think it's still comprehensible :-)


Solution

  • You're really close, only two things to do!

    First, you need to specify the key when you add the animation to the layer.

    star.add(appearAnimation, forKey: "opacity")
    

    Second, the fill mode for the animation needs to be kCAFillModeBackwards instead of kCAFillModeForwards.

    For a more detailed reference see - https://developer.apple.com/library/content/documentation/Cocoa/Conceptual/CoreAnimation_guide/AdvancedAnimationTricks/AdvancedAnimationTricks.html

    And here's a fun tutorial (for practice with CAAnimations!) - https://www.raywenderlich.com/102590/how-to-create-a-complex-loading-animation-in-swift

    Hope this helps 😀

    Full Code:

    class ConstellationView: UIView {
    
      // MARK: - Properties
      @IBInspectable var numberOfStars: Int = 80
      @IBInspectable var animated: Bool = true
    
      // Private properties
      private var starsToDraw = [CAShapeLayer]()
    
      // Layers
      private let starsLayer = CAShapeLayer()
    
      override func awakeFromNib() {
        super.awakeFromNib()
      }
    
      // MARK: - Drawing
      override func layoutSubviews() {
        // Generate stars
        drawStars(rect: self.bounds)
      }
    
      /// Generate stars
      func drawStars(rect: CGRect) {
        let width = rect.size.width
        let height = rect.size.height
        let screenBounds = UIScreen.main.bounds
    
        // Create the stars and store them in starsToDraw array
        for _ in 0 ..< numberOfStars {
          let x = randomFloat() * width
          let y = randomFloat() * height
          // Calculate the thinness of the stars as a percentage of the screen resolution
          let thin: CGFloat = max(screenBounds.width, screenBounds.height) * 0.003 * randomFloat()
          let starLayer = CAShapeLayer()
          starLayer.path = UIBezierPath(ovalIn: CGRect(x: x, y: y, width: thin, height: thin)).cgPath
          starLayer.fillColor = UIColor.white.cgColor
          starsToDraw.append(starLayer)
        }
    
        // Define a fade animation
        let appearAnimation = CABasicAnimation(keyPath: "opacity")
        appearAnimation.fromValue = 0.2
        appearAnimation.toValue = 1
        appearAnimation.duration = 1
        appearAnimation.fillMode = kCAFillModeBackwards
    
        // Add the animation to each star (if animated)
        for (index, star) in starsToDraw.enumerated() {  
          if animated {
            // Add 1 s between each animation
            appearAnimation.beginTime = CACurrentMediaTime() + TimeInterval(index)
            star.add(appearAnimation, forKey: "opacity")
          }
          starsLayer.insertSublayer(star, above: nil)
        }
    
        // Add the stars layer to the view layer
        layer.insertSublayer(starsLayer, above: nil)
      }
    
      private func randomFloat() -> CGFloat {
        return CGFloat(arc4random()) / CGFloat(UINT32_MAX)
      }
    }