0
votes

I'm trying to smoothly animate a square into a circle in SpriteKit.

I'm creating the SKShape with a UIBezierPath using rounded corners. Then, I vary the corner radii to animate.

My problem is that I seem to have a jump in the animation, please see the gif below. Preferably using the rounded corners technique, how can I get it to be smooth?

"Jumpy" problem

Bug with animating circle to square

    let shape = SKShapeNode()
    let l: CGFloat = 100.0
    shape.path = UIBezierPath(roundedRect: CGRect(x: -l/2, y: -l/2, width: l, height: l), byRoundingCorners: [.topLeft, .bottomLeft, .topRight, .bottomRight], cornerRadii: CGSize(width: 0, height: 0)).cgPath
    shape.position = CGPoint(x: frame.midX, y: frame.midY)
    shape.fillColor = .white
    addChild(shape)

    let action = SKAction.customAction(withDuration: 1) { (node, t) in
    let shapeNode = node as! SKShapeNode
    shapeNode.path = UIBezierPath(roundedRect: CGRect(x: -l/2, y: -l/2, width: l, height: l), byRoundingCorners: [.topLeft, .bottomLeft, .topRight, .bottomRight], cornerRadii: CGSize(width: t * l / 2, height: 0)).cgPath
    }
    shape.run(SKAction.repeatForever(action))

Animation debugging

To debug I have created some shapes with progressively larger corner radii as you can see below. The numbers represent the ratio of the corner radii to the length of the square. As you can see there is a jump between 0.3 and 0.35. I can't see what I'm missing.

Progressive incrementation

    let cols = 10
    let rows = 1

    let l: Double = 30.0
    let max: Double = l / 2
    let delta: Double = l * 2

    for i in 0..<rows * cols {
        let s = SKShapeNode()
        let c: Double = Double(i % cols)
        let r: Double = floor(Double(i) / Double(cols))
        let pct: Double = Double(i) / (Double(rows) * Double(cols))
        let rad = pct * max
        s.path = UIBezierPath(roundedRect: CGRect(x: -l/2, y: -l/2, width: l, height: l), byRoundingCorners: [.topRight, .bottomRight, .topLeft, .bottomLeft], cornerRadii: CGSize(width: pct * max, height: pct * max)).cgPath
        s.position = CGPoint(x: c * delta - Double(cols) / 2.0 * delta, y: r * delta - Double(rows) / 2.0 * delta)
        s.lineWidth = 1.5
        s.strokeColor = .white
        addChild(s)

        let t = SKLabelNode(text: String(format:"%0.2f", rad / l))
        t.verticalAlignmentMode = .center
        t.horizontalAlignmentMode = .center
        t.fontName = "SanFrancisco-Bold"
        t.fontSize = 15

        t.position = CGPoint(x: 0, y: -delta * 0.66)
        s.addChild(t)
    }
2
Reading the docs is interesting. cornerRadii: "Values larger than half the rectangle’s width or height are clamped appropriately to half the width or height.” Your formula width: t * l / 2, height: 0 seems odd... Indeed, since you are rounding all four corners of the bezier path, it is hard to see why you don’t just call init(roundedRect:cornerRadius:).matt
That is a fair point, for my use case I am looking to vary them independently. I wanted to keep the question simple and more relevant to other peoples use cases. As for the formula t * l / 2. t goes linearly from 0 to 1 and this is multiplied by what I think the maximum value should be: the length (width) of the square divided by two.Tibor Udvari
This is likely because the drawing code is using apple's hyper-ellipse/squircle code which has been documented to adjust itself to "look best" depending on the rectangle's width/length ratio, etc.Warpling
@Warpling Might be interesting to know more about that, could you recommend any references?Tibor Udvari
@TiborUdvari was too lazy to look it up the first time—sorry ;) Paint Code documented it well. There are actually 3 different shapes iOS uses depending on the width/height/radius.Warpling

2 Answers

3
votes

You may not find the answer using a current api. But you can draw it by yourself.

  let duration = 10.0
    let action = SKAction.customAction(withDuration: duration) { (node, t) in
      let shapeNode = node as! SKShapeNode
        let path = CGMutablePath()
        let borderRadius = l/2 * t  / CGFloat(duration);
      path.move(to: CGPoint.init(x: -l/2, y:  -l/2 + borderRadius));
      path.addLine(to: CGPoint.init(x:  -l/2, y: l/2 - borderRadius));
      path.addArc(tangent1End: CGPoint(x: -l/2, y: l/2), tangent2End: CGPoint(x: -l/2 + borderRadius, y: l/2), radius: borderRadius)
      path.addLine(to: CGPoint.init(x:  l/2 - borderRadius, y: l/2 ));
      path.addArc(tangent1End: CGPoint(x: l/2, y: l/2), tangent2End: CGPoint(x: l/2, y: l/2 - borderRadius), radius: borderRadius)
      path.addLine(to: CGPoint.init(x:  l/2, y: -l/2 + borderRadius));
      path.addArc(tangent1End: CGPoint(x: l/2, y: -l/2), tangent2End: CGPoint(x: l/2 - borderRadius, y: -l/2), radius: borderRadius)
      path.addLine(to: CGPoint.init(x:  -l/2 + borderRadius, y: -l/2));
      path.addArc(tangent1End: CGPoint(x: -l/2, y: -l/2), tangent2End: CGPoint(x: -l/2, y: -l/2 + borderRadius), radius: borderRadius)
      path.closeSubpath()
      shapeNode.path = path
    }

Working solution

0
votes

The documentation of UIBezierPath.init(roundedRect:cornerRadius:) states that:

The radius of each corner oval. A value of 0 results in a rectangle without rounded corners. Values larger than half the rectangle’s width or height are clamped appropriately to half the width or height.

But I tested this and it is actually happening exactly for values larger than a third of the width/height and not at a half of it. I'm filling a bug report and will let's hope it is quickly solved.

Bug report link: https://bugs.swift.org/browse/SR-10496