Animatable text color of UILabel

Every time I talk with an android developer about the strong and weak points of iOS development, I always have the CoreAnimation ace up my sleeve. Core animation is great. Not only that it’s so powerful and efficient, it’s also one of the most elegant frameworks I got the chance to work with. In 95% of the cases you can just write

 [UIView animateWithBlock:..] 

and pass a block with the final values of each property we want to animate.

Being the lazy developer that I am, the UIView animation block is a dream come true. This is why I get so annoyed when I encounter a non animatable property, like the text color property of UILabel. That’s right – UILabel, our main component for showing text on the screen, can’t animate all of its text related properties, like textColor, text, font, etc.

If we want to animate textColor, we have two simple but ugly options, and one complicated but delicious solution. Lets talk about the uglies first.

Using CATextLayer

CALayers are very important classes when we want to perform non trivial animation and visualization.
CALayers are basically classes representing a rectangle on the screen with visual content. Mostly we will use UIViews and not CALayers directly, but each UIView contains a root layer that it draws to. We can access this layer by calling

CALayer *layer = myView.layer;

CALayers are really great. They contain lots of properties that we can set to affect the visual appearance of the view such cornerRadius, shadow, borders and much more. But the really cool thing about CALayers is that most of their properties are animated. This means that if we change a CALayers property inside a UIView animation block, it will be animated automatically.
Well, this sounds exactly what we need! Unfortunately, CALayer does not have a textColor property.

CALayer have some very handy subclasses CAGradientLayer, CATextLayer, CAShapeLayer and more. The subclass that allows us to draw text is CATextLayer.
CATextLayer provides us the ability to render and layout text from plain or attributes strings. It’s a very powerful class, but all we need from it right now is the “foregroundColor” property.
If we look at the documentation of CATextLayer for foregroundColor we see:

/* The color object used to draw the text. Defaults to opaque white.
 * Only used when the `string' property is not an NSAttributedString.
 * Animatable (Mac OS X 10.6 and later.) */

@property CGColorRef foregroundColor;

Yes! Animatable! Exactly what we need.

To use it, all we have to do is create a subview of UIView and add the CALayer to its sublayers:

CATextLayer *textLayer = [CATextLayer layer];
[textLayer setString:@"My string"];
[textLayer setForegroundColor:initialColor;
[textLayer setFrame:self.bounds];
[[self.view layer] addSublayer:textLayer];

That’s it. Now to animate the textColor we just need to use a regular animation block

[UIView animateWithDuration:0.5 animations:^{
     textLayer.foregroundColor = finalColor;
   }];

This is a pretty simple solution, and it works. The major downside is that we can’t use our regular UILabels. This can be very annoying since we won’t be able to use Interface builder like we used to, and we have to work with a CATextLayer every time we want to animate a text. Simple, but far from seamless, which makes it pretty useless to a lazy developer like me.

The second stupid solution I promised you is:

animation chaining

This is a pretty lame solution, but it was marked as the correct answer in one of the questions on this topic on stack overflow, so I’ll show it here anyway.

If we don’t want to start messing with layers, and we just want to use a plain old UILabel, we could animate the text color of the UILabel using a simple trick.
When we want to animate text color it’s usually very subtle, and unlike most animations, we don’t need for the text color to go through all the colors between the initial and the final color. Usually going through 1-2 colors gives us a sufficient animation.
So we could animate the text color using a simple animation chaining, where in each step we will calculate the next color in the chain (not as trivial as it sounds – color algorithm is pretty complicated), change the textColor, and delay the next stage for a fraction of the total animation period.

This is a much less simple than using a CATextLayer, and the animation looks much worse. But, at least it doesn’t use CALayers..

After playing a little with both solutions, I decided to use a third solution:

Subclassing UILabel

Both of the above solutions are insufficient because each of them gives us only half of the solution:

  • Using CATextLayer gives us exactly the UI appearance we desire, but we can’t use it in the normal way we use UILabel and UIView animations
  • Using an animation chaining lets us use a regular UILabel, but just doesn’t give us the smooth animation we want.

The solution should be something that gives us both benefits.

The key to achieving this is to understand that the reason UILabel doesn’t allow us to animate its text color is that the default layer of UILabel is CALayer and not CATextLayer as we would expect.

So all we need to do is subclass UILabel and make it use a CATextLayer instead of the regular CALayer.
Unfortunately, this is not as easy as it sounds. CATextLayer implements a lot of the properties that UILabel implements, but a little differently. For example, instead of a text property, CATextLayer uses a string property. Instead of textColor property, CATextLayer uses foregroundColor property, etc.
This means that in order to properly use CATextLayer inside a UILabel, we have to override most of the UILabel’s methods.

It’s ALOT of work, but after you finish, you can use the component like a regular UILabel, and get all of its properties animatable for free!

Let’s go over the solution step by step, starting with the header file


// AUIAnimatableLabel.h //

@class CATextLayer;

@interface AUIAnimatableLabel : UILabel
{
    CATextLayer *textLayer;
}

@property (readonly) CATextLayer *textLayer;

@end

The first important thing to notice here is that we are subclassing UILabel. This means that we will be able to use this component like a regular UILabel, just drag a UILabel to Interface builder, and change its class to be AUIAnimatableLabel.

The second thing to understand is that we are exposing a property of our CATextLayer. We will use this layer instead of the basic CALayer that UILabel uses, and exposing it will allow us to access its properties if we’ll need to.

Now let’s go over to the implementation


// AUIAnimatableLabel.m //

-(id) initWithCoder:(NSCoder *)aDecoder
{
    self = [super initWithCoder:aDecoder];
    if (self)
    {
        [self _initializeTextLayer];
    }
    return self;
}

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

-(void) _initializeTextLayer
{
    textLayer = [[CATextLayer alloc] init];
    [textLayer setFrame:self.bounds];
    textLayer.contentsScale = [[UIScreen mainScreen] scale];
    textLayer.rasterizationScale = [[UIScreen mainScreen] scale];

    // Initialize the defaults.
    self.textColor = [super textColor];
    self.font = [super font];
    self.backgroundColor = [super backgroundColor];
    self.text = [super text];
    self.textAlignment = [super textAlignment];

    [super setText:nil];

    [self.layer addSublayer:textLayer];
}

We override both initWithCoder and initWithFrame so that we could use the component both by creating it programmatically and from interface builder. Note that we don’t need to override init, because it simply calls initWithFrame.

The _initializeTextLayer method is pretty simple; we initialize a CATextLayer and add it as a subLayer. Then we set the default values by simply calling our custom setter methods with the default values of UILabel. If we initialized the UILabel from interface builder, by this time, the UILabel will hold the values we have set in interface builder.
The most important line here is

[super setText:nil];

. Since we are not using UILabel’s regular layer at all – we have to set it’s text to nil so that it won’t get in our way.

After we finished with the initialization, we have to start override UILabel’s methods. Most of them are pretty easy:

-(UIColor *)textColor
{
    return [UIColor colorWithCGColor:textLayer.foregroundColor];
}

-(void) setTextColor:(UIColor *)textColor
{
    textLayer.foregroundColor = textColor.CGColor;
    [self setNeedsDisplay];
}

We only have to find out the corresponding property in CATextLayer and use it instead of the UILabel property.
Make sure you remember to call [self SetNeedsDisplay] after every property change to make sure that the layer will be drawn.

After we override the basic methods (textColor, text, shadowOffset, etc.) we still have two major UILabel behaviors we have to implement:

  1. UILabel support the “adjustsFontSizeToFitWidth” property, and our textLayer still does not.
  2. UILabel’s vertical orientation is to align center, and our textLayer is aligned to the top.

We will handle both problems in layoutSubviews

layoutSubviews is called every time the view changes in such a way that it’s subViews may need to change layout. This is the perfect point to handle both problems. Let see how they’re solved:

adjustsFontSizeToFitWidth

UILabel know how to change the size of the font so that it fits the bounds of the label. It also knows to set a minimum font size, and only resize the text down to this size. This sounds like a lot of work, but fortunately, there’s a very useful method of NSString called sizeWithFont that we can use. sizeWithFont is an amazing method that calculates for us the minimum rect size a specific string need when using a font and a lineBreakMode. It’s very convenient and you will probably use it every now and then. However this is not exactly what we need. We don’t need the size of the rect, we need the minimum font size to use. Dont worry; this shiny method has the following overload:

[textLayer.string sizeWithFont:<#(UIFont *)#> minFontSize:<#(CGFloat)#> actualFontSize:<#(CGFloat *)#> forWidth:<#(CGFloat)#> lineBreakMode:<#(UILineBreakMode)#>]

If we look closely, theres a parameter called actualFontSize that is of type CGFloat *. This parameter is an OUT parameter that will hold the minimum font needed to display the string with the new size. Win! :-)

Once we know which method to use, the code that uses it is very simple:

    if (self.adjustsFontSizeToFitWidth)
    {
        // Calculate the new font size:
        CGFloat newFontSize;
        [textLayer.string sizeWithFont:self.font minFontSize:self.minimumFontSize actualFontSize:&newFontSize forWidth:self.bounds.size.width lineBreakMode:self.lineBreakMode];
        self.font = [UIFont fontWithName:self.font.fontName size:newFontSize];
    }

Vertical alignment

The next problem with the implementation is that the default behavior of CATextLayer is to align the text to the top, and the UILabel default behavior is to align the text to the center. To solve this we will use the sizeWithFont method again, only this time we will actually use the size it provides. After we have the minimum size required to draw the text with the current font, we will just resize the height of textLayer and place it in the middle of the view. Remember that the textLayer is the only layer that is shown on the screen, so this method will make it look like the text is aligned to the center of the view.

By the way, most of the experienced iOS developers already faced the problem of the vertical alignment of UILabel. Some times you just want it to be top aligned and there’s no easy way to do it, so you have to implement it your own. So I decided that if I’m already messing with it – why not add a vertical alignment property.
Adding the property is pretty straight forward – in the above solution after changing the height of the textLayer, instead of placing it in the middle of the view, just place it according to the vertical alignement.

The complete code to handle vertical alignment is:


// Resize the text so that the text will be vertically aligned according to the set alignment
    CGSize stringSize = [self.text sizeWithFont:self.font 
                              constrainedToSize:self.bounds.size 
                                  lineBreakMode:self.lineBreakMode];
    
    CGRect newLayerFrame = self.layer.bounds;
    newLayerFrame.size.height = stringSize.height;
    switch (self.verticalTextAlignment) {
        case AUITextVerticalAlignmentCenter:
                newLayerFrame.origin.y = (self.bounds.size.height - stringSize.height) / 2;        
            break;
        case AUITextVerticalAlignmentTop:
            newLayerFrame.origin.y = 0;
            break;
        case AUITextVerticalAlignmentBottom:
            newLayerFrame.origin.y = (self.bounds.size.height - stringSize.height);
            break;
        default:
            break;
    }
    textLayer.frame = newLayerFrame;

That’s it folks. There are a lot more interesting methods in this class that you can look at in the code, but this is the general idea.

The class is a drop in replacement for UILabel,  and you can find it here.
Just add the class to your project and start using it. It’s that simple.

Note that it’s still not 100% completed. It still doesn’t support numberOfLines, and also I haven’t played with it enough so it may be buggy. I hope to finish it in the following weeks, so in the meantime use it with caution.

I will be very happy to hear you comments and improvement suggestions for this class.

About these ads

5 Responses to Animatable text color of UILabel

  1. Just tried AUIAnimatedText from GitHub. Very nice.

    However, in both that code and the code in this post, this code isn’t doing what you think it is:

    [UIView animateWithDuration:0.5 animations:^{...}];

    The CALayer isn’t affected by the UIView animation duration. You can change the animation duration or remove this completely and you’ll still see the animations, since layers have a default of about 0.3 seconds.

    Instead, you need to either use a CABasicAnimation, or, even easier, wrap up the changes in a CATransaction:

    [CATransaction begin];
    [CATransaction setValue:[NSNumber numberWithFloat:1.0f]
    forKey:kCATransactionAnimationDuration];
    self.animatableLabel.text = newText;
    [CATransaction commit];

    Other than that, the code is working great for me. I’ll probably add an animationDuration property to the label, so hide all of the CATransaction code in the AUIAnimatableLabel class.

    Thanks!

  2. @DaveBatton: using CATransaction works fine indeed, thanks for the tip.
    That said, I need to chain animations (basically animate the text color from green to yellow, then from yellow to red). CATransaction doesn’t seem to be an option for me then, so I’ve started trying with CABasicAnimation and groups but that doesn’t seem to work.

    Any idea why?

    Cheers,
    Gilles

  3. Actually, I’ve found the answer to my question and it’s obvious yet tricky at the same time:
    In order to use CABasicAnimation, you need to address directly the CATextLayer within AUIAnimatedLabel.

    So for example:

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

    [anim setFromValue:(id)[[UIColor redColor] CGColor]];
    [anim setToValue:(id)[[UIColor greenColor] CGColor]];
    anim.fillMode = kCAFillModeForwards;
    anim.duration = 10.0;
    anim.removedOnCompletion = NO;

    [self.myAUIAnimatedLabel.textLayer addAnimation:anim forKey:nil];

    What I want to animate here is myAUIAnimatedLabel.textColor but in order to do that, I need to animate myAUIAnimatedLabel.textLayer’s foregroundColor property.

    Which somehow defeats a bit the purpose…

  4. Leo BH says:

    Just a heads up, CATextLayer uses rather ugly kerning that doesn’t match that of UILabel / UIButton. Seems that if you really want a drop-in replacement for either of the latter, you’ll have to use an NSAttributedString, and fiddle about with the kerning settings there to reproduce the look of the UIKit widgets.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

Follow

Get every new post delivered to your Inbox.

%d bloggers like this: