I'm trying to recreate the Activity ring that Apple uses in their activity apps. Here is an image for those unaware.
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.
- The first 75% of the ring
- The last 25% of the ring
- 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
- If the ring is filled at 75% or less, it takes 2.25 seconds to fill with a timing function of ease out
- 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.
- 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")
}