In the iOS 7 SDK, Apple gave developers programmatic access to Text Kit, an advanced text layout system that lets you easily display and edit rich text in your applications. Although these capabilities are new to iOS, they’ve existed in other contexts (OS X since its first release, and OpenStep before that) for nearly two decades, in what’s called the Cocoa text system. In fact, if you’ve used OS X at all, there is a near 100% chance that you’ve run apps built with the Cocoa text system.
However, for iOS developers, who often are not steeped in the details of OS X development, the details of using the supplied text layout system are new, and may seem mysterious at first. I intend to help you understand the basics of how this works, and see how you can add rich text features to your own apps.
The simplest thing
Let’s start off by making the simplest possible app that shows off some of what
Text Kit can do. In Xcode, create a new project using the Single View
Application template, and name it Simple Text View. Select Main.storyboard,
use the Object Library to find a UITextView
object, and drag it out to fill
the view of the available view controller; you’ll see blue guidelines appear and
the whole thing snap into place when it’s properly centered. Then use the
Attributes Inspector to change the text view’s Text attribute from Plain
to Attributed. What this does is tell the text view to allow rich text by
using an attributed string. An attributed string, which is represented in iOS
by the NSAttributedString
class, is simply a string that has some attached
metadata describing its attributes. This metadata may contain any number of
ranges of characters, each with its own set of attributes. For example, you
could specify that starting at the fifth character, the next six characters are
bold, and that starting at the tenth character, the next five characters are
italicized; In that case, the tenth character would be both bold and italicized.
In effect,
0123456789ABCDEF
However, plenty of rich text content is created not by programmatically
specifying ranges and attributes, but by users working in an editor that lets
them create rich text. That’s a use case that is fully supported by UITextView
starting in iOS 7.
To prove this, use the Attributes Inspector to modify parts of the “Lorem ipsum” text that the view contains by default. Use the controls in the inspector to change some fonts, adjust paragraph alignment, set foreground and backgroud colors, whatever you want. When you hit cmd-R to run the app in the iOS Simulator or on a device, you’ll see that all the formatting changes you made show up on the device. You can tap to edit the text at any point, and the formatting that applies where the cursor is will carry on to new characters you type, just as you’d expect from any word processor application.
The innards
So far, so good. Even better, it turns out that a few other popular UIKit
classes, namely UILabel
and UITextField
, also allow the use of attributed
strings in iOS 7. This means that if you just want to display some rich text in
a single rectangular box, you’re all set. Just put a properly configured UILabel
where you want to show your rich text, and you’re done! This simple task was
remarkably hard to accomplish before iOS 7, so right there we’ve made a huge
leap.
But, what if you want to do more? There are certain kinds of layout tricks that
none of the UIKit classes can do on their own, out of the box. For example, if
you want to make text flow around a graphic, or make a single string fill up one
rectangle before spilling into another (as in the case of multiple columns),
you’ll have to do more. Fortunately, the innards of Text Kit, which are used by
UITextView and the rest, are at your disposal in the form of the
NSTextStorage
, NSLayoutManager
, and NSTextContainer
classes. Let’s talk
about these one by one:
NSTextStorage
is actually a subclass ofNSMutableAttributedString
, which itself is a subclass ofNSAttributedString
. It adds some functionality that is useful for dealing with a user editing text, and nothing more.NSTextContainer
is an abstract description of a two-dimensional box that text could be rendered into. Basically, this class is little more than a glorified size. It contains a few parameters for describing how text should behave when rendered within a box of its size, and that’s about it.NSLayoutManager
is the real brains of the operation. It knows how to take anNSTextStorage
instance, and layout all the characters it contains into the virtual boxes described by one or moreNSTextContainers
.
A class like UITextView
uses these components to do all its text layout. In
fact, UITextView
has three properties called textStorage
, textContainer
,
and layoutManager
for just this purpose. When UITextView
wants to draw its
content, it tells its layoutManager
to figure out which glyphs (the graphical
representations of the characters it contains) from its textStorage
can fit
within its textContainer
, then it tells the layoutManager
to actually draw
those glyphs at a point inside the text view’s frame. So you see that the design
of UITextView
itself is inherently limited to a single rectangle. In order to
get a feel for how these innards work, I’ll now show you a UIView
subclass
that will display rich text in multiple columns, a trick that UITextView
really can’t pull off in its current form.
Create TBTMultiColumnTextView
In your open Xcode project, create a new subclass of UIView
called
TBTMultiColumnView
. Like UITextView
, this class will have textStorage
and
layoutManager
properties. Unlike UITextView
, it will keep track of multiple
independent text containers and multiple origins for drawing rectangles. The
first thing you should do is create a class extension at the top of the file,
containing the following properties:
@interface TBTMultiColumnTextView ()
@property (copy, nonatomic) NSTextStorage *textStorage;
@property (strong, nonatomic) NSArray *textOrigins;
@property (strong, nonatomic) NSLayoutManager *layoutManager;
@end
Besides the NSTextStorage
and NSLayoutManager
instances, we’re also going to
maintain an array of origins, each corresponding to an NSTextContainer
. We
don’t have to hang onto the text containers themselves, because the layout
manager keeps its own list, which we can access.
Now, let’s get started with the methods for this class. First, override
viewDidLoad
as shown here:
- (void)awakeFromNib {
[super awakeFromNib];
self.layoutManager = [[NSLayoutManager alloc] init];
NSURL *fileURL = [[NSBundle mainBundle] URLForResource:@"constitution"
withExtension:@"rtf"];
self.textStorage = [[NSTextStorage alloc] initWithFileURL:fileURL
options:
@{NSDocumentTypeDocumentAttribute:NSRTFTextDocumentType}
documentAttributes:nil
error:nil];
[self createColumns];
}
This method is pretty straightforward. It starts off by creating a layout
manager, which we’ll use every time we need to draw this object’s content. Then
we read the contents of an RTF file, which we’ve included in our project, into
an NSTextStorage
instance. Our project contains an RTF file that contains the
U.S. constitution, but you can use any RTF document you have at hand. Since this
object will need to be redrawn any time the text storage changes, we implement
the setter, like this:
- (void)setTextStorage:(NSTextStorage *)textStorage {
_textStorage = [[NSTextStorage alloc] initWithAttributedString:textStorage];
[self.textStorage addLayoutManager:self.layoutManager];
[self setNeedsDisplay];
}
Note that we have a special way of making a new copy of the object that’s passed
in. As it turns out, just sending copy
to an instance of NSTextStorage
actually returns an instance of an immutable parent class (just like you’d
expect with, say, an NSMutableString
). That’s why we take the step of
explicitly creating a new instance based on the received parameter.
At the end of awakeFromNib
, we called the createColumns
method, which is
where most of this class’s work really happens. It looks like this:
- (void)createColumns {
// Remove any existing text containers, since we will recreate them.
for (NSUInteger i = [self.layoutManager.textContainers count]; i > 0;) {
[self.layoutManager removeTextContainerAtIndex:--i];
}
// Capture some frequently-used geometry values in local variables.
CGRect bounds = self.bounds;
CGFloat x = bounds.origin.x;
CGFloat y = bounds.origin.y;
// These are effectively constants. If you want to make this class more
// extensible, turning these into public properties would be a nice start!
NSUInteger columnCount = 2;
CGFloat interColumnMargin = 10;
// Calculate sizes for building a series of text containers.
CGFloat totalMargin = interColumnMargin * (columnCount - 1);
CGFloat columnWidth = (bounds.size.width - totalMargin) / columnCount;
CGSize columnSize = CGSizeMake(columnWidth, bounds.size.height);
NSMutableArray *containers = [NSMutableArray arrayWithCapacity:columnCount];
NSMutableArray *origins = [NSMutableArray arrayWithCapacity:columnCount];
for (NSUInteger i = 0; i < columnCount; i++) {
// Create a new container of the appropriate size, and add it to our array.
NSTextContainer *container = [[NSTextContainer alloc] initWithSize:columnSize];
[containers addObject:container];
// Create a new origin point for the container we just added.
NSValue *originValue = [NSValue valueWithCGPoint:CGPointMake(x, y)];
[origins addObject:originValue];
[self.layoutManager addTextContainer:container];
x += columnWidth + interColumnMargin;
}
self.textOrigins = origins;
}
This method is honestly a little longer than we’d like, but for this example it
does the job. This method may need to be called multiple times, whenever the
view’s coordinates are adjusted (such as when the device rotates), so we need to
make sure it can run multiple times without ending up in a weird state. So, it
starts off by removing any old text containers that may be attached to the
layout manager. It does this because the whole point of this method is to create
a fresh set of text containers, and having old ones lying around will only give
us grief. This method then calculates appropriate text container sizes depending
on the view’s size and some hard-coded values for the number of columns and the
amount of margin that should appear between columns. Finally it creates and
configures a number of containers and and equal number of points (wrapped in
NSValue
objects).
Next we’re going to make use of all those containers and points we just created. The drawRect: method tells the layout manager to finally draw its content into each text container. It looks like this:
- (void)drawRect:(CGRect)rect {
for (NSUInteger i = 0; i < [self.layoutManager.textContainers count]; i++) {
NSTextContainer *container = self.layoutManager.textContainers[i];
CGPoint origin = [self.textOrigins[i] CGPointValue];
NSRange glyphRange = [self.layoutManager glyphRangeForTextContainer:container];
[self.layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:origin];
}
}
All we do here is loop over all available text containers and origin points, each time asking the layout manager which glyphs it can fit into the container, then telling the layout manager to draw those glyphs at the origin point.
That’s just about all we need. In order to make things work after device
rotation, however, we need to do one more thing. By overriding layoutSubviews
,
which is called when the view rotates, we can make sure that the columns are
regenerated for the new size:
- (void)layoutSubviews {
[super layoutSubviews];
[self createColumns];
[self setNeedsDisplay];
}
That’s all we need to make this class draw rich text in two columns, and automatically adjust for changes in view geometry. To see this in action, go back to the storyboad and follow these steps:
- Remove the
UITextView
you added at the start. - Find a
UIView
in the object library, drag it into the view and make it fill the view completely. - Use the Identity Inspector to change this object’s class to
TBDMultiColumnView
. - To make sure the view’s geometry changes along with its superview (e.g. when the device rotates), add constraints from the view to its superview for top, bottom, leading, and trailing space. This is most easily accomplished by clicking the Pin button at the bottom of Interface Builder’s editing area, and selecting each of the four red, dashed-line symbols surrounding the little square (which represents the selected view). That sounds complicated, but once you see it, you’ll get it.
Once you’ve taken those final steps, you can build and run in the simulator or on a device, and see your multicolumn display in all its glory!
Closing remarks
This class demonstrates a technique for creating a view that lets rich text flow across multiple columns in just a few lines of code. But we’re really just scratching the surface here. Besides flowing across multiple rectangles, Text Kit will let you do plenty of other things, including drawing text inside the path of an arbitrary shape, making text flow around other paths, and more. You can learn more about thse techniques by looking at Apple’s iOS Text Kit Overview, as well as their Mac documentation for the Cocoa text system, which is where much of Text Kit’s functionality originated.