Animate a CAShapeLayer circle into a rounded-corner triangle
Asked Answered
B

1

7

I'd like to animate a shape as it transitions from a circle to a rounded-corner triangle.

TL;DR: How do I animate a CAShapeLayer's path between two CGPath shapes? I know that they need to have the same number of control points, but I think I'm doing that - what's wrong with this code?

The beginning and end states would look something like this: transition http://clrk.it/4vJS+

Here's what I've tried so far: I'm using a CAShapeLayer, and animating a change in its path property.

According to the documentation (emphasis mine):

The path object may be animated using any of the concrete subclasses of CAPropertyAnimation. Paths will interpolate as a linear blend of the "on-line" points; "off-line" points may be interpolated non-linearly (e.g. to preserve continuity of the curve's derivative). If the two paths have a different number of control points or segments the results are undefined.

In an attempt to get the circle and triangle to have the same number of control points, I made them both four-pointed shapes: the circle is a heavily-rounded rectangle, and the triangle is "hiding" a fourth control point on one side.

Here's my code:

self.view.backgroundColor = .blueColor()

//CREATE A CASHAPELAYER
let shape = CAShapeLayer()
shape.frame = CGRect(x: 50, y: 50, width: 200, height: 200)
shape.fillColor = UIColor.redColor().CGColor
self.view.layer.addSublayer(shape)

let bounds = shape.bounds

//CREATE THE SQUIRCLE
let squareRadius: CGFloat = CGRectGetWidth(shape.bounds)/2

let topCorner = CGPointMake(bounds.midX, bounds.minY)
let rightCorner = CGPointMake(bounds.maxX, bounds.midY)
let bottomCorner = CGPointMake(bounds.midX, bounds.maxY)
let leftCorner = CGPointMake(bounds.minX, bounds.midY)

let squarePath = CGPathCreateMutable()
let squareStartingPoint = midPoint(leftCorner, point2: topCorner)
CGPathMoveToPoint(squarePath, nil, squareStartingPoint.x, squareStartingPoint.y)
addArcToPoint(squarePath, aroundPoint: topCorner, onWayToPoint: rightCorner, radius: squareRadius)
addArcToPoint(squarePath, aroundPoint: rightCorner, onWayToPoint: bottomCorner, radius: squareRadius)
addArcToPoint(squarePath, aroundPoint: bottomCorner, onWayToPoint: leftCorner, radius: squareRadius)
addArcToPoint(squarePath, aroundPoint: leftCorner, onWayToPoint: topCorner, radius: squareRadius)
CGPathCloseSubpath(squarePath)
let square = UIBezierPath(CGPath: squarePath)

//CREATE THE (FAKED) TRIANGLE
let triangleRadius: CGFloat = 25.0

let trianglePath = CGPathCreateMutable()
let triangleStartingPoint = midPoint(topCorner, point2: rightCorner)

let startingPoint = midPoint(topCorner, point2: leftCorner)
CGPathMoveToPoint(trianglePath, nil, startingPoint.x, startingPoint.y)
let cheatPoint = midPoint(topCorner, point2:bottomCorner)
addArcToPoint(trianglePath, aroundPoint: topCorner, onWayToPoint: cheatPoint, radius: triangleRadius)
addArcToPoint(trianglePath, aroundPoint: cheatPoint, onWayToPoint: bottomCorner, radius: triangleRadius)
addArcToPoint(trianglePath, aroundPoint: bottomCorner, onWayToPoint: leftCorner, radius: triangleRadius)
addArcToPoint(trianglePath, aroundPoint: leftCorner, onWayToPoint: topCorner, radius: triangleRadius)
CGPathCloseSubpath(trianglePath)
let triangle = UIBezierPath(CGPath: trianglePath)


shape.path = square.CGPath

...and later on, in viewDidAppear:

let animation = CABasicAnimation(keyPath: "path")
animation.fromValue = self.square.CGPath
animation.toValue = self.triangle.CGPath
animation.duration = 3
self.shape.path = self.triangle.CGPath
self.shape.addAnimation(animation, forKey: "animationKey")

I have two quick little functions for making the code more legible:

func addArcToPoint(path: CGMutablePath!, aroundPoint: CGPoint, onWayToPoint: CGPoint, radius: CGFloat) {
    CGPathAddArcToPoint(path, nil, aroundPoint.x, aroundPoint.y, onWayToPoint.x, onWayToPoint.y, radius)
}

func midPoint(point1: CGPoint, point2: CGPoint) -> CGPoint {
    return CGPointMake((point1.x + point2.x)/2, (point1.y + point2.y)/2)
}

My current result looks like this:

triangle-to-square

NOTE: I'm trying to build the circle out of a very-rounded square, in an attempt to get the same number of control points in the vector shape. In the above GIF, the corner radius has been reduced to make the transformation more visible.

What do you think is going on? How else might I achieve this effect?

Borne answered 20/5, 2015 at 6:34 Comment(7)
try making both shapes with a lot of straight line segments instead?Prewar
actually, are you sure you're using addArcToPoint() correctly?Prewar
(I don't htink the path parameter to addArcToPoint() to be optional)Prewar
are you trying to start with a square? is your question why does your starting shape not look like a circle?Prewar
@Prewar - ah, sorry, i can see how that's not clear - I'm building a circle from a very-rounded square in an attempt to have the same number of "control points" - i reduced the corner radius to help me debug the issue.Borne
Looking at the animated gif, what is it that you think is wrong with it? Surely it's doing exactly what you want.Arlo
looks like your corners are actually shifting counterclockwise during the animation... (Watch the left facing corner closely). It's possible your paths don't actually have the same number of points before and after? You can iterate the path to print out what it contains....Prewar
D
6

You're not ending up with the same number of control points because addArcToPoint() skips creating a line segment if the path's current point and its first argument are equal.

From its documentation (emphasis mine):

If the current point and the first tangent point of the arc (the starting point) are not equal, Quartz appends a straight line segment from the current point to the first tangent point.

These two calls in your triangle-making code won't produce line segments, while the corresponding calls making the squircle will:

addArcToPoint(trianglePath, aroundPoint: cheatPoint, onWayToPoint: bottomCorner, radius: triangleRadius)
addArcToPoint(trianglePath, aroundPoint: bottomCorner, onWayToPoint: leftCorner, radius: triangleRadius)

You can manually create the lines, though, by calling CGPathAddLineToPoint. And you can check your work using CGPathApplierFunction, which will let you enumerate the control points.


Also~~~ I might suggest that spinning a display link and writing a function which specifies the desired shape for every t ∈ [0, 1] is probably going to be a clearer, more maintainable solution.

Derrick answered 16/8, 2015 at 0:27 Comment(2)
Thanks so much! Yep, this is going be an interactive circle - you drag it to the left, the circle becomes an arrow, drag it to the right it goes the other way - so I'll definitely need to write that function!Borne
Facebook's Pop framework is a pretty nice way to build animations with curves, chaining, etc. out of such functions (check out github.com/facebook/pop/blob/master/pop/POPAnimatableProperty.h).Derrick

© 2022 - 2024 — McMap. All rights reserved.