ioscore-animationcashapelayercakeyframeanimationuiviewpropertyanimator

Interrupt and reverse CAKeyframeAnimation


I have a checkbox view that can be checked or unchecked. I would like to animate the drawing of the checkmark and to do so, I am using a CAKeyframeAnimation and drawing on a CAShapeLayer.

This works fine, but I would also like to be able to support reversing the check decision mid-draw. Right now the duration of the animation is configured to be one second. So if a person taps the view and the checkmark starts to draw but then taps the view at a time of 0.5 seconds, then I would like the animation to stop drawing and start reversing. Likewise, if the checkmark is being unchecked and the person taps on it, then I would like it to reverse its clearing animation and start to draw the checkmark again.

I am just not sure how to go about this. I do not know if this is possible with CAKeyframeAnimation or if I should use UIViewPropertyAnimator or something else, or if I can even use UIViewPropertyAnimator since it is a view animator and I am animating a property on a CAShapeLayer. And also I have the checkmark broken down into three pieces (a starting dot, the first downward segment of the checkmark, and the whole checkmark), so I am not sure how to animate that with a UIViewPropertyAnimator anyway (maybe chain the animations, but then it seems like that would make reversing the animation difficult).

Here is my code. Does anyone have any ideas on how to make this interruptible and reversible?

MyCheckmarkView.h

#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN

@interface MyCheckmarkView : UIView<CAAnimationDelegate>

@end

NS_ASSUME_NONNULL_END

MyCheckmarkView.m

#import "MyCheckmarkView.h"

@interface MyCheckmarkView ()

@property (strong, nonatomic) UIColor *strokeColor;
@property (assign, nonatomic, getter=isChecked) BOOL checked;
@property (strong, nonatomic) CAShapeLayer *contentLayer;

@end


@implementation MyCheckmarkView

+ (Class)layerClass {
    return [CAShapeLayer class];
}


- (instancetype)init {
    return [self initWithFrame:CGRectMake(0, 0, 100, 100)];
}

- (instancetype)initWithCoder:(NSCoder *)coder {
    if (self = [super initWithCoder:coder]) {
        [self initialize];
    }
    return self;
}

- (instancetype)initWithFrame:(CGRect)frame {
    if (self = [super initWithFrame:frame]) {
        [self initialize];
    }
    return self;
}

- (void)initialize {
    self->_strokeColor = [UIColor colorWithRed: 0.2 green: 0.6 blue: 1 alpha: 1];
    
    CAShapeLayer *backgroundLayer = (CAShapeLayer *)self.layer;
    backgroundLayer.fillColor = nil;
    backgroundLayer.strokeColor = self.strokeColor.CGColor;
    backgroundLayer.lineWidth = 7.88;
    backgroundLayer.miterLimit = 7.88;
    backgroundLayer.lineCap = kCALineCapRound;
    backgroundLayer.lineJoin = kCALineJoinRound;
    
    UIBezierPath* rectanglePath = [UIBezierPath bezierPathWithRoundedRect: CGRectMake(4.95, 4.92, 90, 90) cornerRadius: 22.6];
    backgroundLayer.path = rectanglePath.CGPath;

    [backgroundLayer addSublayer:self.contentLayer];
    
    UITapGestureRecognizer *tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(toggleCheckedState)];
    [self addGestureRecognizer:tapGestureRecognizer];
}

- (CGSize)intrinsicContentSize {
    return CGSizeMake(100, 100);
}



- (void)toggleCheckedState {
    if (self.checked) {
        [self animateToUncheckedState];
    } else {
        [self animateToCheckedState];
    }
    self.checked = ![self isChecked];
}






- (CAShapeLayer *)contentLayer {
    if (!self->_contentLayer) {
        self->_contentLayer = [[CAShapeLayer alloc] init];
        self->_contentLayer.fillColor = nil;
        self->_contentLayer.strokeColor = self.strokeColor.CGColor;
        self->_contentLayer.lineWidth = 7.88;
        self->_contentLayer.miterLimit = 7.88;
        self->_contentLayer.lineCap = kCALineCapRound;
        self->_contentLayer.lineJoin = kCALineJoinRound;
    }
    return self->_contentLayer;
}

- (void)animateToCheckedState {
    UIBezierPath *initialPath = [UIBezierPath bezierPath];
    [initialPath moveToPoint:CGPointMake(25.94, 48.05)];
    [initialPath addLineToPoint:CGPointMake(25.94, 48.05)];

    UIBezierPath *startPath = [UIBezierPath bezierPath];
    [startPath moveToPoint:CGPointMake(25.94, 48.05)];
    [startPath addLineToPoint: CGPointMake(43.81, 65.34)];
    
    UIBezierPath* checkmarkPath = [UIBezierPath bezierPath];
    [checkmarkPath moveToPoint: CGPointMake(25.94, 48.05)];
    [checkmarkPath addLineToPoint: CGPointMake(43.81, 65.34)];
    [checkmarkPath addLineToPoint: CGPointMake(73.94, 34.53)];
    
    
    UIViewPropertyAnimator *animator = [[UIViewPropertyAnimator alloc] init];
    [animator addAnimations:^{}];
    
    CAKeyframeAnimation *animation = [CAKeyframeAnimation animationWithKeyPath:@"path"];
    animation.duration = 1;
    animation.values = @[
        (id)initialPath.CGPath,
        (id)startPath.CGPath,
        (id)checkmarkPath.CGPath
    ];
    animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
    animation.fillMode = kCAFillModeBoth;
    
    animation.repeatCount = 0;
    animation.autoreverses = NO;
    animation.removedOnCompletion = YES;
    
    [self.contentLayer addAnimation:animation forKey:@"checkmarkAnimation"];
    self.contentLayer.path = checkmarkPath.CGPath;
}


- (void)animateToUncheckedState {
    UIBezierPath *initialPath = [UIBezierPath bezierPath];
    [initialPath moveToPoint:CGPointMake(25.94, 48.05)];
    [initialPath addLineToPoint:CGPointMake(25.94, 48.05)];
    

    UIBezierPath *startPath = [UIBezierPath bezierPath];
    [startPath moveToPoint:CGPointMake(25.94, 48.05)];
    [startPath addLineToPoint: CGPointMake(43.81, 65.34)];
    
    UIBezierPath* checkmarkPath = [UIBezierPath bezierPath];
    [checkmarkPath moveToPoint: CGPointMake(25.94, 48.05)];
    [checkmarkPath addLineToPoint: CGPointMake(43.81, 65.34)];
    [checkmarkPath addLineToPoint: CGPointMake(73.94, 34.53)];

    
    CAKeyframeAnimation *animation = [CAKeyframeAnimation animationWithKeyPath:@"path"];
    animation.duration = 1;
    animation.values = @[
        (id)checkmarkPath.CGPath,
        (id)startPath.CGPath,
        (id)initialPath.CGPath
    ];
    animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
    animation.fillMode = kCAFillModeBoth;
    
    animation.repeatCount = 0;
    animation.autoreverses = NO;
    animation.removedOnCompletion = YES;
    
    [self.contentLayer addAnimation:animation forKey:@"checkmarkAnimation"];
    self.contentLayer.path = nil;
}

@end

Update

Here is my updated code for the animation parts based on the code from @DonMag. Everything works fine, except when the animation starts, it seems to directly set the strokeEnd value for the layer to the desired end value first and then it starts the animation. It could be because I am setting the strokeEnd property at the very end of the method, but that is so the value will be persisted after the animation is removed and the presentationLayer is updated with the value in the original content layer property.

I have also tried to use a CATransaction and then set this in the completionBlock, but that just had the reverse effect. The animation started and completed successfully, but then the animation was removed and briefly the old state was shown and then (for some reason) quickly animated even though I did not do anything to explicitly animate the property (I guess this might be an implicitly animated property?).

But I did not think I should have to use a CATransaction anyway as I am adding the animation to the layer and then updating the property while the presentation layer is displaying the animation. Is this incorrect and is there a better way to do this? If at all possible, I would like the animation to be able to be removed and the original layer to show the correct state after the animation is removed.

Here is my code for both the checked and unchecked states.

Animate to Checked State

NSTimeInterval animationDuration = 3.0;


// get current strokeEnd value
double f = self.contentLayer.presentationLayer.strokeEnd;

CABasicAnimation *anim = [CABasicAnimation animationWithKeyPath:@"strokeEnd"];

// animate strokeEnd from current to 1.0
[anim setFromValue:[NSNumber numberWithDouble:f]];
[anim setToValue:[NSNumber numberWithDouble:1.0]];

[anim setDuration:((1.0 - f) * animationDuration)];

[anim setRemovedOnCompletion:YES];

// start animation
[self.contentLayer addAnimation:anim forKey:@"draw"];

// if checkMark was being "un-drawn"
//  remove that animation
[self.contentLayer removeAnimationForKey:@"undraw"];

// update the original "model" layer so that when the animation is
// finished, the updates will persist to the layer
self.contentLayer.strokeEnd = 1.0;

Animate to Unchecked State


NSTimeInterval animationDuration = 3.0;


// get current strokeEnd value
double f = self.contentLayer.presentationLayer.strokeEnd;

CABasicAnimation *anim = [CABasicAnimation animationWithKeyPath:@"strokeEnd"];

// animate strokeEnd from current to 0.0
[anim setFromValue:[NSNumber numberWithDouble:f]];
[anim setToValue:[NSNumber numberWithDouble:0.0]];

[anim setDuration:(f * animationDuration)];

[anim setRemovedOnCompletion:YES];

// start animation
[self.contentLayer addAnimation:anim forKey:@"undraw"];

// if checkMark was being "drawn"
//  remove that animation
[self.contentLayer removeAnimationForKey:@"draw"];

// persist the changes to the original layer
self.contentLayer.strokeEnd = 0.0;

Solution

  • First, I'd suggest using a single path for your checkMark:

    [checkmarkPath moveToPoint:CGPointMake(25.94, 48.05)];
    [checkmarkPath addLineToPoint: CGPointMake(43.81, 65.34)];
    [checkmarkPath addLineToPoint: CGPointMake(73.94, 34.53)];
    

    We can then use the strokeEnd property to "draw" it from 0.0 to 1.0, or "un-draw" it from 1.0 to 0.0

    Next, to interrupt and reverse it, we can get the current .strokeEnd value from the .presentationLayer and use that as our animation's from value.

    Here's a modification of your class to play around with (no changes to the header file):

    #import "MyCheckmarkView.h"
    
    @interface MyCheckmarkView ()
    @property (strong, nonatomic) UIColor *strokeColor;
    @property (assign, nonatomic, getter=isChecked) BOOL checked;
    @property (strong, nonatomic) CAShapeLayer *contentLayer;
    @end
    
    
    @implementation MyCheckmarkView
    
    + (Class)layerClass {
        return [CAShapeLayer class];
    }
    
    
    - (instancetype)init {
        return [self initWithFrame:CGRectMake(0, 0, 100, 100)];
    }
    
    - (instancetype)initWithCoder:(NSCoder *)coder {
        if (self = [super initWithCoder:coder]) {
            [self initialize];
        }
        return self;
    }
    
    - (instancetype)initWithFrame:(CGRect)frame {
        if (self = [super initWithFrame:frame]) {
            [self initialize];
        }
        return self;
    }
    
    - (void)initialize {
        self->_strokeColor = [UIColor colorWithRed: 0.2 green: 0.6 blue: 1 alpha: 1];
        
        CAShapeLayer *backgroundLayer = (CAShapeLayer *)self.layer;
        backgroundLayer.fillColor = nil;
        backgroundLayer.strokeColor = self.strokeColor.CGColor;
        backgroundLayer.lineWidth = 7.88;
        backgroundLayer.miterLimit = 7.88;
        backgroundLayer.lineCap = kCALineCapRound;
        backgroundLayer.lineJoin = kCALineJoinRound;
        
        UIBezierPath* rectanglePath = [UIBezierPath bezierPathWithRoundedRect: CGRectMake(4.95, 4.92, 90, 90) cornerRadius: 22.6];
        backgroundLayer.path = rectanglePath.CGPath;
        
        [backgroundLayer addSublayer:self.contentLayer];
        
        UITapGestureRecognizer *tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(toggleCheckedState)];
        [self addGestureRecognizer:tapGestureRecognizer];
    }
    
    - (CGSize)intrinsicContentSize {
        return CGSizeMake(100, 100);
    }
    
    - (void)toggleCheckedState {
        if (self.checked) {
            [self animateToUncheckedState];
        } else {
            [self animateToCheckedState];
        }
        self.checked = ![self isChecked];
    }
    
    - (void)layoutSubviews {
        [super layoutSubviews];
    
        // single path for checkmark shape
        UIBezierPath *checkmarkPath = [UIBezierPath bezierPath];
    
        [checkmarkPath moveToPoint:CGPointMake(25.94, 48.05)];
        [checkmarkPath addLineToPoint: CGPointMake(43.81, 65.34)];
        [checkmarkPath addLineToPoint: CGPointMake(73.94, 34.53)];
    
        _contentLayer.path = [checkmarkPath CGPath];
        
        // start with strokeEnd at Zero if not checked
        _contentLayer.strokeEnd = self.checked ? 1.0 : 0.0;
    }
    
    - (void)animateToCheckedState {
        
        // get current strokeEnd value
        double f = _contentLayer.presentationLayer.strokeEnd;
    
        CABasicAnimation *anim = [CABasicAnimation animationWithKeyPath:@"strokeEnd"];
        
        // animate strokeEnd from current to 1.0
        [anim setFromValue:[NSNumber numberWithDouble:f]];
        [anim setToValue:[NSNumber numberWithDouble:1.0]];
    
        [anim setDuration:1.0];
    
        // we're "showing" the checkMark,
        //  so leave it when "finished"
        [anim setRemovedOnCompletion:NO];
    
        [anim setFillMode:kCAFillModeBoth];
    
        // start animation
        [self.contentLayer addAnimation:anim forKey:@"draw"];
        
        // if checkMark was being "un-drawn"
        //  remove that animation
        [self.contentLayer removeAnimationForKey:@"undraw"];
        
    }
    
    - (void)animateToUncheckedState {
    
        // get current strokeEnd value
        double f = _contentLayer.presentationLayer.strokeEnd;
    
        CABasicAnimation *anim = [CABasicAnimation animationWithKeyPath:@"strokeEnd"];
    
        // animate strokeEnd from current to 0.0
        [anim setFromValue:[NSNumber numberWithDouble:f]];
        [anim setToValue:[NSNumber numberWithDouble:0.0]];
        
        [anim setDuration:1.0];
    
        // we're "un-drawing" the checkMark,
        //  so remove it when "finished"
        [anim setRemovedOnCompletion:YES];
    
        [anim setFillMode:kCAFillModeBoth];
    
        // start animation
        [self.contentLayer addAnimation:anim forKey:@"undraw"];
    
        // if checkMark was being "drawn"
        //  remove that animation
        [self.contentLayer removeAnimationForKey:@"draw"];
        
    }
    
    - (CAShapeLayer *)contentLayer {
        if (!self->_contentLayer) {
            self->_contentLayer = [[CAShapeLayer alloc] init];
            self->_contentLayer.fillColor = nil;
            self->_contentLayer.strokeColor = self.strokeColor.CGColor;
            self->_contentLayer.lineWidth = 7.88;
            self->_contentLayer.miterLimit = 7.88;
            self->_contentLayer.lineCap = kCALineCapRound;
            self->_contentLayer.lineJoin = kCALineJoinRound;
        }
        return self->_contentLayer;
    }
    
    @end