Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Animate a CAShapeLayer circle into a rounded-corner triangle

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?

like image 389
bryanjclark Avatar asked May 20 '15 06:05

bryanjclark


1 Answers

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.

like image 130
Andy Matuschak Avatar answered Dec 07 '22 23:12

Andy Matuschak