iOS 文字排版 (CoreText) 那些事
06 Mar 2014
第一次比较深入接触 iOS 文字排版相关内容是在 12 年底,实现某 IM 项目聊天内容的图文混排,照着 nimbus 的 AttributedLabel 和 Raywenderlish 上的这篇文章 《Core Text Tutorial for iOS: Making a Magazine App》 改出了一个比较适用于聊天内容展现的图文混排 (文字和表情) 控件。选择自己写而不直接使用现有第三方库的原因有三:
在这之前也做过一个 iOS 上的 IM 产品,当时这个模块并不是我负责,图文混排的实现非常诡异(通过二分法计算出文字所占区域大小),效率极低,所以需要重新做一个效率比较高的控件出来。
看过一些开源的实现,包括 OHAttribtuedLabel,DTCoreText 和 Nimbus,总觉得他们实现插入图片的接口有点别扭,对于上层调用者来说 CoreText 部分不是完全透明的:调用者需要考虑怎么用自己的图片把原来内容替换掉。(当时的印象,现在具体怎么样已经不清楚了)
这是重新造轮子的机会!
直接拿了 Nimbus 的 AttributedLabel 作为基础,然后重新整理图文混排那部分的代码,调整接口,一共也就花了一个晚上的时间:拜一下 Nimbus 的作者。后来也根据项目的需求做了一些小改动,比如 hack iOS7 下计算 size 不准的问题,Label 上支持添加 UIView 的特性等等。最新的代码可以在 github 上找到:M80AttributedLabel 。
不过写这篇文章最重要的原因不是为了放个代码出来,而是在闲暇时整理一下 iOS/OSX 文字排版相关的知识。
文字排版的基础概念
红框高度既为当前行的行高,绿线为 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 进行图片的绘制。以下就是整个图文混排代码描述的过程:
占位:
- () appendAttachment : ( M80AttributedLabelAttachment * ) attachment
{
attachment . fontAscent = _fontAscent ;
attachment . fontDescent = _fontDescent ;
unichar objectReplacementChar = 0xFFFC ;
NSString * objectReplacementString = [ NSString stringWithCharacters : & objectReplacementChar length : 1 ];
NSMutableAttributedString * attachText = [[ NSMutableAttributedString alloc ] initWithString : objectReplacementString ];
CTRunDelegateCallbacks callbacks ;
callbacks . version = kCTRunDelegateVersion1 ;
callbacks . getAscent = ascentCallback ;
callbacks . getDescent = descentCallback ;
callbacks . getWidth = widthCallback ;
callbacks . dealloc = deallocCallback ;
CTRunDelegateRef delegate = CTRunDelegateCreate ( & callbacks , ( * ) attachment );
NSDictionary * attr = [ NSDictionary dictionaryWithObjectsAndKeys :( __bridge id ) delegate , kCTRunDelegateAttributeName , nil ];
[ attachText setAttributes : attr range : NSMakeRange ( 0 , 1 )];
CFRelease ( delegate );
[ _attachments addObject : attachment ];
[ self appendAttributedText : attachText ];
}
实现委托回调:
CGFloat ascentCallback ( * )
{
M80AttributedLabelAttachment * image = ( __bridge M80AttributedLabelAttachment * );
CGFloat ascent = 0 ;
CGFloat height = [ image boxSize ]. height ;
switch ( image . alignment )
{
M80ImageAlignmentTop:
ascent = image . fontAscent ;
break ;
M80ImageAlignmentCenter:
{
CGFloat fontAscent = image . fontAscent ;
CGFloat fontDescent = image . fontDescent ;
CGFloat baseLine = ( fontAscent + fontDescent ) / 2 - fontDescent ;
ascent = height / 2 + baseLine ;
}
break ;
M80ImageAlignmentBottom:
ascent = height - image . fontDescent ;
break ;
default:
break ;
}
return ascent ;
}
CGFloat descentCallback ( * )
{
M80AttributedLabelAttachment * image = ( __bridge M80AttributedLabelAttachment * );
CGFloat descent = 0 ;
CGFloat height = [ image boxSize ]. height ;
switch ( image . alignment )
{
M80ImageAlignmentTop:
{
descent = height - image . fontAscent ;
break ;
}
M80ImageAlignmentCenter:
{
CGFloat fontAscent = image . fontAscent ;
CGFloat fontDescent = image . fontDescent ;
CGFloat baseLine = ( fontAscent + fontDescent ) / 2 - fontDescent ;
descent = height / 2 - baseLine ;
}
break ;
M80ImageAlignmentBottom:
{
descent = image . fontDescent ;
break ;
}
default:
break ;
}
return descent ;
}
CGFloat widthCallback ( * )
{
M80AttributedLabelAttachment * image = ( __bridge M80AttributedLabelAttachment * );
return [ image boxSize ]. width ;
}
真正的绘制:
- () drawAttachments
{
([ _attachments count ] == 0 )
{
return ;
}
CGContextRef ctx = UIGraphicsGetCurrentContext ();
( ctx == nil )
{
return ;
}
CFArrayRef lines = CTFrameGetLines ( _textFrame );
CFIndex lineCount = CFArrayGetCount ( lines );
CGPoint lineOrigins [ lineCount ];
CTFrameGetLineOrigins ( _textFrame , CFRangeMake ( 0 , 0 ), lineOrigins );
NSInteger numberOfLines = [ self numberOfDisplayedLines ];
( CFIndex i = 0 ; i < numberOfLines ; i ++ )
{
CTLineRef line = CFArrayGetValueAtIndex ( lines , i );
CFArrayRef runs = CTLineGetGlyphRuns ( line );
CFIndex runCount = CFArrayGetCount ( runs );
CGPoint lineOrigin = lineOrigins [ i ];
CGFloat lineAscent ;
CGFloat lineDescent ;
CTLineGetTypographicBounds ( line , & lineAscent , & lineDescent , NULL );
CGFloat lineHeight = lineAscent + lineDescent ;
CGFloat lineBottomY = lineOrigin . y - lineDescent ;
// Iterate through each of the "runs" (i.e. a chunk of text) and find the runs that
// intersect with the range.
( CFIndex k = 0 ; k < runCount ; k ++ )
{
CTRunRef run = CFArrayGetValueAtIndex ( runs , k );
NSDictionary * runAttributes = ( NSDictionary * ) CTRunGetAttributes ( run );
CTRunDelegateRef delegate = ( __bridge CTRunDelegateRef )[ runAttributes valueForKey :( id ) kCTRunDelegateAttributeName ];
( nil == delegate )
{
continue ;
}
M80AttributedLabelAttachment * attributedImage = ( M80AttributedLabelAttachment * ) CTRunDelegateGetRefCon ( delegate );
CGFloat ascent = 0 . 0 f ;
CGFloat descent = 0 . 0 f ;
CGFloat width = ( CGFloat ) CTRunGetTypographicBounds ( run ,
CFRangeMake ( 0 , 0 ),
& ascent ,
& descent ,
NULL );
CGFloat imageBoxHeight = [ attributedImage boxSize ]. height ;
CGFloat xOffset = CTLineGetOffsetForStringIndex ( line , CTRunGetStringRange ( run ). location , nil );
CGFloat imageBoxOriginY = 0 . 0 f ;
switch ( attributedImage . alignment )
{
M80ImageAlignmentTop:
imageBoxOriginY = lineBottomY + ( lineHeight - imageBoxHeight );
break ;
M80ImageAlignmentCenter:
imageBoxOriginY = lineBottomY + ( lineHeight - imageBoxHeight ) / 2 . 0 ;
break ;
M80ImageAlignmentBottom:
imageBoxOriginY = lineBottomY ;
break ;
}
CGRect rect = CGRectMake ( lineOrigin . x + xOffset , imageBoxOriginY , width , imageBoxHeight );
UIEdgeInsets flippedMargins = attributedImage . margin ;
CGFloat top = flippedMargins . top ;
flippedMargins . top = flippedMargins . bottom ;
flippedMargins . bottom = top ;
CGRect attatchmentRect = UIEdgeInsetsInsetRect ( rect , flippedMargins );
id content = attributedImage . content ;
([ content isKindOfClass :[ UIImage class ]])
{
CGContextDrawImage ( ctx , attatchmentRect , (( UIImage * ) content ). CGImage );
}
([ content isKindOfClass :[ UIView class ]])
{
UIView * view = ( UIView * ) content ;
( view . superview == nil )
{
[ self addSubview : view ];
}
CGRect viewFrame = CGRectMake ( attatchmentRect . origin . x ,
self . bounds . size . height - attatchmentRect . origin . y - attatchmentRect . size . height ,
attatchmentRect . size . width ,
attatchmentRect . size . height );
[ view setFrame : viewFrame ];
}
{
NSLog ( @"Attachment Content Not Supported %@" , content );
}
}
}
}
详细的代码可以直接在 github 上查看:https://github.com/xiangwangfeng/M80AttributedLabel/ 。