第一次比较深入接触iOS文字排版相关内容是在12年底,实现某IM项目聊天内容的图文混排,照着nimbus的AttributedLabel和Raywenderlish上的这篇文章《 Core Text Tutorial for iOS: Making a Magazine App 》改出了一个比较适用于聊天内容展现的图文混排(文字和表情)控件。
选择自己写而不是直接使用现有第三方库的原因有三:
1. 在这之前也做过一个iOS上的IM产品,当时这个模块并不是我负责,图文混排的实现非常诡异(通过二分法计算出文字所占区域大小),效率极低,所以需要重新做一个效率比较高的控件出来。
2. 看过一些开源的实现,包括OHAttribtuedLabel,DTCoreText和Nimbus,总觉得他们实现插入图片的接口有点别扭,对于上层调用者来说CoreText部分不是完全透明的:调用者需要考虑怎么用自己的图片把原来内容替换掉。(当时的印象,现在具体怎么样已经不清楚了)
3. 这是重新造轮子的机会!
直接拿了Nimbus的AttributedLabel作为基础,然后重新整理图文混排那部分的代码,调整接口,一共也就花了一个晚上的时间:拜一下Nimbus的作者们。后来也根据项目的需求做了一些小改动,比如hack iOS7下不准的问题,支持在Label上添加UIView的特性等等。最新的代码可以在github上找到:M80AttributedLabel。
不过写这篇文章最重要的原因不是为了放个代码出来,而是在闲暇时整理一下iOS/OSX文字排版相关的知识。
文字排版的基础概念
字体(Font)
字符(Character)和字形(Glyphs)
字形描述集(Glyphs Metris)
边框(Bounding Box)
基线(Baseline)
基础原点(Origin)
行间距(Leading)
字间距(Kerning)
上行高度(Ascent)和下行高度(Decent)
红框高度既为当前行的行高,绿线为baseline,绿色到红框上部分为当前行的最大Ascent,绿线到黄线为当前行的最大Desent,而黄框的高即为行间距。由此可以得出:lineHeight = Ascent + |Decent| + Leading。
更加详细的内容可以参考苹果的这篇文档: 《 Cocoa Text Architecture Guide》。当然如果要做到更完善的排版,还需要掌握段落排版(Paragragh Style)相关的知识,但是如果只是完成聊天框内的文字排版,以上的基础知识已经够用了。详细的段落样式相关知识可以参考: 《 Ruler and Paragraph Style Programming Topics 》
CoreText
iOS/OSX中用于描述富文本的类是NSAttributedString,顾名思义,它比NSString多了Attribute的概念。它可以包含很多属性,粗体,斜体,下划线,颜色,背景色等等,每个属性都有其对应的字符区域。在OSX上我们只需解析完毕相应的数据,准备好NSAttributedString即可,底层的绘制完全可以交给相应的控件完成。但是在iOS上就没有这么方便,想要绘制Attributed String就需要用到CoreText了。(当然iOS6之后已经有AttributedLabel了。)
使用CoreText进行NSAttributedString的绘制,最重要的两个概念就是CTFrameSetter和CTFrame。他们的关系如下:
其中CTFramesetter是由CFAttributedString(NSAttributedString)初始化而来,可以认为它是CTFrame的一个Factory,通过传入CGPath生成相应的CTFrame并使用它进行渲染:直接以CTFrame为参数使用CTFrameDraw绘制或者从CTFrame中获取CTLine进行微调后使用CTLineDraw进行绘制。
一个CTFrame是由一行一行的CLine组成,每个CTLine又会包含若干个CTRun(既字形绘制的最小单元),通过相应的方法可以获取到不同位置的CTRun和CTLine,以实现对不同位置touch事件的响应。
图文混排的实现
CoreText实际上并没有相应API直接将一个图片转换为CTRun并进行绘制,它所能做的只是为图片预留相应的空白区域,而真正的绘制则是交由CoreGraphics完成。(像OSX就方便很多,直接将图片打包进NSTextAttachment即可,根本无须操心绘制的事情,所以基于这个想法,M80AttributedLabel的接口和实现也是使用了attachment这么个概念,图片或者UIView都是被当作文字段中的attachment。)
在CoreText中提供了CTRunDelegate这么个Core Foundation类,顾名思义它可以对CTRun进行拓展:AttributedString某个段设置kCTRunDelegateAttributeName属性之后,CoreText使用它生成CTRun是通过当前Delegate的回调来获取自己的ascent,descent和width,而不是根据字体信息。这样就给我们留下了可操作的空间:用一个空白字符作为图片的占位符,设好Delegate,占好位置,然后用CoreGraphics进行图片的绘制。以下就是整个图文混排代码描述的过程:
占位:
1. - ( void )appendAttachment: (M80AttributedLabelAttachment *)attachment
2. {
3. attachment.fontAscent = _fontAscent;
4. attachment.fontDescent = _fontDescent;
5. unichar objectReplacementChar = 0xFFFC;
6. NSString *objectReplacementString = [NSString stringWithCharacters:&objectReplacementChar length:1];
7. NSMutableAttributedString *attachText = [[NSMutableAttributedString alloc]initWithString:objectReplacementString];
8.
9. CTRunDelegateCallbacks callbacks;
10. callbacks.version = kCTRunDelegateVersion1;
11. callbacks.getAscent = ascentCallback;
12. callbacks.getDescent = descentCallback;
13. callbacks.getWidth = widthCallback;
14. callbacks.dealloc = deallocCallback;
15.
16. CTRunDelegateRef delegate = CTRunDelegateCreate(&callbacks, ( void *)attachment);
17. NSDictionary *attr = [NSDictionary dictionaryWithObjectsAndKeys:(__bridge id)delegate,kCTRunDelegateAttributeName, nil];
18. [attachText setAttributes:attr range:NSMakeRange(0, 1)];
19. CFRelease(delegate);
20.
21. [_attachments addObject:attachment];
22. [self appendAttributedText:attachText];
23. }
实现委托回调:
1. CGFloat ascentCallback( void *ref)
2. {
3. M80AttributedLabelAttachment *image = (__bridge M80AttributedLabelAttachment *)ref;
4. CGFloat ascent = 0;
5. CGFloat height = [image boxSize].height;
6. switch (image.alignment)
7. {
8. case M80ImageAlignmentTop:
9. ascent = image.fontAscent;
10. break ;
11. case M80ImageAlignmentCenter:
12. {
13. CGFloat fontAscent = image.fontAscent;
14. CGFloat fontDescent = image.fontDescent;
15. CGFloat baseLine = (fontAscent + fontDescent) / 2 - fontDescent;
16. ascent = height / 2 + baseLine;
17. }
18. break ;
19. case M80ImageAlignmentBottom:
20. ascent = height - image.fontDescent;
21. break ;
22. default :
23. break ;
24. }
25. return ascent;
26. }
27.
28. CGFloat descentCallback( void *ref)
29. {
30. M80AttributedLabelAttachment *image = (__bridge M80AttributedLabelAttachment *)ref;
31. CGFloat descent = 0;
32. CGFloat height = [image boxSize].height;
33. switch (image.alignment)
34. {
35. case M80ImageAlignmentTop:
36. {
37. descent = height - image.fontAscent;
38. break ;
39. }
40. case M80ImageAlignmentCenter:
41. {
42. CGFloat fontAscent = image.fontAscent;
43. CGFloat fontDescent = image.fontDescent;
44. CGFloat baseLine = (fontAscent + fontDescent) / 2 - fontDescent;
45. descent = height / 2 - baseLine;
46. }
47. break ;
48. case M80ImageAlignmentBottom:
49. {
50. descent = image.fontDescent;
51. break ;
52. }
53. default :
54. break ;
55. }
56.
57. return descent;
58.
59. }
60.
61. CGFloat widthCallback( void * ref)
62. {
63. M80AttributedLabelAttachment *image = (__bridge M80AttributedLabelAttachment *)ref;
64. return [image boxSize].width;
65. }
真正的绘制:
1. - ( void )drawAttachments
2. {
3. if ([_attachments count] == 0)
4. {
5. return ;
6. }
7. CGContextRef ctx = UIGraphicsGetCurrentContext();
8. if (ctx == nil)
9. {
10. return ;
11. }
12.
13. CFArrayRef lines = CTFrameGetLines(_textFrame);
14. CFIndex lineCount = CFArrayGetCount(lines);
15. CGPoint lineOrigins[lineCount];
16. CTFrameGetLineOrigins(_textFrame, CFRangeMake(0, 0), lineOrigins);
17. NSInteger numberOfLines = [self numberOfDisplayedLines];
18. for (CFIndex i = 0; i < numberOfLines; i++)
19. {
20. CTLineRef line = CFArrayGetValueAtIndex(lines, i);
21. CFArrayRef runs = CTLineGetGlyphRuns(line);
22. CFIndex runCount = CFArrayGetCount(runs);
23. CGPoint lineOrigin = lineOrigins[i];
24. CGFloat lineAscent;
25. CGFloat lineDescent;
26. CTLineGetTypographicBounds(line, &lineAscent, &lineDescent, NULL);
27. CGFloat lineHeight = lineAscent + lineDescent;
28. CGFloat lineBottomY = lineOrigin.y - lineDescent;
29.
30.
31. // intersect with the range.
32. for (CFIndex k = 0; k < runCount; k++)
33. {
34. CTRunRef run = CFArrayGetValueAtIndex(runs, k);
35. NSDictionary *runAttributes = (NSDictionary *)CTRunGetAttributes(run);
36. CTRunDelegateRef delegate = (__bridge CTRunDelegateRef)[runAttributes valueForKey:(id)kCTRunDelegateAttributeName];
37. if (nil == delegate)
38. {
39. continue ;
40. }
41. M80AttributedLabelAttachment* attributedImage = (M80AttributedLabelAttachment *)CTRunDelegateGetRefCon(delegate);
42.
43. CGFloat ascent = 0.0f;
44. CGFloat descent = 0.0f;
45. CGFloat width = (CGFloat)CTRunGetTypographicBounds(run,
46. CFRangeMake(0, 0),
47. &ascent,
48. &descent,
49. NULL);
50.
51. CGFloat imageBoxHeight = [attributedImage boxSize].height;
52. CGFloat xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, nil);
53.
54. CGFloat imageBoxOriginY = 0.0f;
55. switch (attributedImage.alignment)
56. {
57. case M80ImageAlignmentTop:
58. imageBoxOriginY = lineBottomY + (lineHeight - imageBoxHeight);
59. break ;
60. case M80ImageAlignmentCenter:
61. imageBoxOriginY = lineBottomY + (lineHeight - imageBoxHeight) / 2.0;
62. break ;
63. case M80ImageAlignmentBottom:
64. imageBoxOriginY = lineBottomY;
65. break ;
66. }
67.
68. CGRect rect = CGRectMake(lineOrigin.x + xOffset, imageBoxOriginY, width, imageBoxHeight);
69. UIEdgeInsets flippedMargins = attributedImage.margin;
70. CGFloat top = flippedMargins.top;
71. flippedMargins.top = flippedMargins.bottom;
72. flippedMargins.bottom = top;
73.
74. CGRect attatchmentRect = UIEdgeInsetsInsetRect(rect, flippedMargins);
75.
76. id content = attributedImage.content;
77. if ([content isKindOfClass:[UIImage class ]])
78. {
79. CGContextDrawImage(ctx, attatchmentRect, ((UIImage *)content).CGImage);
80. }
81. else if ([content isKindOfClass:[UIView class ]])
82. {
83. UIView *view = (UIView *)content;
84. if (view.superview == nil)
85. {
86. [self addSubview:view];
87. }
88. CGRect viewFrame = CGRectMake(attatchmentRect.origin.x,
89. self.bounds.size.height - attatchmentRect.origin.y - attatchmentRect.size.height,
90. attatchmentRect.size.width,
91. attatchmentRect.size.height);
92. [view setFrame:viewFrame];
93. }
94. else
95. {
96. NSLog(@ "Attachment Content Not Supported %@" ,content);
97. }
98.
99. }
100. }
101. }