Animating between two bezier path shapes
Asked Answered
R

3

7

I found it tricky to animate a UIImageView between two states: its original rectangle frame, and a new shape created with a UIBezierPath. There are many different techniques mentioned, most of which did not work for me.

First was the realization that using UIView block animation would not work; evidently one can't perform sublayer animations in an animateWithDuration: block. (see here and here)

That left CAAnimation, with the concrete subclasses like CABasicAnimation. I soon realized that one can't animate from a view that doesn't have a CAShapeLayer to one that does (see here, for example).

And they can't be just any two shape layer paths, but rather "Animating the path of a shape layer is only guaranteed to work when you are animating from like to like" (see here)

With that in place, comes the more mundane problems, like what to use for fromValue and toValue (should they be a CAShapeLayer, or a CGPath?), what to add the animation to (the layer, or the mask?), etc.

It seemed there were so many variables; which combination would give me the animation I was looking for?

Rebellion answered 10/2, 2015 at 16:42 Comment(0)
R
17

The first important point is to construct the two bezier paths similarly, so the rectangle is a (trivial) analogue to the more complex shape.

// the complex bezier path
let initialPoint = CGPoint(x: 0, y: 0)
let curveStart = CGPoint(x: 0, y: (rect.size.height) * (0.2))
let curveControl = CGPoint(x: (rect.size.width) * (0.6), y: (rect.size.height) * (0.5))
let curveEnd = CGPoint(x: 0, y: (rect.size.height) * (0.8))
let firstCorner = CGPoint(x: 0, y: rect.size.height)
let secondCorner = CGPoint(x: rect.size.width, y: rect.size.height)
let thirdCorner = CGPoint(x: rect.size.width, y: 0)

var myBezierArc = UIBezierPath()
myBezierArc.moveToPoint(initialPoint)
myBezierArc.addLineToPoint(curveStart)
myBezierArc.addQuadCurveToPoint(curveEnd, controlPoint: curveControl)
myBezierArc.addLineToPoint(firstCorner)
myBezierArc.addLineToPoint(secondCorner)
myBezierArc.addLineToPoint(thirdCorner)

The simpler 'trivial' bezier path, that creates a rectangle, is exactly the same but the controlPoint is set so that it appears to not be there:

let curveControl = CGPoint(x: 0, y: (rect.size.height) * (0.5))

( Try removing the addQuadCurveToPoint line to get a very strange animation! )

And finally, the animation commands:

let myAnimation = CABasicAnimation(keyPath: "path")

if (isArcVisible == true) {
    myAnimation.fromValue = myBezierArc.CGPath
    myAnimation.toValue = myBezierTrivial.CGPath
} else {
    myAnimation.fromValue = myBezierTrivial.CGPath
    myAnimation.toValue = myBezierArc.CGPath
}       
myAnimation.duration = 0.4
myAnimation.fillMode = kCAFillModeForwards
myAnimation.removedOnCompletion = false

myImageView.layer.mask.addAnimation(myAnimation, forKey: "animatePath")

If anyone is interested, the project is here.

Rebellion answered 10/2, 2015 at 16:42 Comment(2)
Thanks of the example, and all the links in your question. It's interesting and informative to read the discussions around this issue.Treblinka
how to set tovalue(myBezierTrivial.CGPath) in animation command?Christoper
T
10

Another approach is to use a display link. It's like a timer, except it's coordinated with the update of the display. You then have the handler of the display link modify the view according to what it should look like at any particular point of the animation.

For example, if you wanted to animate the rounding of the corners of the mask from 0 to 50 points, you could do something like the following, where percent is a value between 0.0 and 1.0 indicating what percentage of the animation is done:

let path = UIBezierPath(rect: imageView.bounds)
let mask = CAShapeLayer()
mask.path = path.CGPath
imageView.layer.mask = mask

let animation = AnimationDisplayLink(duration: 0.5) { percent in
    let cornerRadius = percent * 50.0
    let path = UIBezierPath(roundedRect: self.imageView.bounds, cornerRadius: cornerRadius)
    mask.path = path.CGPath
}

Where:

class AnimationDisplayLink : NSObject {
    var animationDuration: CGFloat
    var animationHandler: (percent: CGFloat) -> ()
    var completionHandler: (() -> ())?

    private var startTime: CFAbsoluteTime!
    private var displayLink: CADisplayLink!

    init(duration: CGFloat, animationHandler: (percent: CGFloat)->(), completionHandler: (()->())? = nil) {
        animationDuration = duration
        self.animationHandler = animationHandler
        self.completionHandler = completionHandler

        super.init()

        startDisplayLink()
    }

    private func startDisplayLink () {
        startTime = CFAbsoluteTimeGetCurrent()
        displayLink = CADisplayLink(target: self, selector: "handleDisplayLink:")
        displayLink.addToRunLoop(NSRunLoop.currentRunLoop(), forMode: NSRunLoopCommonModes)
    }

    private func stopDisplayLink() {
        displayLink.invalidate()
        displayLink = nil
    }

    func handleDisplayLink(displayLink: CADisplayLink) {
        let elapsed = CFAbsoluteTimeGetCurrent() - startTime
        var percent = CGFloat(elapsed) / animationDuration

        if percent >= 1.0 {
            stopDisplayLink()
            animationHandler(percent: 1.0)
            completionHandler?()
        } else {
            animationHandler(percent: percent)
        }
    }
}

The virtue of the display link approach is that it can be used to animate some property that is otherwise unanimatable. It also lets you to precisely dictate the interim state during the animation.

If you can use CAAnimation or UIKit block-based animation, that's probably the way to go. But the display link can sometimes be a good fallback approach.

Teacup answered 11/2, 2015 at 14:14 Comment(1)
This may come in very handy one day, when CAAnimation and UIKit won't handle something!Rebellion
T
3

I was inspired by your example to try a circle to square animation using the techniques that are mentioned in your answer and some of the links. I intend to extend this to be a more general circle to polygon animation, but currently it only works for squares. I have a class called RDPolyCircle (a subclass of CAShapeLayer) that does the heavy lifting. Here is its code,

@interface RDPolyCircle ()
@property (strong,nonatomic) UIBezierPath *polyPath;
@property (strong,nonatomic) UIBezierPath *circlePath;
@end

@implementation RDPolyCircle {
    double cpDelta;
    double cosR;
}

-(instancetype)initWithFrame:(CGRect) frame numberOfSides:(NSInteger)sides isPointUp:(BOOL) isUp isInitiallyCircle:(BOOL) isCircle {
    if (self = [super init]) {
        self.frame = frame;
        _isPointUp = isUp;
        _isExpandedPolygon = !isCircle;

        double radius = (frame.size.width/2.0);
        cosR = sin(45 * M_PI/180.0) * radius;
        double fractionAlongTangent = 4.0*(sqrt(2)-1)/3.0;
        cpDelta = fractionAlongTangent * radius * sin(45 * M_PI/180.0);

        _circlePath = [self createCirclePathForFrame:frame];
        _polyPath = [self createPolygonPathForFrame:frame numberOfSides:sides];
        self.path = (isCircle)? self.circlePath.CGPath : self.polyPath.CGPath;
        self.fillColor = [UIColor clearColor].CGColor;
        self.strokeColor = [UIColor blueColor].CGColor;
        self.lineWidth = 6.0;
    }
    return self;
}


-(UIBezierPath *)createCirclePathForFrame:(CGRect) frame {

    CGPoint ctr = CGPointMake(frame.origin.x + frame.size.width/2.0, frame.origin.y + frame.size.height/2.0);

    // create a circle using 4 arcs, with the first one symmetrically spanning the y-axis
    CGPoint leftUpper = CGPointMake(ctr.x - cosR, ctr.y - cosR);
    CGPoint cp1 = CGPointMake(leftUpper.x + cpDelta, leftUpper.y - cpDelta);

    CGPoint rightUpper = CGPointMake(ctr.x + cosR, ctr.y - cosR);
    CGPoint cp2 = CGPointMake(rightUpper.x - cpDelta, rightUpper.y - cpDelta);
    CGPoint cp3 = CGPointMake(rightUpper.x + cpDelta, rightUpper.y + cpDelta);

    CGPoint rightLower = CGPointMake(ctr.x + cosR, ctr.y + cosR);
    CGPoint cp4 = CGPointMake(rightLower.x + cpDelta, rightLower.y - cpDelta);
    CGPoint cp5 = CGPointMake(rightLower.x - cpDelta, rightLower.y + cpDelta);

    CGPoint leftLower = CGPointMake(ctr.x - cosR, ctr.y + cosR);
    CGPoint cp6 = CGPointMake(leftLower.x + cpDelta, leftLower.y + cpDelta);
    CGPoint cp7 = CGPointMake(leftLower.x - cpDelta, leftLower.y - cpDelta);
    CGPoint cp8 = CGPointMake(leftUpper.x - cpDelta, leftUpper.y + cpDelta);

    UIBezierPath *circle = [UIBezierPath bezierPath];
    [circle moveToPoint:leftUpper];
    [circle addCurveToPoint:rightUpper controlPoint1:cp1  controlPoint2:cp2];
    [circle addCurveToPoint:rightLower controlPoint1:cp3 controlPoint2:cp4];
    [circle addCurveToPoint:leftLower controlPoint1:cp5 controlPoint2:cp6];
    [circle addCurveToPoint:leftUpper controlPoint1:cp7 controlPoint2:cp8];
    [circle closePath];
    circle.lineCapStyle = kCGLineCapRound;
    return circle;
}


-(UIBezierPath *)createPolygonPathForFrame:(CGRect) frame numberOfSides:(NSInteger) sides {
    CGPoint leftUpper = CGPointMake(self.frame.origin.x, self.frame.origin.y);
    CGPoint cp1 = CGPointMake(leftUpper.x + cpDelta, leftUpper.y);

    CGPoint rightUpper = CGPointMake(self.frame.origin.x + self.frame.size.width, self.frame.origin.y);
    CGPoint cp2 = CGPointMake(rightUpper.x - cpDelta, rightUpper.y);
    CGPoint cp3 = CGPointMake(rightUpper.x, rightUpper.y + cpDelta);

    CGPoint rightLower = CGPointMake(self.frame.origin.x + self.frame.size.width, self.frame.origin.y + self.frame.size.height);
    CGPoint cp4 = CGPointMake(rightLower.x , rightLower.y - cpDelta);
    CGPoint cp5 = CGPointMake(rightLower.x - cpDelta, rightLower.y);

    CGPoint leftLower = CGPointMake(self.frame.origin.x, self.frame.origin.y + self.frame.size.height);
    CGPoint cp6 = CGPointMake(leftLower.x + cpDelta, leftLower.y);
    CGPoint cp7 = CGPointMake(leftLower.x, leftLower.y - cpDelta);
    CGPoint cp8 = CGPointMake(leftUpper.x, leftUpper.y + cpDelta);

    UIBezierPath *square = [UIBezierPath bezierPath];
    [square moveToPoint:leftUpper];
    [square addCurveToPoint:rightUpper controlPoint1:cp1  controlPoint2:cp2];
    [square addCurveToPoint:rightLower controlPoint1:cp3 controlPoint2:cp4];
    [square addCurveToPoint:leftLower controlPoint1:cp5 controlPoint2:cp6];
    [square addCurveToPoint:leftUpper controlPoint1:cp7 controlPoint2:cp8];
    [square closePath];
    square.lineCapStyle = kCGLineCapRound;
    return square;
}


-(void)toggleShape {
    if (self.isExpandedPolygon) {
        [self restore];
    }else{

        CABasicAnimation *expansionAnimation = [CABasicAnimation animationWithKeyPath:@"path"];
        expansionAnimation.fromValue = (__bridge id)(self.circlePath.CGPath);
        expansionAnimation.toValue = (__bridge id)(self.polyPath.CGPath);
        expansionAnimation.duration = 0.5;
        expansionAnimation.fillMode = kCAFillModeForwards;
        expansionAnimation.removedOnCompletion = NO;

        [self addAnimation:expansionAnimation forKey:@"Expansion"];
        self.isExpandedPolygon = YES;
    }
}


-(void)restore {
    CABasicAnimation *contractionAnimation = [CABasicAnimation animationWithKeyPath:@"path"];
    contractionAnimation.fromValue = (__bridge id)(self.polyPath.CGPath);
    contractionAnimation.toValue = (__bridge id)(self.circlePath.CGPath);
    contractionAnimation.duration = 0.5;
    contractionAnimation.fillMode = kCAFillModeForwards;
    contractionAnimation.removedOnCompletion = NO;

    [self addAnimation:contractionAnimation forKey:@"Contraction"];
    self.isExpandedPolygon = NO;
}

From the view controller, I create an instance of this layer, and add it to a simple view's layer, then do the animations on a button push,

#import "ViewController.h"
#import "RDPolyCircle.h"

@interface ViewController ()
@property (weak, nonatomic) IBOutlet UIView *circleView; // a plain UIView 150 x 150 centered in the superview
@property (strong,nonatomic) RDPolyCircle *shapeLayer;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    // currently isPointUp and numberOfSides are not implemented (the shape created has numberOfSides=4 and isPointUp=NO)
    // isInitiallyCircle is implemented
    self.shapeLayer = [[RDPolyCircle alloc] initWithFrame:self.circleView.bounds numberOfSides: 4 isPointUp:NO isInitiallyCircle:YES];
    [self.circleView.layer addSublayer:self.shapeLayer];
}


- (IBAction)toggleShape:(UIButton *)sender {
    [self.shapeLayer toggleShape];
}

The project can be found here, http://jmp.sh/iK3kuVs.

Treblinka answered 11/2, 2015 at 7:4 Comment(1)
Can I share my simple func bezierPathWithPolygonInRect(rect: CGRect, numberOfSides: Int) -> UIBezierPath { methodRebellion

© 2022 - 2024 — McMap. All rights reserved.