Search code examples
iosswiftxcodeanimationcashapelayer

Circle animation iOS UIKit behaviour with tail not completing fully


So I'm trying to learn how to draw circles in UIKit and I've got them pretty much figured it out but I'm just trying to implement one more thing. In the video below when the tail of the circle reaches the end I would like for the tail to not reach the head fully, meaning I would like the size of the circle to not shrink completely.

I sort of have it in the video below but there is still the snap were the tails goes away and the animation starts again at the head. So I would like the disappearance of the tail to not go away.

Video Demo: https://github.com/DJSimonSays93/CircleAnimation/blob/main/README.md

Here is the code:

class SpinningView: UIView {

let circleLayer = CAShapeLayer()

let rotationAnimation: CAAnimation = {
    let animation = CABasicAnimation(keyPath: "transform.rotation.z")
    animation.fromValue = 0
    animation.toValue = Double.pi * 2
    animation.duration = 3 // increase this duration to slow down the circle animation effect
    animation.repeatCount = MAXFLOAT
    return animation
}()

override func awakeFromNib() {
    super.awakeFromNib()
    setup()
}

func setup() {
    circleLayer.lineWidth = 10.0
    circleLayer.fillColor = nil
    //circleLayer.strokeColor = UIColor(red: 0.8078, green: 0.2549, blue: 0.2392, alpha: 1.0).cgColor
    circleLayer.strokeColor = UIColor.systemBlue.cgColor
    circleLayer.lineCap = .round
    layer.addSublayer(circleLayer)
    updateAnimation()
}

override func layoutSubviews() {
    super.layoutSubviews()
    let center = CGPoint(x: bounds.midX, y: bounds.midY)
    let radius = min(bounds.width, bounds.height) / 2 - circleLayer.lineWidth / 2
    
    let startAngle: CGFloat = -90.0
    let endAngle: CGFloat = startAngle + 360.0
    
    circleLayer.position = center
    circleLayer.path = createCircle(startAngle: startAngle, endAngle: endAngle, radius: radius).cgPath
}

private func updateAnimation() {
    //The strokeStartAnimation beginTime + duration value need to add up to the strokeAnimationGroup.duration value
    let strokeStartAnimation: CABasicAnimation = CABasicAnimation(keyPath: "strokeStart")
    strokeStartAnimation.beginTime = 0.5
    strokeStartAnimation.fromValue = 0
    strokeStartAnimation.toValue = 0.93 //change this to 0.93 for cool effect
    strokeStartAnimation.duration = 3.0
    strokeStartAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
    
    let strokeEndAnimation: CABasicAnimation = CABasicAnimation(keyPath: "strokeEnd")
    strokeEndAnimation.fromValue = 0
    strokeEndAnimation.toValue = 1.0
    strokeEndAnimation.duration = 2.0
    strokeEndAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
    
    let colorAnimation = CABasicAnimation(keyPath: "strokeColor")
    colorAnimation.fromValue = UIColor.systemBlue.cgColor
    colorAnimation.toValue = UIColor.systemRed.cgColor
    
    let strokeAnimationGroup: CAAnimationGroup = CAAnimationGroup()
    strokeAnimationGroup.duration = 3.5
    strokeAnimationGroup.repeatCount = Float.infinity
    strokeAnimationGroup.fillMode = .forwards
    strokeAnimationGroup.isRemovedOnCompletion = false
    strokeAnimationGroup.animations = [strokeStartAnimation, strokeEndAnimation, colorAnimation]
    
    circleLayer.add(strokeAnimationGroup, forKey: nil)
    circleLayer.add(rotationAnimation, forKey: "rotation")
}

private func createCircle(startAngle: CGFloat, endAngle: CGFloat, radius: CGFloat) -> UIBezierPath {
    return UIBezierPath(arcCenter: CGPoint.zero,
                        radius: radius,
                        startAngle: startAngle.toRadians(),
                        endAngle: endAngle.toRadians(),
                        clockwise: true)
}

Solution

  • Something like this?

    There is nothing special here. It is almost exactly the same as your initial code but with a small tweak for the rotation angle.

    Approach

    Your initial animation looks great to start with! Like you said, the "snap" where the animation restarts from 0% of the strokeEnd is what gives it off.

    As @MadProgrammer pointed out, theoretically you can get rid of the "snap" by never starting or ending the stroke at 0%. This ensures there is always some portion of the stroke visible.

    This is a great start, but unfortunately strokeStart and strokeEnd do not allow values outside of the [0.0, 1.0] range. So you can't exactly create an animation (without many keyframes) so that the stroke positions overlap in each animation loop (because you would need to use values out of range to cover the full circle).

    So, what I have done is use the above method anyway and ended up with the animation shown below. The arc length of the stroke at the start and end of the animation are equal - very important.

    Then, using your existing rotation animation I very slightly rotate the entire drawing during the stroke animation so that the start and end arcs seem to land on top of each other. The rotation angle was calculated as follows.

    0.07 was selected by subtracting your initial value for strokeStartAnimation.toValue by 1.0.

    • The scalar length of the arc would then be, 0.07 (S).
    • The radius of the circle would bounds.width / 2 (r).
    • To obtain the arc length (L), we need to multiply scalar length by the Perimeter (P).
    • The relationship between arc length (L) and the rotation angle (theta) is,
    2 * Theta * r = L
    
    But L is also equal to S * P, so some substituting around and we get,
    
    theta = 2S (in Radians)
    

    The Solution

    So, with that out of the way. The solution is the following changes to your code.

    • Define the scalar arc length as a class variable, startOffset.
    • Use startOffset to set the toValue of the strokeStart anim.
    • Use startOffset to set the fromValue of the strokeEnd anim.
    • Set the to value of rotationAnimation to 2 * theta.
    • Match Rotation animation duration with stroke animation duration.

    The final rotation animation looks like this:

    var rotationAnimation: CAAnimation{
        get{
            let radius = Double(bounds.width) / 2.0
            let perimeter = 2 * Double.pi * radius
            let theta = perimeter * startOffset / (2 * radius)
            let animation = CABasicAnimation(keyPath: "transform.rotation.z")
            animation.fromValue = 0
            animation.toValue = theta * 2 + Double.pi * 2
            animation.duration = 3.5
            animation.repeatCount = MAXFLOAT
            return animation
        }
    }
    

    And the strokes:

    let strokeStartAnimation: CABasicAnimation = CABasicAnimation(keyPath: "strokeStart")
    strokeStartAnimation.beginTime = 0.5
    strokeStartAnimation.fromValue = 0
    strokeStartAnimation.toValue = 1.0 - startOffset
    strokeStartAnimation.duration = 3.0
    strokeStartAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
    
    let strokeEndAnimation: CABasicAnimation = CABasicAnimation(keyPath: "strokeEnd")
    strokeEndAnimation.fromValue = startOffset
    strokeEndAnimation.toValue = 1.0
    strokeEndAnimation.duration = 2.0
    strokeEndAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
    

    I made a pull request to your existing code. Try it out and let me know how it goes.