Search code examples
iosswiftcore-animationuibezierpathcaanimation

Animate UIBezierPath arc between 2 angles


I've built a custom circular range picker, similar to the one in iOS's Alarm/Bedtime app. To achieve this, I'm using two UIButton thumbs with a UIPanGestureRecognizer attached and a CAShapeLayer + UIBezierPath indicating the time-range between them. It works fantastic when I'm using touch input for both movement along the circle and resizing the arc itself.

Pardon my crude depiction of circles, but here's the gist of how the picker works:

Circles

The problem is that I need to switch between certain time-range presets using a UISegmentedControl and I'd really wish to animate this change. I've managed to beautifully animate both thumbs (green) along the circle in all 360 degrees using CAKeyframeAnimation(keyPath: "position"), but I haven't been able to do this with my time-range (red) CAShapeLayer. The best I managed was animating the range layer using CABasicAnimation(keyPath: "path"), but it got distorted during the animation and was not following the arc, but instead went across the circle in a straight vector.

For the sake of brevity, I'll avoid posting the contents of getStuff() functions as they're unrelated to the problem.

// This works
let startAngle: CGFloat = getAngle(for: start)
let startPath: UIBezierPath = getThumbTravelPath(fromAngle: startThumbAngle, toAngle: startAngle)
let startAnimation = CAKeyframeAnimation(keyPath: "position")
startAnimation.path = startPath.cgPath
startAnimation.isRemovedOnCompletion = false
startAnimation.calculationMode = .cubicPaced
startAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
startAnimation.duration = 0.2
startThumbView.layer.add(startAnimation, forKey: nil)
    
let endAngle: CGFloat = getAngle(for: end)
let endPath: UIBezierPath = getThumbTravelPath(fromAngle: endThumbAngle, toAngle: endAngle)
let endAnimation = CAKeyframeAnimation(keyPath: "position")
endAnimation.path = endPath.cgPath
endAnimation.isRemovedOnCompletion = false
endAnimation.calculationMode = .cubicPaced
endAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
endAnimation.duration = 0.2
endThumbView.layer.add(endAnimation, forKey: nil)

// This doesn't work
let path: UIBezierPath = getRangePathFor(startAngle: startAngle, endAngle: endAngle)
let pathAnimation = CABasicAnimation(keyPath: "path")
pathAnimation.fromValue = rangePath.cgPath 
pathAnimation.toValue = path.cgPath
pathAnimation.duration = 0.2
pathAnimation.isRemovedOnCompletion = false
pathAnimation.isAdditive = true
rangeLayer.path = path.cgPath
rangeLayer.add(pathAnimation, forKey: nil)

I've seen some people suggesting using the CABasicAnimation(keyPath: "startStroke") + CABasicAnimation(keyPath: "endStroke") approach, but as far as I can tell, this won't work because I can't have a negative value for startStroke, thus I can't have an arc stretching from say 270° to 45°.


Solution

  • Looks like strokeStart & strokeEnd was the answer after all.


    Solution

    I managed to solve this by rotating the rangeLayer itself in a way that always corresponds the angle of the start thumb, so my strokeStart is always zero. As the next step, I'm simply subtracting startAngle from endAngle (in degrees, not radians) and using it to calculate the value of endStroke.

        private func setRangeLayer(startRadian: CGFloat, endRadian: CGFloat, animated: Bool, duration: CFTimeInterval? = nil) {
        let endAngle = endRadian / .pi * 180
        let endStroke = endAngle / 360
        let rotation = CGAffineTransform(rotationAngle: startRadian + .pi / 2)
        
        CATransaction.begin()
        if !animated {
            CATransaction.disableActions()
            CATransaction.setAnimationDuration(0)
        } else {
            CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeInEaseOut))
            if let duration = duration {
                CATransaction.setAnimationDuration(duration)
            }
         }
        rangeLayer.strokeStart = 0
        rangeLayer.strokeEnd = endStroke
        rangeLayer.setAffineTransform(rotation)
        CATransaction.commit()
        }
    

    Example

    The reason I'm converting from degrees to radians and then back to degrees is to standardise the function for different use cases throughout the class. However, you can just as easily use radians for rotation and degrees for endStroke.

        let startAngle: CGFloat = getAngle(for: startTimestamp)
        let endAngle: CGFloat = getAngle(for: endTimestamp)
        let startRadian: CGFloat = (startAngle * .pi / 180) - .pi / 2
        let endRadian: CGFloat = (endAngle - startAngle) * .pi / 180
        setRangeLayer(startRadian: startRadian, endRadian: endRadian, animated: true, duration: animationDuration)
    

    Important note

    In order to properly rotate CAShapeLayer around its center, you must set its frame / bounds beforehand or it will spin along its origin like an airplane rotor instead.


    Hope this will help someone in a similar situation.