4

enter image description here

I have a custom UIView where I add a CAShapeLayer as a direct sublayer of the view's layer:

private let arcShapeLayer = CAShapeLayer()

The CAShapeLayer has the following properties (the border is to visualize more easily the animation) declared in the awakeFromNib:

arcShapeLayer.lineWidth = 2.0
arcShapeLayer.strokeColor = UIColor.black.cgColor
arcShapeLayer.fillColor = UIColor.lightGray.cgColor
arcShapeLayer.masksToBounds = false
layer.masksToBounds = false
layer.addSublayer(arcShapeLayer)

I declare the frame of the arcShapeLayer in the layoutSubviews:

arcShapeLayer.frame = layer.bounds

Then I have the following function where I pass a certain percentage (from the ViewController) in order to achieve the arc effect after some dragging. Simply put I add a quadCurve at the bottom of the layer:

func configureArcPath(curvePercentage: CGFloat) -> CGPath {

    let path = UIBezierPath()
    path.move(to: CGPoint.zero)
    path.addLine(to:
        CGPoint(
            x: arcShapeLayer.bounds.minX,
            y: arcShapeLayer.bounds.maxY
        )
    )

    path.addQuadCurve(
        to:
        CGPoint(
            x: arcShapeLayer.bounds.maxX,
            y: arcShapeLayer.bounds.maxY
        ),
        controlPoint:
        CGPoint(
            x: arcShapeLayer.bounds.midX,
            y: arcShapeLayer.bounds.maxY + arcShapeLayer.bounds.maxY * (curvePercentage * 0.4)
        )
    )

    path.addLine(to:
        CGPoint(
            x: arcShapeLayer.bounds.maxX,
            y: arcShapeLayer.bounds.minY
        )
    )
    path.close()

    return path.cgPath

}

Then I try to animate with a spring effect the arc with the following code:

func animateArcPath() {

    let springAnimation = CASpringAnimation(keyPath: "path")
    springAnimation.initialVelocity = 10
    springAnimation.mass = 10
    springAnimation.duration = springAnimation.settlingDuration
    springAnimation.fromValue = arcShapeLayer.path
    springAnimation.toValue = configureArcPath(curvePercentage: 0.0)
    springAnimation.fillMode = .both
    arcShapeLayer.add(springAnimation, forKey: nil)

    arcShapeLayer.path = configureArcPath(curvePercentage: 0.0)

}

The problem, as you can see in the video, is that the arc never overshoots. Although it oscillates between its original position and the rest position, the spring effect is never achieved.

What am I missing here?

TheoK
  • 3,601
  • 5
  • 27
  • 37
  • What happens if you just call configureArcPath with a negative value? Do you get a concave path? – matt Dec 11 '18 at 17:28
  • @matt yes it oscillates as if the final path is the concave one (which is not). My path has the same amount of points as the initial one (which is the prerequisite for the animation to take place). I wonder what is going wrong here. – TheoK Dec 11 '18 at 17:31
  • 1
    I’ve no idea, but I do know how I’d do it. Instead of animating a shape layer’s path, I’d animate a path-drawing view’s control point, as I do in this example: https://github.com/mattneub/Programming-iOS-Book-Examples/blob/3ecfba2ec68673c125aca6e276a44d43a4cee947/bk2ch04p165customAnimatableProperty3/Triangle/TriangleView.swift – matt Dec 11 '18 at 17:44
  • See https://stackoverflow.com/a/31258793/341994 for a video of that project. I do not use a springing animation in the video but that would be a trivial change. – matt Dec 11 '18 at 18:19
  • Interesting approach. I also thought of simulating the oscillation with keyframe animation, where each frame will oscillate a little less. I think that a pretty simple function could produce each frame's value. – TheoK Dec 11 '18 at 21:16
  • Yes, sounds like my `case 10` in this example: https://github.com/mattneub/Programming-iOS-Book-Examples/blob/3ecfba2ec68673c125aca6e276a44d43a4cee947/bk2ch04p148layerAnimation/ch17p490layerAnimation/ViewController.swift – matt Dec 11 '18 at 21:22
  • 1
    Of course I’d prefer to know why your animation clamps at zero! – matt Dec 11 '18 at 21:22
  • Ooooh turns out this is a well known limitation: https://stackoverflow.com/questions/38464864/cashapelayer-path-spring-animation-not-overshooting – matt Dec 14 '18 at 21:07

1 Answers1

4

I played around with this and I can report that you are absolutely right. It has nothing to do clamping at zero. The visible animation clamps at both extremes.

Let's say you supply a big mass value so that the overshoot should go way past the initial position of the convex bow on its return journey back to the start. Then what we see is that the animation clamps both at that original position and at the minimum you are trying to animate to. As the spring comes swinging between them, we see the animation happening between one extreme and the other, but as it reaches the max or min it just clamps there until it has had time to swing to its full extent and return.

I have to conclude that CAShapeLayer path doesn't like springing animations. This same point is raised at CAShapeLayer path spring animation not 'overshooting'

I was able to simulate the sort of look you're probably after by chaining normal basic animations:

enter image description here

Here's the code I used (based on your own code):

@IBAction func animateArcPath() {
    self.animate(to:-1, in:0.5, from:arcShapeLayer.path!)
}
func animate(to arc:CGFloat, in time:Double, from current:CGPath) {
    let goal = configureArcPath(curvePercentage: arc)
    let anim = CABasicAnimation(keyPath: "path")
    anim.duration = time
    anim.fromValue = current
    anim.toValue = goal
    anim.delegate = self
    anim.setValue(arc, forKey: "arc")
    anim.setValue(time, forKey: "time")
    anim.setValue(goal, forKey: "pathh")
    arcShapeLayer.add(anim, forKey: nil)
}
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
    if let arc = anim.value(forKey:"arc") as? CGFloat,
        let time = anim.value(forKey:"time") as? Double,
        let p = anim.value(forKey:"pathh") {
        if time < 0.05 {
            return
        }
        self.animate(to:arc*(-0.5), in:time*0.5, from:p as! CGPath)
    }
}
matt
  • 515,959
  • 87
  • 875
  • 1,141