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:
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°.
Looks like strokeStart
& strokeEnd
was the answer after all.
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()
}
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)
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.