Animating a CALayer's mask size change
Asked Answered
C

9

30

I have a UIView subclass which uses a CAShapeLayer mask on its CALayer. The mask uses a distinct shape, with three rounded corners and a cut out rectangle in the remaining corner.

When I resize my UIView using a standard animation block, the UIView itself and its CALayer resize just fine. The mask, however, is applied instantly, which leads to some drawing issues.

I've tried animating the mask's resizing using a CABasicAnimation but didn't have any luck getting the resizing animated.

Can I somehow achieve an animated resizing effect on the mask? Do I need to get rid of the mask, or will I have to change something about the way I currently draw the mask (using - (void)drawInContext:(CGContextRef)ctx).

Cheers, Alex

Computation answered 26/5, 2010 at 7:8 Comment(0)
E
45

I found the solution to this problem. Other answers are partially correct and are helpful.

The following points are important to understanding the solution:

  • The mask property is not animatable itself.
  • Since the mask is a CALayer it can be animated on its own.
  • Frame is not animatable, use bounds and position. This may not apply to you(if you weren't trying to animate the frame), but was an issue for me. (See Apple QA 1620)
  • A view layer's mask is not tied to UIView so it will not receive the core animation transaction that is applied to the view's layer.
  • We are modifying the CALayer directly, so we can't expect that UIView will have any idea of what we are trying to do, so the UIView animation won't create the core animation transaction to include changes to our properties.

In order to solve, we are going to have to tap into Core Animation ourselves, and can't rely on the UIView animation block to do the work for us.

Simply create a CATransaction with the same duration that you are using with [UIView animateWithDuration:...]. This will create a separate animation, but if your durations and easing function is the same, it should animate exactly with the other animations in your animation block.

NSTimeInterval duration = 0.5;// match this to the value of the UIView animateWithDuration: call

[CATransaction begin];
[CATransaction setValue:[NSNumber numberWithFloat:duration] forKey:kCATransactionAnimationDuration];

self.myView.layer.mask.position = CGPointMake(newX, 0);
self.myView.layer.mask.bounds = CGRectMake(0, 0, newWidth, newHeight);

[CATransaction commit];
Erminiaerminie answered 3/11, 2012 at 18:17 Comment(1)
@rockyraccoon sorry, this is too old I'm not able to produce a full example. If you have a specific question I might be able to help.Erminiaerminie
G
13

I use a CAShapeLayer to mask a UIView by setting self.layer.mask to that shape layer.

To animate the mask whenever the size of the view changes I overwrote the -setBounds: to animate the mask layer path if the bounds are changed during an animation.

Here's how I implemented it:

- (void)setBounds:(CGRect)bounds
{
    [super setBounds:bounds];
    CAPropertyAnimation *boundsAnimation = (CABasicAnimation *)[self.layer animationForKey:@"bounds"];

    // update the mask
    self.maskLayer.frame = self.layer.bounds;

    // if the bounds change happens within an animation, also animate the mask path
    if (!boundsAnimation) {
        self.maskLayer.path = [self createMaskPath];
    } else {
        // copying the original animation allows us to keep all animation settings
        CABasicAnimation *animation = [boundsAnimation copy];
        animation.keyPath = @"path";

        CGPathRef newPath = [self createMaskPath];
        animation.fromValue = (id)self.maskLayer.path;
        animation.toValue = (__bridge id)newPath;

        self.maskLayer.path = newPath;

        [self.maskLayer addAnimation:animation forKey:@"path"];
    }
}

(For the example self.maskLayer is set to `self.layer.mask)

My -createMaskPath calculates the CGPathRef that I use to mask the view. I also update the mask path in -layoutSubviews.

Gond answered 20/3, 2014 at 18:5 Comment(0)
C
5

The mask property of CALayer is not animatable which explains your lack of luck in that direction.

Does the drawing of your mask depend on the frame/bounds of the mask? (Can you provide some code?) Does the mask have needsDisplayOnBoundsChange property set?

Cheers, Corin

Cadaver answered 1/6, 2010 at 11:29 Comment(1)
I have the same problem here and yes, the mask is fitting the view's/layer's bounds. It is adding rounded corners. The property is set.Enfeeble
U
5

To animate the bounds change of the mask layer of a UIView: subclass UIView, and animate the mask with a CATransaction - similar to Kekodas answer but more general:

@implementation UIMaskView

- (void) layoutSubviews {
    [super layoutSubviews];

    CAAnimation* animation = [self.layer animationForKey:@"bounds"];
    if (animation) {
        [CATransaction begin];
        [CATransaction setAnimationDuration:animation.duration];
    }

    self.layer.mask.bounds = self.layer.bounds;
    if (animation) {
        [CATransaction commit];
    }
}

@end
Underdrawers answered 28/3, 2014 at 11:10 Comment(3)
Key should be bounds.size, not bounds.News
also worth matching the timing functionLim
This worked for me, except (contrary to Kekoa's advice) I had to animate the frame, not the bounds. Still, a great solution.Newsome
S
2

The mask parameter doesn't animate, but you can animate the layer which is set as the mask...

If you animate the CAShapeLayer's Path property, that should animate the mask. I can verify that this works from my own projects. Not sure about using a non-vector mask though. Have you tried animating the contents property of the mask?

Thanks, Jon

Shebashebang answered 28/9, 2010 at 12:23 Comment(2)
Could you give me sample code for that? I've got CAShapeLayer , then I am calling setPath on it, then I am setting to uiview setMask : shapeLayer... and it's visible as calayer now. How to get access to this path and change it? Thanks!Hemidemisemiquaver
I can indeed animate the layer that has the mask but only after removing its delegate which couples it to the UIView. Then I can resize the layer smoothly. However the mask (the rounded corners) will not be updated while resizing. And neither is the view content getting resized.Enfeeble
C
0

I couldn't find any programmatical solution so I just draw an png image with correct shape and alpha values and used that instead. That way I don't need to use a mask...

Cannelloni answered 21/6, 2012 at 7:48 Comment(0)
H
0

It is possible to animate the mask change.

I prefer to use CAShapeLayer as the mask layer. It is very convenient to animate a mask change with the help of property path.

Before animate any change, dump the content of the source into an instance CGImageRef, and create a new layer for animation. Hide the original layer during the animation and show it when animation ends.

The following is a sample code for creating key animation on property path. If you want to create your own path animation, make sure that there are always same number of points in the paths.

- (CALayer*)_mosaicMergeLayer:(CGRect)bounds content:(CGImageRef)content isUp:(BOOL)isUp {

    CALayer* layer = [CALayer layer];
    layer.frame = bounds;
    layer.backgroundColor = [[UIColor clearColor] CGColor];
    layer.contents = (id)content;

    CAShapeLayer* maskLayer = [CAShapeLayer layer];
    maskLayer.fillColor = [[UIColor blackColor] CGColor];
    maskLayer.frame = bounds;
    maskLayer.fillRule = kCAFillRuleEvenOdd;
    maskLayer.path = ( isUp ? [self _maskArrowUp:-bounds.size.height*2] : [self _maskArrowDown:bounds.size.height*2] );
    layer.mask = maskLayer;

    CAKeyframeAnimation* ani = [CAKeyframeAnimation animationWithKeyPath:@"path"];
    ani.removedOnCompletion = YES;
    ani.duration = 0.3f;
    ani.fillMode = kCAFillModeForwards;
    ani.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];

    NSArray* values = ( isUp ?
        [NSArray arrayWithObjects:
        (id)[self _maskArrowUp:0],
        (id)[self _maskArrowUp:-ceilf(bounds.size.height*1.2)],
        nil] 
    :       
        [NSArray arrayWithObjects:
        (id)[self _maskArrowDown:0],
        (id)[self _maskArrowDown:bounds.size.height],
        nil] 
    );
    ani.values = values;
    ani.delegate = self;
    [maskLayer addAnimation:ani forKey:nil];

    return layer;
}

- (void)_startMosaicMergeAni:(BOOL)up {

    CALayer* overlayer = self.aniLayer;
    CGRect bounds = overlayer.bounds;
    self.firstHalfAni = NO;

    CALayer* frontLayer = nil;
    frontLayer = [self _mosaicMergeLayer:bounds 
                                content:self.toViewSnapshot 
                                isUp:up];
    overlayer.contents = (id)self.fromViewSnapshot;
    [overlayer addSublayer:frontLayer];
}
Heaviside answered 3/1, 2013 at 12:13 Comment(0)
A
0

Swift 3+ answer based on Kekoa's solution:

let duration = 0.15 //match this to the value of the UIView.animate(withDuration:) call

CATransaction.begin()
CATransaction.setValue(duration, forKey: kCATransactionAnimationDuration)

myView.layer.mask.position = CGPoint(x: [new X], y: [new Y]) //just an example

CATransaction.commit()
Ammons answered 11/4, 2019 at 15:9 Comment(0)
S
0

Swift implementation of @stigi answer, my mask layer is called shape Layer

override var bounds: CGRect {
    didSet {
        // debugPrint(self.layer.animationKeys()) //Very useful for know if animation is happening and key name
        let propertyAnimation = self.layer.animation(forKey: "bounds.size")

        self.shapeLayer?.frame = self.layer.bounds

        // if the bounds change happens within an animation, also animate the mask path
        if let boundAnimation = propertyAnimation as? CABasicAnimation {
            // copying the original animation allows us to keep all animation settings
            if let basicAnimation = boundAnimation.copy() as? CABasicAnimation {
                basicAnimation.keyPath = "path"

                let newPath = UIBezierPathUtils.customShapePath(rect: self.layer.bounds, cornersTriangleSize: cornerTriangleSize).cgPath
                basicAnimation.fromValue = self.shapeLayer?.path
                basicAnimation.toValue = newPath

                self.shapeLayer?.path = newPath
                self.shapeLayer?.add(basicAnimation, forKey: "path")
            }
        } else {
            self.shapeLayer?.path = UIBezierPathUtils.customShapePath(rect: self.layer.bounds, cornersTriangleSize: cornerTriangleSize).cgPath
        }
    }
}
Slemmer answered 1/12, 2021 at 5:29 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.