Skip to content

Instantly share code, notes, and snippets.

@nicklockwood
Last active January 12, 2024 06:15
Show Gist options
  • Save nicklockwood/d374033b27c62662ac8d to your computer and use it in GitHub Desktop.
Save nicklockwood/d374033b27c62662ac8d to your computer and use it in GitHub Desktop.
This article was originally written for objc.io issue 12, but didn't make the cut. It was intended to be read in the context of the other articles, so if you aren't familiar with concepts such as CALayer property animations and the role of actionForKey:, read the articles in that issue first.

Hacking UIView animation blocks for fun and profit

In this article, I'm going to explore a way that we can create views that implement custom Core Animation property animations in a natural way.

As we know, layers in iOS come in two flavours: Backing layers and hosted layers. The only difference between them is that the view acts as the layer delegate for its backing layer, but not for any hosted sublayers.

In order to implement the UIView transactional animation blocks, UIView disables all animations by default and then re-enables them individually as required. It does this using the actionForLayer:forKey: method.

Somewhat strangely, UIView doesn't enable animations for every property that CALayer does by default. A notable example is the layer.contents property, which is animatable by default for a hosted layer, but cannot be animated using a UIView animation block.

The ability to animate layer contents is incredibly useful in practice. It means that you can do things like crossfade between two images in a UIImageView, or between two different text strings in a label. So let's enable that feature.

In the code below, we create a UILabel subclass called FancyLabel, and override actionForLayer:forKey: so that it returns [CATransition animation] for the contents key instead of [NSNull null] (which is what the method returns normally):

@interface FancyLabel : UILabel

@end

@implementation FancyLabel

- (id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)key
{
    if ([key isEqualToString:@"contents"])
    {
        return [CATransition animation];
    }
    return [super actionForLayer:layer forKey:key];
}

@end

We'll rig up a simple view controller to test our label. Here is the code (the label and UITextField have been added in the Storyboard):

@interface ViewController () <UITextFieldDelegate>

@property (nonatomic, strong) IBOutlet FancyLabel *label;

@end

@implementation ViewController

- (BOOL)textFieldShouldReturn:(UITextField *)textField
{
    [textField resignFirstResponder];
    return NO;
}

- (void)textFieldDidEndEditing:(UITextField *)textField
{
    self.label.text = textField.text;
}

@end

If you set the text property of the label, instead of updating immediately, it will now crossfade from the previous text to the new text. You might wonder why we've overridden the action for the "contents" key instead of "text"?

The key passed to the actionForLayer:forKey: method relates to the property of the underlying layer that is being modified, not the original property of the view that caused that modification to happen. When you set the text of a UILabel, it causes the contents of the layer to be redrawn. This is not always the case for all properties of all views; it depends on the type of view and the specific property.

On iOS, the view is composed of a hierarchy of layers, each of which are drawn to the screen using hardware accelerated OpenGL drawing. Because some layers contain graphics that cannot be drawn using OpenGL, layers have an optional backing image, which can be drawn using the slower-but-more-flexible Quartz graphics APIs and then rendered as a texture by OpenGL.

The contents property represents the backing CGImage of the layer. Most views do not actually have a backing image, as their contents can be drawn directly using OpenGL, but text drawing cannot currently be handled using OpenGL, and must be drawn into an image first.

When we set the text property of a UILabel, it draws the new text into an image and sets that image as the layer contents. At that point, the actionForLayer:forKey: method is called, and we return our overridden action.

The action we are returning is a CAAnimation subclass of type CATransition. CATransition is a special type of animation that affects the entire layer instead of just one property. By default, CATransition uses a crossfade effect, but if we wanted to, we could use one of several other transition types. For example, the following creates a sort of flipboard effect, where the old text scrolls up to reveal the new text underneath whenever it is changed:

- (id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)key
{
    if ([key isEqualToString:@"contents"])
    {
        CATransition *transition = [CATransition animation];
        transition.type = kCATransitionPush;
        transition.subtype = kCATransitionFromTop;
        return transition;
    }
    return [super actionForLayer:layer forKey:key];
}

OK, so this is neat, but it's not a very good iOS citizen as view subclasses go. We don't expect layer properties to animate whenever we set them unless we are currently inside a UIView animation block. What we ideally want to do is only animate our contents when inside an animation block, i.e. when other view properties would normally animate. How can we do that?

First, we need to tie our transition to a property that Core Animation knows how to animate. The contents key is a bit of a special case, so we need to use something else for this trick. Fortunately, Core Animation has a neat feature whereby we can simply use KVC (Key Value Coding) to set arbitrary properties on the layer. That means we can dynamically add new animatable properties at runtime without subclassing the layer itself. If we override the setText: method of our view to also set a "text" key on our layer, we can then observe that in our actionForLayer:forKey: method, as follows:

@implementation FancyLabel

- (void)setText:(NSString *)text
{
    //actually update the text
    [super setText:text];
    
    //trigger our transition animation
    [self.layer setValue:text forKey:@"text"];
}

- (id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)key
{
    if ([key isEqualToString:@"text"])
    {
        CATransition *transition = [CATransition animation];
        transition.type = kCATransitionPush;
        transition.subtype = kCATransitionFromTop;
        return transition;
    }
    return [super actionForLayer:layer forKey:key];
}

@end

The UIView animation mechanism implementation is private, so there's no simple flag we can check to see if the view is currently animating, however we do know one thing: When animating, UIView's actionForLayer:forKey: will return valid CAActions for its animatable property keys, and when not animating it will return [NSNull null] for them. If we simply pick a suitable key we can interrogate UIView to see if it's currently supplying an action for that key, and use that to determine our response for our custom key. We'll use the key "backgroundColor" since that's a property of UIView that normally supports animation, and that we know returns a CABasicAnimation as its action (as of iOS 8, some properties, such as "bounds" return a private class instead, so watch out):

- (id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)key
{
    if ([key isEqualToString:@"text"])
    {
        if ([super actionForLayer:layer forKey:@"backgroundColor"] != [NSNull null])
        {
            CATransition *transition = [CATransition animation];
            transition.type = kCATransitionPush;
            transition.subtype = kCATransitionFromTop;
            return transition;
        }
    }
    return [super actionForLayer:layer forKey:key];
}

Setting the label text directly will no longer animate, but if we set it inside a UIView animation block it will still animate as before:

- (void)textFieldDidEndEditing:(UITextField *)textField
{
    [UIView animateWithDuration:1.0 animations:^{
        self.label.text = textField.text;
    }];
}

That works, but although we've set the duration of our animation to one second, the transition is actually taking place within 0.25 seconds. The problem is that we're detecting the fact that we're inside a UIView animation block, but not taking into account its properties. Fortunately we can obtain those values from the backgroundColor action and transfer them to our transition, as follows:

- (id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)key
{
    if ([key isEqualToString:@"text"])
    {
        CAAnimation *action = (CAAnimation *)[self actionForLayer:layer forKey:@"backgroundColor"];
        if (action != (CAAnimation *)[NSNull null])
        {
            CATransition *transition = [CATransition animation];
            transition.type = kCATransitionPush;
            transition.subtype = kCATransitionFromTop;
            
            //CAMediatiming attributes
            transition.beginTime = action.beginTime;
            transition.duration = action.duration;
            transition.speed = action.speed;
            transition.timeOffset = action.timeOffset;
            transition.repeatCount = action.repeatCount;
            transition.repeatDuration = action.repeatDuration;
            transition.autoreverses = action.autoreverses;
            transition.fillMode = action.fillMode;
            
            //CAAnimation attributes
            transition.timingFunction = action.timingFunction;
            transition.delegate = action.delegate;
            
            return transition;
        }
    }
    return [super actionForLayer:layer forKey:key];
}

Success! Our transition now respects the duration, timing function, etc. of our UIView animation block. It will also call the completion block if specified. One small caveat is that if we use a delay argument for our animation block it won't work because the text will still be updated immediately. To fix that we would need to reimplement the UILabel text drawing ourselves (which is possible, but out of scope for this tutorial).

So there you have it, you now have the means to tie your custom CALayer animations into the standard UIView animation mechanism, without swizzling or calling private APIs.

@natanrolnik
Copy link

Nick, thanks for sharing this article. It's worth mentioning that it won't work for attributedText, unless the code below is added as well:

- (void)setAttributedText:(NSAttributedString *)attributedText
{
    //actually update the text
    [super setAttributedText:attributedText];

    //trigger our transition animation
    [self.layer setValue:attributedText forKey:@"attributedText"];
}

And then, in the actionForLayer:forKey: override, the condition should be:

if ([key isEqualToString:@"text"] || [key isEqualToString:@"attributedText"])

Copy link

ghost commented Feb 20, 2015

Excellent write up. It's really annoying that UIView doesn't allow implicit animation of all animatable CALayer properties, even within an animation block. But this is a nice way to work around the limitations of UIKit.

@vlas-voloshin
Copy link

Thanks for the information! I ended up here after coming up with basically the same trick to animate border color on a custom view. One thing I'd really like to point out for anyone planning to do the same is that in some cases this line becomes really important:

transition.delegate = action.delegate;

I spent a couple of hours just trying to debug what was wrong in my code when an activity controller presented on top of a view with that custom animation trickery didn't respond to touches. UIKit probably sets up some state affecting user interaction for animations when it returns an action for layer, and the delegate becomes crucial to consistently update this state when animation finishes. When I transferred the delegate on my own "substitute" animation, everything began working correctly.

@rounak
Copy link

rounak commented Apr 18, 2015

Thanks for posting this. I ran into some issues where even a basic animation (fillColor) wasn't respecting the delay parameter, so I ended up wrapping the animation call in a dispatch_after block.

@darrarski
Copy link

Nick, thanks a lot for the article. I made simple circular progress view component basing on your hints. If someone would like to see an example of custom, animated UIView properties implementation, it can be found here: https://github.com/darrarski/DRCircularProgress-iOS

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment