Start new CAAnimation from current CAAnimation's state
Asked Answered
M

2

7

I'm using a pair of CALayers as image masks, allowing me to animate a bulb filling or emptying at a set speed while also following the current touch position. That is, one mask jumps to follow the touch and the other slides to that position. Since I use an explicit animation I'm forced to set the position of the mask sliding mask when I add the animation. This means that if I start a fill and then start an empty before the fill completes the empty will begin from the completed fill position (the opposite is also true).

Is there a way to get the position of the animation, set the position at each step of the animation, or to have the new animation begin from the current state of the active animation?

The code handling the animating is below:

- (void)masksFillTo:(CGFloat)height {
    // Clamp the height we fill to inside the bulb. Remember Y gets bigger going down.
    height = MIN(MAX(BULB_TOP, height), BULB_BOTTOM);

    // We can find the target Y location by subtracting the Y value for the top of the
    // bulb from the height.
    CGFloat targetY = height - BULB_TOP;

    // Find the bottom of the transparent mask to determine where the solid fill
    // is sitting. Then find how far that fill needs to move.
    // TODO: This works with the new set position, so overriding old anime doesn't work
    CGFloat bottom = transMask.frame.origin.y + transMask.frame.size.height;

    // If the target is above the bottom of the solid, we want to fill up.
    // This means the empty mask jumps and the transparent mask slides.
    CALayer *jumper;
    CALayer *slider;
    if (bottom - targetY >= 0) {
        jumper = emptyMask;
        slider = transMask;

        // We need to reset the bottom to the emptyMask
        bottom = emptyMask.frame.origin.y + emptyMask.frame.size.height;
    } else {
        jumper = transMask;
        slider = emptyMask;
    }

    [jumper removeAllAnimations];
    [slider removeAllAnimations];

    CGFloat dy = bottom - targetY;

    [CATransaction begin]; 
    [CATransaction setValue:(id)kCFBooleanTrue forKey:kCATransactionDisableActions];
    [jumper setPosition:CGPointMake(jumper.position.x, jumper.position.y - dy)];
    [self slideMaskFillTo:height withMask:slider]; // Do this inside here or an odd flash glitch appears.
    [CATransaction commit];
}

// TODO: Always starts from new position, even if animation hasn't reached it.
- (void)slideMaskFillTo:(CGFloat)height withMask:(CALayer *)slider {
    // We can find the target Y location by subtracting the Y value for the top of the
    // bulb from the height.
    CGFloat targetY = height - BULB_TOP;

    // We then find the bottom of the mask.
    CGFloat bottom = slider.frame.origin.y + slider.frame.size.height;

    CGFloat dy = bottom - targetY;

    // Do the animation. Animating with duration doesn't appear to work properly.
    // Apparently "When modifying layer properties from threads that don’t have a runloop, 
    // you must use explicit transactions."
    CABasicAnimation *a = [CABasicAnimation animationWithKeyPath:@"position"];
    a.duration = (dy > 0 ? dy : -dy) / PXL_PER_SEC; // Should be 2 seconds for a full fill
    a.fromValue = [NSValue valueWithCGPoint:slider.position];
    CGPoint newPosition = slider.position;
    newPosition.y -= dy;
    a.toValue = [NSValue valueWithCGPoint:newPosition];
    a.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear];
    [slider addAnimation:a forKey:@"colorize"];

    // Update the actual position
    slider.position = newPosition;
}

And an example of how this is called. Notice this means it can be called mid-animation.

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    UITouch *touch = [touches anyObject];
    CGPoint point = [touch locationInView:self.view];

    [self masksFillTo:point.y];
}

If anyone finds it relevant, this is the creation of the images and masks.

// Instantiate the different bulb images - empty, transparent yellow, and solid yellow. This
// includes setting the frame sizes. This approach found at https://mcmap.net/q/1479219/-ios-slide-up-an-image-over-an-image-revealing-the-underneath-one-a-la-jquery-slide
emptyBulb = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"Light.png"]];
transBulb = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"Light-moving.png"]];
solidBulb = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"Light-on.png"]];

[emptyBulb setFrame:CGRectMake(10, BULB_TOP, 300, BULB_HEIGHT)]; // 298 x 280
[transBulb setFrame:CGRectMake(10, BULB_TOP, 300, BULB_HEIGHT)]; // 298 x 280
[solidBulb setFrame:CGRectMake(10, BULB_TOP, 300, BULB_HEIGHT)]; // 298 x 280

[self.view addSubview:solidBulb]; // Empty on top, then trans, then solid.
[self.view addSubview:transBulb];
[self.view addSubview:emptyBulb];

// Create a mask for the empty layer so it will cover the other layers.
emptyMask = [CALayer layer];
[emptyMask setContentsScale:emptyBulb.layer.contentsScale]; // handle retina scaling
[emptyMask setFrame:emptyBulb.layer.bounds];
[emptyMask setBackgroundColor:[UIColor blackColor].CGColor];
emptyBulb.layer.mask = emptyMask;

// Also create a mask for the transparent image.
transMask = [CALayer layer];
[transMask setContentsScale:transBulb.layer.contentsScale]; // handle retina scaling
[transMask setFrame:transBulb.layer.bounds];
[transMask setBackgroundColor:[UIColor blackColor].CGColor];
transBulb.layer.mask = transMask;
Monadism answered 25/7, 2012 at 12:35 Comment(0)
M
11

Was just led to a solution via this answer. If one looks in the right part of the docs, you'll find the following:

- (id)presentationLayer

Returns a copy of the layer containing all properties as they were at the start of the current transaction, with any active animations applied.

So if I add this code before I first check the transparent mask location (aka solid level), I grab the current animated position and can switch between fill up and fill down.

CALayer *temp;

if (transMask.animationKeys.count > 0) {
    temp = transMask.presentationLayer;
    transMask.position = temp.position;
}

if (emptyMask.animationKeys.count > 0) {
    temp = emptyMask.presentationLayer;
    emptyMask.position = temp.position;
}
Monadism answered 25/7, 2012 at 13:21 Comment(0)
D
2

layer.presentation() makes copy of original layer each time you get it, and you need to override init(layer: Any) for every custom CALayer you have.

Another possible way to start from current state is to set animation's beginTime using current animation's beginTime. It allows to start new animation with offset.

In your case it could be (Swift):

if let currentAnimation = slider.animation(forKey: "colorize") {
    let currentTime = CACurrentMediaTime()
    let animationElapsedTime = currentAnimation.beginTime + currentAnimation.duration - currentTime
    a.beginTime = currentTime - animationElapsedTime
}

However this works great for linear timings, but for others it could be difficult to calculate the proper time.

Durrell answered 27/3, 2019 at 6:48 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.