0

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.

  1. The first 75% of the ring
  2. The last 25% of the ring
  3. 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

  1. If the ring is filled at 75% or less, it takes 2.25 seconds to fill with a timing function of ease out
  2. 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.
  3. 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")
    }
Kai
  • 1
  • Did you try to use CAAnimationGroup? – Eugene Dudnyk Sep 12 '20 at 17:32
  • My understanding is that CAAnimationGroup is for multiple animations on a single layer whereas in this example I have three different layers – Kai Sep 12 '20 at 17:45
  • Not really... See my answer – Eugene Dudnyk Sep 13 '20 at 14:21
  • CAAnimationGroup can animate multiple layers. See https://stackoverflow.com/a/63710424/341994 – matt Sep 13 '20 at 14:38
  • Apologies for the delayed response guys, I tried the linked solution this but unfortunately it isn't what I was after. Synchronously wasn't the problem but rather sequential animations that overall can take a set time like 3 seconds and have a timingfunction applied to them. After looking around, I don't entirely think it's possible with my approach. Thank you for the suggestions! – Kai Sep 23 '20 at 20:27

1 Answers1

0

This solution demonstrates how to animate several layers synchronously with their dedicated CABasicAnimation by using CAAnimationGroup.

  1. Create a container CALayer subclass for layers that should be animated synchronously. Let's call it TestLayer
  2. Define the property for every layer you need to animate.
  3. Override needsDisplay(forKey:) in the TestLayer to receive the display call when animation modifies the particular layer via keyPath.
  4. 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()
}
Eugene Dudnyk
  • 5,553
  • 1
  • 23
  • 48
  • Apologies for the delayed response, I tried this but unfortunately this isn't what I was after. Synchronously wasn't the problem but rather sequential animations that overall can take a set time like 3 seconds and have a timingfunction applied to them. After looking around, I don't entirely think it's possible with my approach. Thank you for the suggestion however! – Kai Sep 23 '20 at 20:26
  • What you are trying to achieve is possible with my proposal - you just need to set different begin time to the `CABasicAnimation` that has to start later, and set the timing curve to the `CAAnimationGroup`. – Eugene Dudnyk Sep 23 '20 at 20:28