Core Text is a text engine found in iOS 3.2+ and OSX 10.5+ that gives you fine-grained control over text layout and formatting.
It sits in the sweet spot between UIKit and Core Graphics/Quartz:
Core Text is especially handy if you are creating a magazine or book app – which work great on the iPad!
This Core Text tutorial for iOS will get you started by taking you through the process of creating a very simple Magazine application using Core Text – for Zombies!
You’ll learn how to:
To get the most out of this Core Text tutorial, you need to know the basics of iOS development first. If you are new to iOS development, you should check out some of the other tutorials on this site first.
Without further ado, let’s make some happy zombies by making them their very own iPad magazine!
Start up Xcode, go to File\New\New Project, choose the iOS\Application\View-based Application, and click Next. Name the project CoreTextMagazine, choose iPad as Device family, click Next, choose a folder to save your project in, and click Create.
Next thing you have to do is add the Core Text framework to the project:
That’s all the setup you need – now it’s time to start adding some code!
To get on track with Core Text as fast as possible you are going to create a custom UIView, which will use Core Text in its drawRect: method.
Go to File\New\New File, choose iOS\Cocoa Touch\Objective-C class, and click Next. Enter UIView for Subclass of, click Next, name the new class CTView, and click Save.
In CTView.h just above @interface add the following code to include the Core Text framework:
#import <CoreText/CoreText.h> |
In the next step you’re going to set this new custom view as the main view in the application.
Select in the Project navigator the XIB file “CoreTextMagazineViewController.xib”, and bring up the Utilities strip in XCode (this appears whe you select the third tab in the View section of the top toolbar). From the Utilities strip, select the third icon on the top toolbar to select the Identity tab.
Now just click in the white space in the Interface editor to select the window’s view – you should see in the Utilities strip in the field Class the text “UIView” appearing. Write in that field “CTView” and hit Enter.
Now your application will show your custom Core Text view when started, but we’ll do that in a moment – let’s first add the code to draw some text so we have what to test.
Open CTView.m and delete all the predefined methods. Enter the following code to draw a “Hello world” inside your view:
- (void)drawRect:(CGRect)rect { [super drawRect:rect]; CGContextRef context = UIGraphicsGetCurrentContext(); CGMutablePathRef path = CGPathCreateMutable(); //1 CGPathAddRect(path, NULL, self.bounds ); NSAttributedString* attString = [[[NSAttributedString alloc] initWithString:@"Hello core text world!"] autorelease]; //2 CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attString); //3 CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, [attString length]), path, NULL); CTFrameDraw(frame, context); //4 CFRelease(frame); //5 CFRelease(path); CFRelease(framesetter); } |
Let’s discuss this bit by bit, using the comment markers above to designate each section:
Note that when working with Core Text classes you use a set of functions like CTFramesetterCreateWithAttributedString and CTFramesetterCreateFrame instead of directly using Objective-C objects.
You might think to yourself “Why would I ever want to use C again, I thought I was done with that since we have Objective-C?!”
Well, many of the low level libraries on iOS are written in plain C for speed and simplicity. Don’t worry though, you’ll find the Core Text functions pretty easy to work with.
Just one important thing to remember though: don’t forget to always use CFRelease on the references you get from functions which have “Create” in their name.
Believe it or not, that’s all you need to draw some simple text using Core Text! Hit Run and see the result.
Well that does not seem right, does it? Like many of the low level APIs, Core Text uses a Y-flipped coordinate system. To make it even worse, the content is also rendered flipped downwards! Because of this, keep in mind that if you mix UIKit drawing and Core Text drawing, you might get weird results.
Let’s fix the content orientation! Add the following code just after this line “CGContextRef context = UIGraphicsGetCurrentContext();”:
// Flip the coordinate system CGContextSetTextMatrix(context, CGAffineTransformIdentity); CGContextTranslateCTM(context, 0, self.bounds.size.height); CGContextScaleCTM(context, 1.0, -1.0); |
This is very simple code, which just flips the content by applying a transformation to the view’s context. Just copy/paste it each time you do drawing with CT.
Now hit Run again – congrats on your first Core Text app!
If you are a bit confused about the CTFramesetter and the CTFrame – that’s OK. Here I’ll make a short detour to explain how Core Text renders text content.
Here’s what the Core Text object model looks like:
You create a CTFramesetter reference and you provide it with NSAttributedString. At this point, an instance of CTTypesetter is automatically created for you, a class that manages your fonts. Next you use the CTFramesetter to create one or more frames in which you will be rendering text.
When you create a frame you tell it the subrange of text that will be rendered inside its rectangle. Core Text then automatically creates a CTLine for each line of text and (pay attention here) a CTRun for each piece of text with the same formatting.
As an example, Core Text would create a CTRun if you had several words in a row colored red, then another CTRun for the following plain text, then another CTRun for a bold sentence, etc. Again: very important – you don’t create CTRun instances, Core Text creates them for you based on the attributes of the supplied NSAttributedString.
Each of these CTRun objects can adopt different attributes, so you have fine control over kerning, ligatures, width, height and more.
To create this magazine app, we need the capability to mark some of the text as having different attributes. We could do this by directly using methods on NSAttributedString such as setAttributes:range, but this is unwieldy to deal with in practice (unless you like to painstakingly write a ton of code!)
So to make thing simpler to work with, we’ll create a simple text markup parser which will allow us to use simple tags to set formatting in the magazine content.
Go to File\New\New File, choose iOS\Cocoa Touch\Objective-C class, and click Next. Enter NSObject for Subclass of, click Next, name the new class MarkupParser.m, and click Save.
Inside MarkupParser.h delete all the text and paste this code – it defines few properties and the method to do the parsing:
#import <Foundation/Foundation.h> #import <CoreText/CoreText.h> @interface MarkupParser : NSObject { NSString* font; UIColor* color; UIColor* strokeColor; float strokeWidth; NSMutableArray* images; } @property (retain, nonatomic) NSString* font; @property (retain, nonatomic) UIColor* color; @property (retain, nonatomic) UIColor* strokeColor; @property (assign, readwrite) float strokeWidth; @property (retain, nonatomic) NSMutableArray* images; -(NSAttributedString*)attrStringFromMarkup:(NSString*)html; @end |
Next open MarkupParser.m and replace the contents with the following:
#import "MarkupParser.h" @implementation MarkupParser @synthesize font, color, strokeColor, strokeWidth; @synthesize images; -(id)init { self = [super init]; if (self) { self.font = @"Arial"; self.color = [UIColor blackColor]; self.strokeColor = [UIColor whiteColor]; self.strokeWidth = 0.0; self.images = [NSMutableArray array]; } return self; } -(NSAttributedString*)attrStringFromMarkup:(NSString*)markup { } -(void)dealloc { self.font = nil; self.color = nil; self.strokeColor = nil; self.images = nil; [super dealloc]; } @end |
As you see you start pretty easy with the parser code – it just contains properties to hold the font, text color, stroke width and stroke color. Later on we’ll be adding images inside the text, so you need an array where you’re going to keep the list of images in the text.
Writing a parser is usually pretty hard work, so I’m going to show you how to build a very very simple one using regular expressions. This tutorial’s parser will be very simple and will support only opening tags – i.e. a tag will set the style of the text after the tag, the style will be applied until a new tag is found. The text markup will look like this:
These are <font color="red">red<font color="black"> and <font color="blue">blue <font color="black">words.
and will produce output like this:
These are red and blue words.
For the purpose of this Core Text tutorial such markup will be quite sufficient. For your projects you can develop it further if you'd like to.
Let's get parsin'!
Inside the attrStringFromMarkup: method add the following:
NSMutableAttributedString* aString = [[NSMutableAttributedString alloc] initWithString:@""]; //1 NSRegularExpression* regex = [[NSRegularExpression alloc] initWithPattern:@"(.*?)(<[^>]+>|\\Z)" options:NSRegularExpressionCaseInsensitive|NSRegularExpressionDotMatchesLineSeparators error:nil]; //2 NSArray* chunks = [regex matchesInString:markup options:0 range:NSMakeRange(0, [markup length])]; [regex release]; |
There are two sections to cover here:
Why are we creating this regular expression? We're going to use it to search the string for every place it matches, and then 1) render the text chunk found; then 2) change the current styles according to what's found in the tag. This will be repeated until the text is over.
Very simple parser indeed, eh?
Now that you have the chunks of text and all the formatting tags (like the font tag you see a bit above) in the "chunks" array, you'll need to loop trough them and build the attributed string from the text and tags.
Add this to the method body:
for (NSTextCheckingResult* b in chunks) { NSArray* parts = [[markup substringWithRange:b.range] componentsSeparatedByString:@"<"]; //1 CTFontRef fontRef = CTFontCreateWithName((CFStringRef)self.font, 24.0f, NULL); //apply the current text style //2 NSDictionary* attrs = [NSDictionary dictionaryWithObjectsAndKeys: (id)self.color.CGColor, kCTForegroundColorAttributeName, (id)fontRef, kCTFontAttributeName, (id)self.strokeColor.CGColor, (NSString *) kCTStrokeColorAttributeName, (id)[NSNumber numberWithFloat: self.strokeWidth], (NSString *)kCTStrokeWidthAttributeName, nil]; [aString appendAttributedString:[[[NSAttributedString alloc] initWithString:[parts objectAtIndex:0] attributes:attrs] autorelease]]; CFRelease(fontRef); //handle new formatting tag //3 if ([parts count]>1) { NSString* tag = (NSString*)[parts objectAtIndex:1]; if ([tag hasPrefix:@"font"]) { //stroke color NSRegularExpression* scolorRegex = [[[NSRegularExpression alloc] initWithPattern:@"(?<=strokeColor=\")\\w+" options:0 error:NULL] autorelease]; [scolorRegex enumerateMatchesInString:tag options:0 range:NSMakeRange(0, [tag length]) usingBlock:^(NSTextCheckingResult *match, NSMatchingFlags flags, BOOL *stop){ if ([[tag substringWithRange:match.range] isEqualToString:@"none"]) { self.strokeWidth = 0.0; } else { self.strokeWidth = -3.0; SEL colorSel = NSSelectorFromString([NSString stringWithFormat: @"%@Color", [tag substringWithRange:match.range]]); self.strokeColor = [UIColor performSelector:colorSel]; } }]; //color NSRegularExpression* colorRegex = [[[NSRegularExpression alloc] initWithPattern:@"(?<=color=\")\\w+" options:0 error:NULL] autorelease]; [colorRegex enumerateMatchesInString:tag options:0 range:NSMakeRange(0, [tag length]) usingBlock:^(NSTextCheckingResult *match, NSMatchingFlags flags, BOOL *stop){ SEL colorSel = NSSelectorFromString([NSString stringWithFormat: @"%@Color", [tag substringWithRange:match.range]]); self.color = [UIColor performSelector:colorSel]; }]; //face NSRegularExpression* faceRegex = [[[NSRegularExpression alloc] initWithPattern:@"(?<=face=\")[^\"]+" options:0 error:NULL] autorelease]; [faceRegex enumerateMatchesInString:tag options:0 range:NSMakeRange(0, [tag length]) usingBlock:^(NSTextCheckingResult *match, NSMatchingFlags flags, BOOL *stop){ self.font = [tag substringWithRange:match.range]; }]; } //end of font parsing } } return (NSAttributedString*)aString; |
Phew, this is a lot of code! But don't worry, we'll go over it here section by section.
Note: If you're unsatiably curious how the regular expressions in this section work, they are basically saying ("Use the look-behind assertion to look for any text that is preceded by color=". Then match any normal word character (which does not include a quote), which basically keeps matching until the close quote is found. For more details, check out Apple's NSRegularExpression class reference.
Right! Half the work of rendering formatted text is done - now attrStringFromMarkup: can take markup in and spit a NSAttributedString out ready to be fed to Core Text.
So let's pass in a string to render, and try it out!
Open CTView.m and add this just before @implementation:
#import "MarkupParser.h" |
Find the line where attString is defined - replace it with the following code:
MarkupParser* p = [[[MarkupParser alloc] init] autorelease]; NSAttributedString* attString = [p attrStringFromMarkup: @"Hello <font color=\"red\">core text <font color=\"blue\">world!"]; |
Above you make a new parser, feed it a piece of markup and it gives you back formatted text.
That's it - hit Run and try it for yourself!
Ain't that just awesome? Thanks to 50 lines of parsing we don't have to deal with text ranges and code heavy text formatting, we can just use now a simple text file to hold the contents of our magazine app. Also the simple parser you just wrote can be extended infinitely to support everything you'd need in your magazine app.
So far we have text showing up, which is a good first step. But for a magazine we'd like to have columns - and this is where Core Text becomes particularly handy.
Before proceeding with the layout code, let's first load a longer string into the app so we have something long enough to wrap across multiple lines.
Go to File\New\New File, choose iOS\Other\Empty, and click Next. Name the new file test.txt, and click Save.
Then add the text from this file into test.txt and save.
Open CTView.m and find the 2 lines where you create MarkupParser and NSAttributedString and delete them. We're taking the loading of the text file out of the drawRect: method, because that sort of code doesn't really belong there. It's the job of a UIView to display content given to it - not load content. We'll move the attString variable to an instance variable and property in this class later.
Next open CoreTextMagazineViewController.m, delete all the existing content, and add the following instead:
#import "CoreTextMagazineViewController.h" #import "CTView.h" #import "MarkupParser.h" @implementation CoreTextMagazineViewController - (void)viewDidLoad { [super viewDidLoad]; NSString *path = [[NSBundle mainBundle] pathForResource:@"test" ofType:@"txt"]; NSString* text = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:NULL]; MarkupParser* p = [[[MarkupParser alloc] init] autorelease]; NSAttributedString* attString = [p attrStringFromMarkup: text]; [(CTView*)self.view setAttString: attString]; } @end |
When the view of the application is loaded, the app reads the text from test.txt, converts it to an attributed string and then sets the attString property on the window's view. We haven't added that property to CTView yet though, so let's add that next!
In CTView.h define these 3 instance variables:
float frameXOffset; float frameYOffset; NSAttributedString* attString; |
Then add the corresponding code in CTView.h and CTView.m to define a property for attString:
//CTView.h @property (retain, nonatomic) NSAttributedString* attString; //CTView.m //just below @implementation ... @synthesize attString; //at the bottom of the file -(void)dealloc { self.attString = nil; [super dealloc]; } |
Now you can hit Run again to see the view showing the contents of the text file. Cool!
How to make columns out of this text? Luckily Core Text provides a handy function - CTFrameGetVisibleStringRange. This function tells you how much text will fit into a given frame. So the idea is - create column, check how much text fits inside, if there's more - create another column, etc. etc. (columns here will be CTFrame instances, since columns are just taller rectangles)
First of all - we are going to have columns, then pages, then a whole magazine, so... let's make our CTView subclass UIScrollView to get free paging and scrolling!
Open up CTView.h and change the @interface line to:
@interface CTView : UIScrollView<UIScrollViewDelegate> { |
OK! We've got free scrolling and paging now available. We're going to enable the paging in a minute.
Up to now we were creating our framesetter and frame inside the drawRect: method. When you have columns and different formatting it's better to do all those calculations only once. So what we are going to do is have a new class "CTColumnView" which will only render CT content passed to it, and in our CTView class we're going to only once create instances of CTColumnView and add them as subviews.
So to summarize: CTView is going to take care of scrolling, paging and building the columns; CTColumnView will actually render the content on the screen.
Go to File\New\New File, choose iOS\Cocoa Touch\Objective-C class, and click Next. Enter UIView for "Subclass of", click Next, name the new class CTColumnView.m, and click Save. Here's the initial code for the CTColumnView class:
//inside CTColumnView.h #import <UIKit/UIKit.h> #import <CoreText/CoreText.h> @interface CTColumnView : UIView { id ctFrame; } -(void)setCTFrame:(id)f; @end //inside CTColumnView.m #import "CTColumnView.h" @implementation CTColumnView -(void)setCTFrame: (id) f { ctFrame = f; } -(void)drawRect:(CGRect)rect { CGContextRef context = UIGraphicsGetCurrentContext(); // Flip the coordinate system CGContextSetTextMatrix(context, CGAffineTransformIdentity); CGContextTranslateCTM(context, 0, self.bounds.size.height); CGContextScaleCTM(context, 1.0, -1.0); CTFrameDraw((CTFrameRef)ctFrame, context); } @end |
This class does pretty much what we've been doing up to now - it just renders a CTFrame. We're going to create an instance of it for each text column in the magazine.
Let's first add a property to hold our CTView's text frames and declare the buildFrames method, which will do the columns setup:
//CTView.h - at the top #import "CTColumnView.h" //CTView.h - as an ivar NSMutableArray* frames; //CTView.h - declare property @property (retain, nonatomic) NSMutableArray* frames; //CTView.h - in method declarations - (void)buildFrames; //CTView.m - just below @implementation @synthesize frames; //CTView.m - inside dealloc self.frames = nil; |
Now buildFrames can create the text frames once and store them in the "frames" array. Let's add the code to do so.
- (void)buildFrames { frameXOffset = 20; //1 frameYOffset = 20; self.pagingEnabled = YES; self.delegate = self; self.frames = [NSMutableArray array]; CGMutablePathRef path = CGPathCreateMutable(); //2 CGRect textFrame = CGRectInset(self.bounds, frameXOffset, frameYOffset); CGPathAddRect(path, NULL, textFrame ); CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attString); int textPos = 0; //3 int columnIndex = 0; while (textPos < [attString length]) { //4 CGPoint colOffset = CGPointMake( (columnIndex+1)*frameXOffset + columnIndex*(textFrame.size.width/2), 20 ); CGRect colRect = CGRectMake(0, 0 , textFrame.size.width/2-10, textFrame.size.height-40); CGMutablePathRef path = CGPathCreateMutable(); CGPathAddRect(path, NULL, colRect); //use the column path CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(textPos, 0), path, NULL); CFRange frameRange = CTFrameGetVisibleStringRange(frame); //5 //create an empty column view CTColumnView* content = [[[CTColumnView alloc] initWithFrame: CGRectMake(0, 0, self.contentSize.width, self.contentSize.height)] autorelease]; content.backgroundColor = [UIColor clearColor]; content.frame = CGRectMake(colOffset.x, colOffset.y, colRect.size.width, colRect.size.height) ; //set the column view contents and add it as subview [content setCTFrame:(id)frame]; //6 [self.frames addObject: (id)frame]; [self addSubview: content]; //prepare for next frame textPos += frameRange.length; //CFRelease(frame); CFRelease(path); columnIndex++; } //set the total width of the scroll view int totalPages = (columnIndex+1) / 2; //7 self.contentSize = CGSizeMake(totalPages*self.bounds.size.width, textFrame.size.height); } |
Let's look at the code.
Now let's also call buildFrames when all the CT setup is done. Inside CoreTextMagazineViewController.m add at the end of viewDidLoad :
[(CTView *)[self view] buildFrames]; |
One more thing to do before giving the new code a try: in the file CTView.m find the method drawRect: and remove it. We do now all the rendering in the CTColumnView class, so we'll leave the CTView drawRect: method to be the standard UIScrollView implementation.
Alright... hit Run and you'll see text floating in columns! Drag right and left to go between pages ... awesome!
We have columns, formatted text, but we miss images. Turns out drawing images with Core Text is not that easy - it's a text framework after all.
But thanks to the fact we already have a little markup parser we're going to get images inside the text pretty quick!
Basically Core Text does not have possibility to draw images. However, since it's a layout engine, what it can do is leave an empty space where you want to draw a picture. And since your code is already inside a drawRect: method, drawing an image yourself is easy.
Let's look at how leaving an empty space in the text works. Remember all the text chunks are CTRun instances? You simply set a delegate for a given CTRun and the delegate object is responsible to let know Core Text what is the CTRun ascent space, descent space and width. Like so:
When Core Text "reaches" a CTRun which has a CTRunDelegate it asks the delegate - how much width should I leave for this chunk of data, how high should it be? This way you build a hole in the text - then you draw your image in that very spot.
Let's start by adding support for an "img" tag in our little markup parser! Open up MarkupParser.m and find "} //end of font parsing"; add the following code immediately after this line to add support for "img" tag:
if ([tag hasPrefix:@"img"]) { __block NSNumber* width = [NSNumber numberWithInt:0]; __block NSNumber* height = [NSNumber numberWithInt:0]; __block NSString* fileName = @""; //width NSRegularExpression* widthRegex = [[[NSRegularExpression alloc] initWithPattern:@"(?<=width=\")[^\"]+" options:0 error:NULL] autorelease]; [widthRegex enumerateMatchesInString:tag options:0 range:NSMakeRange(0, [tag length]) usingBlock:^(NSTextCheckingResult *match, NSMatchingFlags flags, BOOL *stop){ width = [NSNumber numberWithInt: [[tag substringWithRange: match.range] intValue] ]; }]; //height NSRegularExpression* faceRegex = [[[NSRegularExpression alloc] initWithPattern:@"(?<=height=\")[^\"]+" options:0 error:NULL] autorelease]; [faceRegex enumerateMatchesInString:tag options:0 range:NSMakeRange(0, [tag length]) usingBlock:^(NSTextCheckingResult *match, NSMatchingFlags flags, BOOL *stop){ height = [NSNumber numberWithInt: [[tag substringWithRange:match.range] intValue]]; }]; //image NSRegularExpression* srcRegex = [[[NSRegularExpression alloc] initWithPattern:@"(?<=src=\")[^\"]+" options:0 error:NULL] autorelease]; [srcRegex enumerateMatchesInString:tag options:0 range:NSMakeRange(0, [tag length]) usingBlock:^(NSTextCheckingResult *match, NSMatchingFlags flags, BOOL *stop){ fileName = [tag substringWithRange: match.range]; }]; //add the image for drawing [self.images addObject: [NSDictionary dictionaryWithObjectsAndKeys: width, @"width", height, @"height", fileName, @"fileName", [NSNumber numberWithInt: [aString length]], @"location", nil] ]; //render empty space for drawing the image in the text //1 CTRunDelegateCallbacks callbacks; callbacks.version = kCTRunDelegateVersion1; callbacks.getAscent = ascentCallback; callbacks.getDescent = descentCallback; callbacks.getWidth = widthCallback; callbacks.dealloc = deallocCallback; NSDictionary* imgAttr = [[NSDictionary dictionaryWithObjectsAndKeys: //2 width, @"width", height, @"height", nil] retain]; CTRunDelegateRef delegate = CTRunDelegateCreate(&callbacks, imgAttr); //3 NSDictionary *attrDictionaryDelegate = [NSDictionary dictionaryWithObjectsAndKeys: //set the delegate (id)delegate, (NSString*)kCTRunDelegateAttributeName, nil]; //add a space to the text so that it can call the delegate [aString appendAttributedString:[[[NSAttributedString alloc] initWithString:@" " attributes:attrDictionaryDelegate] autorelease]]; } |
Let's get a look at all that new code - actually parsing the "img" tag does pretty much the same as what you did already for the font tag. By using 3 regexes you effectively read the width, height and src attributes of the img tag. When done - you add a new NSDictionary to self.images holding the information you just parsed out plus the location of the image in the text.
Now look at section 1 - CTRunDelegateCallbacks is a C struct which holds references to functions. This struct provides the information you want to pass to the CTRunDelegate. As you can guess already getWidth is called to provide a width for the CTRun, getAscent provides the height of the CTRun, etc. In the code above you provide function names for those handlers; in a minute we're gonna add the function bodies too.
Section 2 is very important - imgAttr dictionary holds the dimentions of the image; this object is also retained as it is going to be passed to the function handlers - so, when getAscent handler triggers it'll get as a parameter imgAttr and will just read out the height of the image and provide the value to CT. Neat! (we'll get to this in a moment)
CTRunDelegateCreate in section 3 creates a delegate instance reference and binds the callbacks and the data parameter together.
In the next step you need to create the attributes dictionary (the same way as for the font formatting above), but instead of formatting attributes you pass the delegate instance. In the end you add a single space which will trigger the delegate and create the hole in the text where the image will be rendered later on.
Next step, which you already anticipated, is to provide the callback functions for the delegate:
//inside MarkupParser.m, just above @implementation /* Callbacks */ static void deallocCallback( void* ref ){ [(id)ref release]; } static CGFloat ascentCallback( void *ref ){ return [(NSString*)[(NSDictionary*)ref objectForKey:@"height"] floatValue]; } static CGFloat descentCallback( void *ref ){ return [(NSString*)[(NSDictionary*)ref objectForKey:@"descent"] floatValue]; } static CGFloat widthCallback( void* ref ){ return [(NSString*)[(NSDictionary*)ref objectForKey:@"width"] floatValue]; } |
ascentCallback, descentCallback and widthCallback only read the respective properties from the passed NSDictionary and provide them to CT. What deallocCallback does is that it releases the dictionary holding the image information - it's called when the CTRunDelegate gets deallocated, so you have chance to do your memory management there.
Now that your parser is handling "img" tags, let's adjust also the CTView to render them. We need a method to send the images array to the view, let's combine setting the attributed string and the images into one method. Add the code:
//CTView.h - inside @interface declaration as an ivar NSArray* images; //CTView.h - declare property for images @property (retain, nonatomic) NSArray* images; //CTView.h - add a method declaration -(void)setAttString:(NSAttributedString *)attString withImages:(NSArray*)imgs; //CTView.m - just below @implementation @synthesize images; //CTView.m - inside the dealloc method self.images = nil; //CTView.m - anywhere inside the implementation -(void)setAttString:(NSAttributedString *)string withImages:(NSArray*)imgs { self.attString = string; self.images = imgs; } |
Now that CTView is prepared to accept an array with images, let's pass those from the parser to the view and render them!
Go to CoreTextMagazineViewController.m and find the line "[(CTView*)self.view setAttString: attString];" - replace it with the following:
[(CTView *)[self view] setAttString:attString withImages: p.images]; |
If you lookup attrStringFromMarkup: in the MarkupParser class you'll see it saves all the image tags data into self.images. This is what you are passing now directly to the CTView.
To render images we'll have to know the exact frame of the run where the image should appear. To find the origin of that spot we need to take in account several values:
Let's render those images! First of all we need to update the CTColumnView class:
//inside CTColumnView.h //as an ivar NSMutableArray* images; //as a property @property (retain, nonatomic) NSMutableArray* images; //inside CTColumnView.m //after @implementation... @synthesize images; -(id)initWithFrame:(CGRect)frame { if ([super initWithFrame:frame]!=nil) { self.images = [NSMutableArray array]; } return self; } -(void)dealloc { self.images= nil; [super dealloc]; } //at the end of drawRect: for (NSArray* imageData in self.images) { UIImage* img = [imageData objectAtIndex:0]; CGRect imgBounds = CGRectFromString([imageData objectAtIndex:1]); CGContextDrawImage(context, imgBounds, img.CGImage); } |
So with this code update we add an ivar and a property called images where we will keep a list of the images appearing in each text column. To avoid declaring yet another new class to hold the image data inside images we're going to store NSArray objects which will hold:
And now the code that calculates the images' position and attaches them to the respective text columns:
//inside CTView.h -(void)attachImagesWithFrame:(CTFrameRef)f inColumnView:(CTColumnView*)col; //inside CTView.m -(void)attachImagesWithFrame:(CTFrameRef)f inColumnView:(CTColumnView*)col { //drawing images NSArray *lines = (NSArray *)CTFrameGetLines(f); //1 CGPoint origins[[lines count]]; CTFrameGetLineOrigins(f, CFRangeMake(0, 0), origins); //2 int imgIndex = 0; //3 NSDictionary* nextImage = [self.images objectAtIndex:imgIndex]; int imgLocation = [[nextImage objectForKey:@"location"] intValue]; //find images for the current column CFRange frameRange = CTFrameGetVisibleStringRange(f); //4 while ( imgLocation < frameRange.location ) { imgIndex++; if (imgIndex>=[self.images count]) return; //quit if no images for this column nextImage = [self.images objectAtIndex:imgIndex]; imgLocation = [[nextImage objectForKey:@"location"] intValue]; } NSUInteger lineIndex = 0; for (id lineObj in lines) { //5 CTLineRef line = (CTLineRef)lineObj; for (id runObj in (NSArray *)CTLineGetGlyphRuns(line)) { //6 CTRunRef run = (CTRunRef)runObj; CFRange runRange = CTRunGetStringRange(run); if ( runRange.location <= imgLocation && runRange.location+runRange.length > imgLocation ) { //7 CGRect runBounds; CGFloat ascent;//height above the baseline CGFloat descent;//height below the baseline runBounds.size.width = CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &ascent, &descent, NULL); //8 runBounds.size.height = ascent + descent; CGFloat xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, NULL); //9 runBounds.origin.x = origins[lineIndex].x + self.frame.origin.x + xOffset + frameXOffset; runBounds.origin.y = origins[lineIndex].y + self.frame.origin.y + frameYOffset; runBounds.origin.y -= descent; UIImage *img = [UIImage imageNamed: [nextImage objectForKey:@"fileName"] ]; CGPathRef pathRef = CTFrameGetPath(f); //10 CGRect colRect = CGPathGetBoundingBox(pathRef); CGRect imgBounds = CGRectOffset(runBounds, colRect.origin.x - frameXOffset - self.contentOffset.x, colRect.origin.y - frameYOffset - self.frame.origin.y); [col.images addObject: //11 [NSArray arrayWithObjects:img, NSStringFromCGRect(imgBounds) , nil] ]; //load the next image //12 imgIndex++; if (imgIndex < [self.images count]) { nextImage = [self.images objectAtIndex: imgIndex]; imgLocation = [[nextImage objectForKey: @"location"] intValue]; } } } lineIndex++; } } |
Note: Ultimate credit goes to David Beck for his example on looping over runs, on which this code builds upon.
I know at first this code looks really hard-core, but bear with me - we're at the end of the tutorial; this is the final push for glory!
Let's go section by section:
OK! Great! Almost there - one tiny final step: find in CTView.m the line "[content setCTFrame:(id)frame];" and add below it:
[self attachImagesWithFrame:frame inColumnView: content]; |
Now you have all the code working, but you don't have great content to visualize...
No fear, I've prepared the next issue of Zombie Monthly - a montly popular magazine for zombies - for you; just do the following to import the content:
Then switch to CoreTextMagazineViewController.m and switch the line that gets the path to the file to use the new zombies.txt instead:
NSString *path = [[NSBundle mainBundle] pathForResource:@"zombies" ofType:@"txt"]; |
That's it - compile and run, and enjoy the latest issue of Zombie Monthly! :)
Just one final touch. Say we want to make the text in the columns justified so they fill the entire width of the column. Add the following code to make this happen:
//inside CTView.m //at the end of the setAttString:withImages: method CTTextAlignment alignment = kCTJustifiedTextAlignment; CTParagraphStyleSetting settings[] = { {kCTParagraphStyleSpecifierAlignment, sizeof(alignment), &alignment}, }; CTParagraphStyleRef paragraphStyle = CTParagraphStyleCreate(settings, sizeof(settings) / sizeof(settings[0])); NSDictionary *attrDictionary = [NSDictionary dictionaryWithObjectsAndKeys: (id)paragraphStyle, (NSString*)kCTParagraphStyleAttributeName, nil]; NSMutableAttributedString* stringCopy = [[[NSMutableAttributedString alloc] initWithAttributedString:self.attString] autorelease]; [stringCopy addAttributes:attrDictionary range:NSMakeRange(0, [attString length])]; self.attString = (NSAttributedString*)stringCopy; |
This should get you started with paragraph styling; lookup kCTParagraphStyleSpecifierAlignment in Apple's Core Text documentation to get to the list of all paragraph styles you can control.
Now that your Magazine App with Core Text is finished you might be asking yourself: "Why would I use Core Text over UIWebView?"
Well both CT and UIWebView are useful in their own context.
Don't forget UIWebView is a full-blown web browser and using it to visualize a single multicolored line of text is a huge overkill.
Imagine you have 10 multicolor labels in your UI- this means you're going to consume the memory for 10 Safaris (ok, almost, but you get the point).
So, keep in mind: UIWebView is a great web browser for when you need one, while Core Text is an efficient text rendering engine.
Here's the complete Core Text example project we developed in the above Core Text tutorial.
If you want to keep playing around with this project to learn more about Core Text, read up in Apple's Core Text Reference Collection and see if you can add some of the following features to the app:
Since I know you are already thinking how to expand the parser engine beyond what I included in this short tutorial I have two recommendations for you:
If you have any questions, comments, or suggestions, please join in the forum discussion below!