Text Kit & Core Text 文字排版
28 May 2014文字排版
通常我们使用UILabel、UITextField、UITextView在iOS上展示一些我们需要的文字。前者用于简单的展示,后两者可以用于接受用户的输入。通常情况下我们用上述3者展示简单的纯文本,如果我们需要展示图文混排或者稍微带一点排版样式的文字时,我们需要使用更底层的一些技术,比如Text Kit 或者 Core Text。
上图展示的iOS7的框架层次结构,可以看到基于Core Text的Text Kit为上述3个常用控件提供了技术支持。 首先我们需要准备一些关于字符的预备知识作为铺垫。
字符和字形(Character & Glyphs)
字符(Character)
字符从本质上讲是一个在特定字符集中定义的数字,比如我们在OS X中广泛使用的Unicode。Unicode标准为每一种我们可能会用到的字符,提供了唯一的数字标识。
字形(Glyphs)
字形是用于展示上述字符的一个图形形状。但是每个字符对应的字形,根据字体的不同、字体粗细/斜体定义的不一,并不是唯一的。 例如下图展示的字符“A”的多种字形
另外字体对应字形也不总是一对一的,在英文中经常就有连写的写法,如下图展示f+f和f+l连写情况下的字形
属性字符串(NSAttributedString)
属性字符串(NSAttributedString)在Core Text中使用广泛,接下来你可能会经常遇到他。他用于管理字符串和相关的属性集(例如:字体、字间距),这些属性可以被用于单个字符也可以是一段连续的字符串。 他使用一个NSDictionary来管理唯一标示的属性名称,你可以为任一范围的字符指定想要的字符属性。 在iOS6及以后,你可以通过attributedText属性为UILabel、UITextView、UITextField指定想要展示的字符串。通过这种方式我们就可以展示一些带格式的文本了。但是也许你会问如果要兼容iOS5或者更早的版本,要怎么做呢?这个我们在后面的Core Text部分会有解答。
以下是常见的4种类型,mutable和toll free bridge
NSAttributedString
NSMutableAttributedString
// Toll Free Bridge
CFAttributedString
CFMutableAttributedString
创建方法
initWithString:
initWithString:attributes:
initWithAttributedString:
同时还支持从RTF或者RTFD格式的rich text直接创建
initWithRTF:documentAttributes:
initWithRTFD:documentAttributes:
另外还支持从HTML数据直接创建
initWithHTML:documentAttributes:
initWithHTML:baseURL:documentAttributes:
读写属性
详细参考NSAttributedString 和 NSMutableAttributedString 的reference
Text kit
好了接下来重新回到我们之前说的Text Kit 和 Core Text中来。 Text Kit是UIKit framework中定义的一组用于提供高性能的排版、布局和展示文字的类和协议,比如展示特别的字间距、行间距、断行规则。 Text Kit中最主要的类之间的数据流图:
-
Text views是指实例化以后的UITextView
-
Text containers是指实例化的NSTextContainer。定义了字符串需要显示的区域,通常是一个矩形。通过继承后自定义他也可以是其他的任意形状。另外通过NSTextContainer.exclusionPath定义的UIBezierPath数组,可以定义在当前区域内不能用于text展示的例外区域。
-
Layout manager是指实例化的NSLayoutManager。映射了字符到字形的对应变换关系。
-
Text storage是指实例化的NSTextStorage。NSTextStorage是NSMutableAttributeString的子类,他保存了我们需要展示的字符串,以及字符串的各种样式
更形象化的展示,如下图所示
如果text在一个text container中展示不完全,那么他就会展示到另外一个text container中(如果有的话)。
代码demo:
NSTextStorage* textStorage = [[NSTextStorage alloc] initWithString:string];
NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init];
[textStorage addLayoutManager:layoutManager];
self.textContainer = [[NSTextContainer alloc] initWithSize:self.view.bounds.size];
[layoutManager addTextContainer:self.textContainer];
UITextView* textView = [[UITextView alloc] initWithFrame:self.view.bounds textContainer:self.textContainer];
[self.view addSubview:textView];
Core Text
位于Text Kit 更底层的 Core Text framework 是一种用于排版和展示文字的技术,他被设计得高效且易用,速度比已有的ATSUI要快2倍以上。自从Core Text随着OSX 10.5(Leopard)的推出以来,很快就取代了ATSUI的地位。
简单的绘制方法
-
OSX 可以通过drawAtPoint:和drawInRect:以及drawWithRect:options:方法绘制在NSView。但是这几个方法不推荐大范围使用,特别是针对频繁的绘制text,更推荐使用Text布局引擎(这个后面会提到)
-
iOS 由于没有NSView的支持,Apple特别提供了CATextLayer来帮助我们绘制。 CATextLayer有一个-(id)String 的属性,可以被设置为NSString或者NSAttrib utedString两种类型。这样我们就可以把CATextLayer加在任何我们想要的view中,用于展示需要的text。
代码例子如下:
NSAttributedString *attributedString ; // 假设这里已经初始化好了
CATextLayer *textLayer = [[CATextLayer alloc ] init];
textLayer = attributedString;
[self.view.layer addSublayer:textLayer];
这里也就解答了前面提到的如何兼容iOS5下通过NSAttributeString来展示带格式的文本了。CATextLayer支持iOS 3.2及以后的版本,所以如果你要支持更早的版本,那么你可能要自己在stackoverflow一下了,呵呵。
Core Text布局引擎和Font api
接下来我们正式回到Core Text的部分。 Core Text主要由两个部分组成,text布局引擎和font API。
-
text布局引擎 text布局引擎将字符生成字形,同时将字形转换成包含多行的frames,lines以及run。而且他还提供了字形和布局相关的数据,例如字形的定位和lines和frames的尺寸。
-
font API 通过NSFont和NSFontDescriptor将iOS开发者喜闻乐见的方法带给了Mac OSX的开发者们。他提供了字体的引用、字体的描述以及访问font数据结构的快捷方法。
-
首先通过已经创建好的CFAttributedStringRef(toll bridge to NSAttributedString),使用下面方法创建一个CTFramesetterRef CTFramesetterRef CTFramesetterCreateWithAttributedString( CFAttributedStringRef string );
-
通过CTFramesetterRef 和 指定的文字显示区域 CGPathRef 创建CTFrameRef。 根据上图所示,同一个CFAttributedStringRef 和 CGPathRef 可以产生一个或者多个的CTFrameRef,在此同时framesetter将段落的样式应用到CTFrameRef上,比如对齐方式,tab位置,行间距,缩进,断行方式等。
CTFrameRef CTFramesetterCreateFrame( CTFramesetterRef framesetter, CFRange stringRange, CGPathRef path, CFDictionaryRef frameAttributes );
-
在CTFramesetterRef 生成 CTFrameRef的同时,也唤起了一个typesetter(排字,通常是CTTypesetterRef)。她的作用是将CFAttributedStringRef中的字符转换成对应的字形。
-
每个段落中(可以理解CTFrameRef代表一个段落)中包含了多行(CTLineRef)。
-
每一行中又包含了多个CTRunRef,是指一行中连续的一段包含同样属性和方向的文字。
自定义图文混排
通常情况下图文混排,还是需要开发者自己做一些事情。至于怎么做,这个就需要用到CTRunDelegate了。
- CTRunDelegate
本身这是Core Text提供的一个用于自定义CTRun排版属性的回调方法,这里的排版属性包括字形的宽度,字形的向上高度,向下高度。
字形宽度可以理解,但是什么是向上高度和向下高度呢?这个请看下图:
其实这个也比较好理解,大家刚学英语的时候,一定用过那种英文练习簿吧。那么倒数第二根线就是原点所在的基线了,往上就是向上高度,往下就是向下高度。
我们可以通过以下方法来创建一个CTRunDelegateRef
CTRunDelegateRef CTRunDelegateCreate(const CTRunDelegateCallbacks* callbacks, void* refCon )
- CTRunDelegateCallbacks
CTRunDelegateCallbacks才是真正定义字形宽度、向上高度和向下高度的结构体,我们看一下他的定义:
typedef struct
{
CFIndex version;
CTRunDelegateDeallocateCallback dealloc;
CTRunDelegateGetAscentCallback getAscent;
CTRunDelegateGetDescentCallback getDescent;
CTRunDelegateGetWidthCallback getWidth;
} CTRunDelegateCallbacks;
其中的dealloc、getAscent、getDescent、getWidth几个属性的类型都差不多
typedef void (*CTRunDelegateDeallocateCallback) (
void* refCon );
typedef CGFloat (*CTRunDelegateGetAscentCallback) (
void* refCon );
typedef CGFloat (*CTRunDelegateGetDescentCallback) (
void* refCon );
typedef CGFloat (*CTRunDelegateGetWidthCallback) (
void* refCon );
c语言中的void* 含义是任意类型的指针,但是我理解这里的参数一般指的是NSAttributeString中当前CTRun对应的属性NSDictionary(如果我理解的不对欢迎指出)
-
首先我们定义好自己的CTRunDelegateCallbacks:
void ACRichTextRunDelegateDeallocCallback(void *refCon) { } CGFloat ACRichTextRunDelegateGetAscentCallback(void *refCon) { CFDictionaryRef attributes = (CFDictionaryRef) refCon; CFStringRef height = CFDictionaryGetValue(attributes, @ATTRIBUTE_IMG_HEIGHT); if (height) { double heightF = CFStringGetDoubleValue(height); return (float) heightF; } return IMG_DEFAULT_HEIGHT; } CGFloat ACRichTextRunDelegateGetDescentCallback(void *refCon) { return 0; } CGFloat ACRichTextRunDelegateGetWidthCallback(void *refCon) { CFDictionaryRef attributes = (CFDictionaryRef) refCon; CFStringRef height = CFDictionaryGetValue(attributes, @ATTRIBUTE_IMG_WIDTH); if (height) { double heightF = CFStringGetDoubleValue(height); return (float) heightF; } return IMG_DEFAULT_HEIGHT; } // 创建我们自己的CTRunDelegateCallbacks 结构体 CTRunDelegateCallbacks imageCallbacks; imageCallbacks.version = kCTRunDelegateVersion1; imageCallbacks.dealloc = ACRichTextRunDelegateDeallocCallback; imageCallbacks.getAscent = ACRichTextRunDelegateGetAscentCallback; imageCallbacks.getDescent = ACRichTextRunDelegateGetDescentCallback; imageCallbacks.getWidth = ACRichTextRunDelegateGetWidthCallback;
-
之后我们把CTRunDelegateCallbacks结构体设置到CFAttributedString中,并通过range指定作用的范围
CFMutableAttributedStringRef text ; // 假设这里已经初始化好 NSDictionary *imgAttributes = component.attributes; CTRunDelegateRef runDelegate = CTRunDelegateCreate(&imageCallbacks, (__bridge void *) imgAttributes); CFAttributedStringSetAttribute(text, CFRangeMake(position, 1), kCTRunDelegateAttributeName, runDelegate); // 把属性设置到 CFAttributedStringSetAttributes(text, CFRangeMake(position, 1), (__bridge CFDictionaryRef) component.attributes, NO); CFRelease(runDelegate);
经过上面这么多步骤,其实只是告诉Core Text 有一个地方需要占多大的位置,这样系统就会在指定的地方把空间腾出来,不绘制文字上去。真正的图像绘制其实还是需要我们自己通过Core Graphic来做。
- 获取CTRun对应的坐标范围
通过创建CTFrameSetterRef,并且获取嵌套的CTFrameRef,以及包含的CTLineRef,从而最终循环到对应的CTRunRef,再通过以下函数获取当前CTRun坐标范围内的相关属性,比如宽度、向上高度和向下高度。
// 后面3个参数ascent/descent/leading都是输出项
double CTRunGetTypographicBounds (
CTRunRef run,
CFRange range,
CGFloat *ascent,
CGFloat *descent,
CGFloat *leading
);
- 自定义绘图
上面获取的只是当前run的属性,真正绘图需要的是相对origin的坐标值,所以在循环CTLine和CTRun的时候,要记录下line和run的origin,并累加起来才是真正相对于坐标原点的偏移量。 有了坐标值后面就是真正的Core Graphic绘图了,限于篇幅不再展开去,具体可以参考Apple的官方文档: