As promised in another post, here are some recent findings on achieving stylish text rendering on iOS: If you’re anything like me, you’re doing design mockups in Adobe Photoshop first, put different elements into PNGs and then code the UI behavior and animations in XCode. This works great up to the point where you hit one of two things:
- You want to localize your app to various languages and have to render PNGs for each language
- You have dynamic textual content, e.g. player names, high scores, …
The problem is that in order to get high quality typography, one usually needs to add either a blurred drop shadow, a gradient or an outer glow. Since this is just a ticking of a checkbox in Photoshop’s layer style pallet, we all have become so accustomed to seeing this that plain solid color text just doesn’t do anymore.
While there are a number of tutorials and source code examples on the web for this, I’ve found that in my case, they lacked something in one area or the other. Hence this post.
While there are a number of tutorials and source code examples on the web for this, I’ve found that in my case, they lacked something in one area or the other. Hence this post.
Gradients
Let’s start with gradients: After some research, I stumbled upon Dimitris Doukas Blog http://dev.doukasd.com/2011/05/stylelabel-a-uilabel-with-style/ . He creates a gradient image and then set’s the UILabel’s textcolor property to a pattern brush using UIColor::colorWithPatternImage: . There were two problems that his code did not handle that were quite important for me:
- He does it for a UILabel but I needed it to work for a UITextField as well
- His code does not handle a UILabel that has multiple lines of text
- It did not work well for me when the frame is much larger than the text contained in it.
The second is quite easy to fix by analyzing how many lines the text will be split to and creating appropriate gradient image. The only tricky bit is to make sure the first line of pixels from a text line does not spill into the previous line. In my first approach, I had a blue to red gradient and the second text line sure enough started with a thin line of bright red. The frame issue can also be addressed by modifying his gradient creation routine a bit, no biggy.
Adapting his code for UITextField was rather straight forward except for – of course – the usual unexpected problems. Chief among which was that upon setting a pattern image color, the UITextField would not show any text while in editing mode. The only solution I found for this this far is to implement the UITextFieldDelegate::textFieldShouldBeginEditing: method and temporarily set the textColor back to a non-image-based color. I would love to have this handled inside my ExtendedUITextField class as well, but using key-value-observing did not seem to work.
One trivial optimization of Dimitris code was to create the gradient with a width of 1 pixel. Since the pattern color brush repeats the texture anyway, it should reduce the memory footprint and be faster to generate although I didn’t do any profiling on that. It just seemed to make sense.
Drop Shadows
Drop shadow’s were also an issue when going from UILabel to UITextField. There are two main approaches to doing drop shadows in iOS:
- CoreGraphics: Overwrite drawTextInRect and use the CGContextSetShadowWithColor.
- CoreAnimation: Use CALayer::shadowOpacity and the various other shadow properties on CALayer and have CoreAnimation render the shadow for you.
Again, it turns out that UITextField is a bit tricky. I wanted to use CoreGraphics as this gives you the best performance but on UITextField, the drop shadow ended up being cropped at the bottom all the time. So I currently use CoreAnimation for my ExtendedUITextField and CoreGraphics for my ExtendedUILabel. At first I – for the sake of consistency – tried to use CoreAnimation for both labels and text fields, but when animating the various elements in my UI, performance was just too bad.
On a side note, I found the shadow stuff to nice to use as an outer glow replacement when I don’t need a drop shadow. For example, the score board in Streetsoccer uses subtle gradients and outer glows which are hardly noticeable but if you see the before and after, it makes a huge difference.
Summary
I wish I had done this research sooner. Everything looks much more professional now. The only problem I have is that my labels currently use Photoshop’s Trajan Pro font and that one is a) not available on iOS and licensing fees for embedding fonts are in general quite outrageous and b) I need full unicode support for the text fields while Trajan Pro only has like the ASCII characters. I almost see myself buying a font creator tool and doing my own custom true type font…
– Alex
Source Code
For completeness sake, here is the code as I currently use it. It is far from perfect, so if you decide to use it, do so at your own risk. I posted it just for educational purposes.
ExtendedLabel.h
#import <UIKit/UIKit.h>
@interface ExtendedLabel : UILabel
{
NSArray *gradientColors;
UIColor *strokeColor;
CGFloat shadowBlur;
CGSize shadowOffset;
UIColor * shadowColor;
BOOL isGradientValid;
}
@property (retain) NSArray *gradientColors;
@property (retain) UIColor *strokeColor;
- (void)setShadowWithColor:(UIColor *)color Offset:(CGSize)offset Radius:(CGFloat)radius;
@end
ExtendedLabel.m
#import "ExtendedLabel.h"
#import <QuartzCore/QuartzCore.h>
@implementation ExtendedLabel
@synthesize gradientColors, strokeColor;
- (void)dealloc
{
[gradientColors release];
[strokeColor release];
[super dealloc];
}
- (void)resetGradient
{
if (CGRectEqualToRect(self.frame, CGRectZero))
{
return;
}
if ( [self.gradientColors count] == 0 )
{
self.textColor = [UIColor blackColor];
return;
}
if ( [self.text length] == 0 )
{
return;
}
UIGraphicsBeginImageContext(CGSizeMake(1, self.frame.size.height));
CGContextRef context = UIGraphicsGetCurrentContext();
UIGraphicsPushContext(context);
int const colorStops = [self.gradientColors count];
CGSize lineSize = [self.text sizeWithFont:self.font];
CGSize textSize = [self.text sizeWithFont:self.font constrainedToSize:self.bounds.size lineBreakMode:self.lineBreakMode];
CGFloat topOffset = (self.bounds.size.height - textSize.height) / 2.0f;
CGFloat lines = textSize.height / lineSize.height;
size_t num_locations = colorStops * lines + 2;
CGFloat locations[num_locations];
CGFloat components[num_locations * 4];
locations[0] = 0.0f;
[[gradientColors objectAtIndex:0] getRed:&(components[0]) green:&(components[1]) blue:&(components[2]) alpha:&(components[3])];
locations[num_locations - 1] = 1.0f;
[[gradientColors lastObject] getRed:&(components[(num_locations-1) * 4]) green:&(components[(num_locations-1) * 4 + 1]) blue:&(components[(num_locations-1) * 4 + 2]) alpha:&(components[(num_locations-1) * 4 + 3])];
for ( int l = 0; l < lines; ++l )
{
for ( int i = 0; i < colorStops; ++i )
{
int index = 1 + l * colorStops + i;
locations[index] = ( topOffset + l * lineSize.height + lineSize.height * (CGFloat)i / (CGFloat)(colorStops - 1) ) / self.frame.size.height;
UIColor *color = [gradientColors objectAtIndex:i];
[color getRed:&(components[4*index+0]) green:&(components[4*index+1]) blue:&(components[4*index+2]) alpha:&(components[4*index+3])];
}
// Add a little bit to the first stop so that it won't render into the last line of pixels at the previous line of text.
locations[1 + l * colorStops] += 0.01f;
}
CGColorSpaceRef rgbColorspace = CGColorSpaceCreateDeviceRGB();
CGGradientRef gradient = CGGradientCreateWithColorComponents(rgbColorspace, components, locations, num_locations);
CGPoint topCenter = CGPointMake(0, 0);
CGPoint bottomCenter = CGPointMake(0, self.frame.size.height);
CGContextDrawLinearGradient(context, gradient, topCenter, bottomCenter, 0);
CGGradientRelease(gradient);
CGColorSpaceRelease(rgbColorspace);
UIGraphicsPopContext();
self.textColor = [UIColor colorWithPatternImage:UIGraphicsGetImageFromCurrentImageContext()];
UIGraphicsEndImageContext();
}
- (void)setShadowWithColor:(UIColor *)color Offset:(CGSize)offset Radius:(CGFloat)radius
{
shadowOffset = offset;
shadowBlur = radius;
[color retain];
[shadowColor release];
shadowColor = color;
[self setNeedsDisplay];
}
- (void)setText:(NSString *)text
{
[super setText:text];
isGradientValid = NO;
}
- (void)setFont:(UIFont *)font
{
[super setFont:font];
isGradientValid = NO;
}
- (void)setFrame:(CGRect)aFrame
{
[super setFrame:aFrame];
isGradientValid = NO;
}
- (CGRect)textRectForBounds:(CGRect)rect
{
return CGRectMake(rect.origin.x + MAX(0, shadowBlur - shadowOffset.width), rect.origin.y + MAX(0, shadowBlur - shadowOffset.height), rect.size.width - ABS(shadowOffset.width) - shadowBlur, rect.size.height - ABS(shadowOffset.height) - shadowBlur);
}
- (void)drawTextInRect:(CGRect)rect
{
if ( isGradientValid == NO )
{
isGradientValid = YES;
[self resetGradient];
}
CGContextRef context = UIGraphicsGetCurrentContext();
//draw stroke
if (self.strokeColor != nil)
{
CGContextSetStrokeColorWithColor(context, strokeColor.CGColor);
CGContextSetTextDrawingMode(context, kCGTextFillStroke);
}
// Note: Setting shadow on the context is much faster than setting shadow on the CALayer.
if ( shadowColor != nil )
{
// We take the radius times two to have the same result as settings the CALayers shadow radius.
// CALayer seems to take a true radius where CGContext seems to take amount of pixels (so 2 would
// be one pixel in each direction or something like that).
CGContextSetShadowWithColor(context, shadowOffset, shadowBlur * 2.0f, [shadowColor CGColor]);
}
[super drawTextInRect:rect];
}
@end
ExtendedTextField.h
#import <UIKit/UIKit.h>
@interface ExtendedTextField : UITextField
{
NSArray *gradientColors;
UIColor * placeholderColor;
UIColor *strokeColor;
CGFloat shadowBlur;
CGSize shadowOffset;
BOOL isGradientValid;
}
@property (retain) NSArray *gradientColors;
@property (retain) UIColor *placeholderColor;
@property (retain) UIColor *strokeColor;
- (void)setShadowWithColor:(UIColor *)color Offset:(CGSize)offset Radius:(CGFloat)radius;
@end
ExtendedTextField.m
#import <QuartzCore/QuartzCore.h>
#import "ExtendedTextField.h"
@implementation ExtendedTextField
@synthesize gradientColors, placeholderColor, strokeColor;
- (void)dealloc
{
[gradientColors release];
[strokeColor release];
[placeholderColor release];
[super dealloc];
}
- (void)resetGradient
{
if (CGRectEqualToRect(self.frame, CGRectZero))
{
return;
}
// create a new bitmap image context
UIGraphicsBeginImageContext(self.frame.size);
// get context
CGContextRef context = UIGraphicsGetCurrentContext();
// push context to make it current (need to do this manually because we are not drawing in a UIView)
UIGraphicsPushContext(context);
//draw gradient
CGGradientRef gradient;
CGColorSpaceRef rgbColorspace;
CGSize textSize;
if ( [self.text length] != 0 )
{
textSize = [self.text sizeWithFont:self.font];
}
else
{
textSize = [self.placeholder sizeWithFont:self.font];
}
if ( textSize.height == 0.0f )
{
return;
}
//set uniform distribution of color locations
size_t num_locations = [gradientColors count];
CGFloat locations[num_locations];
for (int k=0; k<num_locations; k++) {
locations[k] = textSize.height / self.frame.size.height * (CGFloat)k / (CGFloat)(num_locations - 1); //we need the locations to start at 0.0 and end at 1.0, equaly filling the domain
}
//create c array from color array
CGFloat components[num_locations * 4];
for (int i=0; i<num_locations; i++) {
UIColor *color = [gradientColors objectAtIndex:i];
[color getRed:&(components[4*i+0]) green:&(components[4*i+1]) blue:&(components[4*i+2]) alpha:&(components[4*i+3])];
}
rgbColorspace = CGColorSpaceCreateDeviceRGB();
gradient = CGGradientCreateWithColorComponents(rgbColorspace, components, locations, num_locations);
CGPoint topCenter = CGPointMake(0, 0);
CGPoint bottomCenter = CGPointMake(0, self.frame.size.height);
CGContextDrawLinearGradient(context, gradient, topCenter, bottomCenter, 0);
CGGradientRelease(gradient);
CGColorSpaceRelease(rgbColorspace);
// pop context
UIGraphicsPopContext();
// get a UIImage from the image context
UIImage *gradientImage = UIGraphicsGetImageFromCurrentImageContext();
// clean up drawing environment
UIGraphicsEndImageContext();
self.textColor = [UIColor colorWithPatternImage:gradientImage];
}
- (void)setShadowWithColor:(UIColor *)color Offset:(CGSize)offset Radius:(CGFloat)radius
{
shadowOffset = offset;
self.layer.shadowOpacity = 1.0f;
self.layer.shadowRadius = radius;
self.layer.shadowColor = color.CGColor;
self.layer.shadowOffset = offset;
self.layer.shouldRasterize = YES;
[self setNeedsDisplay];
}
- (void)setText:(NSString *)text
{
[super setText:text];
isGradientValid = NO;
}
- (void)setFont:(UIFont *)font
{
[super setFont:font];
isGradientValid = NO;
}
- (void)setFrame:(CGRect)aFrame
{
[super setFrame:aFrame];
isGradientValid = NO;
}
- (CGRect)textRectForBounds:(CGRect)rect
{
return CGRectMake(rect.origin.x + MAX(0, shadowBlur - shadowOffset.width), rect.origin.y + MAX(0, shadowBlur - shadowOffset.height), rect.size.width - ABS(shadowOffset.width) - shadowBlur, rect.size.height - ABS(shadowOffset.height) - shadowBlur);
}
- (void)drawTextInRect:(CGRect)rect
{
if ( isGradientValid == NO )
{
isGradientValid = YES;
[self resetGradient];
}
CGContextRef context = UIGraphicsGetCurrentContext();
//draw stroke
if (self.strokeColor != nil)
{
CGContextSetStrokeColorWithColor(context, strokeColor.CGColor);
CGContextSetTextDrawingMode(context, kCGTextFillStroke);
}
[super drawTextInRect:rect];
}
- (CGRect)placeholderRectForBounds:(CGRect)rect
{
return CGRectMake(rect.origin.x + MAX(0, shadowBlur - shadowOffset.width), rect.origin.y + MAX(0, shadowBlur - shadowOffset.height), rect.size.width - ABS(shadowOffset.width) - shadowBlur, rect.size.height - ABS(shadowOffset.height) - shadowBlur);
}
- (void)drawPlaceholderInRect:(CGRect)rect
{
if ( isGradientValid == NO )
{
isGradientValid = YES;
[self resetGradient];
}
[super drawPlaceholderInRect:rect];
}
@end
Leave a Reply