使用 CoreText 实现图文混排

iOS没有现成的支持图文混排的控件,而要用多个基础控件组合拼成图文混排这样复杂的排版,是件很苦逼的事情。对此的解决方案有使用CoreText进行绘制,或者使用TextKit。本文主要讲解对于CoreText的使用。

一、Core Text简介

CoreText是基于IOS3.2及OSX10.5的用于文字精细排版的文本框架。它直接与Core Graphics(又称:Quartz)交互,将需要显示的文本内容,位置,字体,字形直接传递给Quartz,与其他UI组件相比,能更高效的进行渲染。

Paste_Image.png

Core Text 架构图

二、CoreText与UIWebView在排版方面的优劣比较
UIWebView也常用于处理复杂的排版,对应排版他们之间的优劣如下(摘自 《iOS开发进阶》—— 唐巧):

CoreText占用的内容更少,渲染速度更快。UIWebView占用的内存多,渲染速度慢。
CoreText在渲染界面的前就可以精确地获得显示内容的高度(只要有了CTFrame即可),而WebView只有渲染出内容后,才能获得内容的高度(而且还需要用JavaScript代码来获取)。
CoreText的CTFrame可以在后台线程渲染,UIWebView的内容只能在主线程(UI线程)渲染。
基于CoreText可以做更好的原生交互效果,交互效果可以更加细腻。而UIWebView的交互效果都是用JavaScript来实现的,在交互效果上会有一些卡顿的情况存在。例如,在UIWebView下,一个简单的按钮按下的操作,都无法做出原生按钮的即时和细腻的按下效果。

CoreText排版的劣势:

CoreText渲染出来的内容不能像UIWebView那样方便地支持内容的复制。
基于CoreText来排版需要自己处理很多复制的逻辑,例如需要自己处理图片与文字混排相关的逻辑,也需要自己实现连接点击操作的支持。

在业界有很多应用都采用CoreText技术进行排版,例如新浪微博客户端,多看阅读客户端,猿题库等等。

三、绘制纯文本

我们创建一个继承于UIView的类,重写他的drawRect方法,来绘制纯文本。

- (void)drawRect:(CGRect)rect {
    
    [super drawRect:rect];
    
    // 步骤1:得到当前用于绘制画布的上下文,用于后续将内容绘制在画布上
    // 因为Core Text要配合Core Graphic 配合使用的,如Core Graphic一样,绘图的时候需要获得当前的上下文进行绘制
    CGContextRef context = UIGraphicsGetCurrentContext();
    
    // 步骤2:翻转当前的坐标系(因为对于底层绘制引擎来说,屏幕左下角为(0,0))
    CGContextSetTextMatrix(context, CGAffineTransformIdentity);
    CGContextTranslateCTM(context, 0, self.bounds.size.height);
    CGContextScaleCTM(context, 1.0, -1.0);
    
    // 步骤3:创建绘制区域
    CGMutablePathRef path = CGPathCreateMutable();
    CGPathAddEllipseInRect(path, NULL, self.bounds);
    
    // 步骤4:创建需要绘制的文字与计算需要绘制的区域
    NSMutableAttributedString *attrString = [[NSMutableAttributedString alloc] initWithString:@"iOS程序在启动时会创建一个主线程,而在一个线程只能执行一件事情,如果在主线程执行某些耗时操作,例如加载网络图片,下载资源文件等会阻塞主线程(导致界面卡死,无法交互),所以就需要使用多线程技术来避免这类情况。iOS中有三种多线程技术 NSThread,NSOperation,GCD,这三种技术是随着IOS发展引入的,抽象层次由低到高,使用也越来越简单。"];

    // 步骤5:根据AttributedString生成CTFramesetterRef
    CTFramesetterRef frameSetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attrString);
    CTFrameRef frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, [attrString length]), path, NULL);

    // 步骤6:进行绘制
    CTFrameDraw(frame, context);

    // 步骤7.内存管理
    CFRelease(frame);
    CFRelease(path);
    CFRelease(frameSetter);
}
Paste_Image.png

CoreText绘制纯文本
四、关于坐标系

上诉代码的步骤2对绘图的坐标系进行了处理,因为在iOS UIKit中,UIView是以左上角为原点,而Core Text一开始的定位是使用与桌面应用的排版系统,桌面应用的坐标系是以左下角为原点,即Core Text在绘制的时候也是参照左下角为原点进行绘制的,所以需要对当前的坐标系进行处理。

实际上,Core Graphic 中的context也是以左下角为原点的, 但是为什么我们用Core Graphic 绘制一些简单的图形的时候不需要对坐标系进行处理喃,是因为通过这个方法UIGraphicsGetCurrentContext()来获得的当前context是已经被处理过的了,用下面方法可以查看指定的上下文的当前图形状态变换矩阵。

NSLog(@"当前context的变换矩阵 %@", NSStringFromCGAffineTransform(CGContextGetCTM(context)));

打印结果为[2, 0, 0, -2, 0, 654],可以发现变换矩阵与CGAffineTransformIdentity的值[1, 0, 0, 1, 0, 0]是不相同的,并且与设备是否为Retina屏和设备尺寸相关。他的作用是将上下文空间坐标系进行翻转,并使原来的左下角原点变成右上角是原点,并将向上为正y轴变为向下为正y轴。 所以在使用drawRect的时候,当前的context已经被做了一次翻转,如果不对当前的坐标系进行处理,会发现,绘制出来的文字是镜像上下颠倒的,如图

Paste_Image.png

不处理context
所以需要先重置当前的坐标系翻转状态,在进行一次翻转,处理之后的矩阵为[2, 0, -0, 2, 0, 0],函数CGContextTranslateCTM的作用变换坐标系中的原点,函数CGContextScaleCTM的作用是改变用户坐标系统的规模比例。
五、自定义文本的颜色,字体与行间距

可以看到我们使用了NSMutableAttributedString这个类来描述需要绘制的文字,而一个NSMutableAttributedString对象可以包含很多属性,每一个属性都有起对应的字符区域,我们可以用这些属性来描述文本中特殊的颜色和字体。

- (void)drawRect:(CGRect)rect {

    // 省略前面的步骤1-4

    // 步骤8:设置部分文字颜色
    [attrString addAttribute:(id)kCTForegroundColorAttributeName value:[UIColor greenColor] range:NSMakeRange(10, 10)];
    
    // 设置部分文字
    CGFloat fontSize = 20;
    CTFontRef fontRef = CTFontCreateWithName((CFStringRef)@"ArialMT", fontSize, NULL);
    [attrString addAttribute:(id)kCTFontAttributeName value:(__bridge id)fontRef range:NSMakeRange(15, 10)];
    CFRelease(fontRef);
   
    // 设置行间距
    CGFloat lineSpacing = 10;
    const CFIndex kNumberOfSettings = 3;
    CTParagraphStyleSetting theSettings[kNumberOfSettings] = {
        {kCTParagraphStyleSpecifierLineSpacingAdjustment, sizeof(CGFloat), &lineSpacing},
        {kCTParagraphStyleSpecifierMaximumLineSpacing, sizeof(CGFloat), &lineSpacing},
        {kCTParagraphStyleSpecifierMinimumLineSpacing, sizeof(CGFloat), &lineSpacing}
    };
    CTParagraphStyleRef theParagraphRef = CTParagraphStyleCreate(theSettings, kNumberOfSettings);
    [attrString addAttribute:(id)kCTParagraphStyleAttributeName value:(__bridge id)theParagraphRef range:NSMakeRange(0, attrString.length)];
    CFRelease(theParagraphRef);
    // 省略之后的步骤5-7
}
Paste_Image.png

自定义文本属性

提示:在配置NSMutableAttributedString?的Attribute的时候,用到了很多这样的(__bridge?id)标识,来解释下:这个因为addAttribute:是OC的方法,需要Object C 对象,而CTParagraphStyleRef这些是由C语言实现的Core Foundation Framework 框架中的对象,这两种类型可以相互转换和操作。Core Foundation Framework 框架中的对象也有引用计数的概念,但是不是Cocoa Framework中的release/retain不同,而是使用自身的CFRetain/CFRelease接口,在使用的时候要多加注意引用和释放的问题, 更加详细的解释可以参照这篇文章
六、图文混排
终于要开始进行图文混排了,上面说了那么多,我们来进行一个小结,下图是CoreText绘制的流程图与CTFrame和CTLine,CTRun之间的关系:

Paste_Image.png

CoreText绘制的流程图,CTFrame和CTLine CTRun之间的关系

我们来解释一下这些类:

CFAttributedStringRef :属性字符串,用于存储需要绘制的文字字符和字符属性

CTFramesetterRef:通过CFAttributedStringRef进行初始化,作为CTFrame对象的生产工厂,负责根据path创建对应的CTFrame

CTFrame:用于绘制文字的类,可以通过CTFrameDraw函数,直接将文字绘制到context上

CTLine:在CTFrame内部是由多个CTLine来组成的,每个CTLine代表一行

CTRun:每个CTLine又是由多个CTRun组成的,每个CTRun代表一组显示风格一致的文本

实际上CoreText是不直接支持绘制图片的,但是我们可以先在需要显示图片的地方用一个特殊的空白占位符代替,同时设置该字体的CTRunDelegate信息为要显示的图片的宽度和高度,这样绘制文字的时候就会先把图片的位置留出来,再在drawRect方法里面用CGContextDrawImage绘制图片。

- (void)drawRect:(CGRect)rect {
    
    [super drawRect:rect];
    // 省略步骤1-4  ,步骤8
    // 步骤9:图文混排部分
    // CTRunDelegateCallbacks:一个用于保存指针的结构体,由CTRun delegate进行回调
    CTRunDelegateCallbacks callbacks;
    memset(&callbacks, 0, sizeof(CTRunDelegateCallbacks));
    callbacks.version = kCTRunDelegateVersion1;
    callbacks.getAscent = ascentCallback;
    callbacks.getDescent = descentCallback;
    callbacks.getWidth = widthCallback;
    
    // 图片信息字典
    NSDictionary *imgInfoDic = @{@"width":@100,@"height":@30};
    
    // 设置CTRun的代理
    CTRunDelegateRef delegate = CTRunDelegateCreate(&callbacks, (__bridge void *)imgInfoDic);
    
    // 使用0xFFFC作为空白的占位符
    unichar objectReplacementChar = 0xFFFC;
    NSString *content = [NSString stringWithCharacters:&objectReplacementChar length:1];
    NSMutableAttributedString *space = [[NSMutableAttributedString alloc] initWithString:content];
    CFAttributedStringSetAttribute((CFMutableAttributedStringRef)space, CFRangeMake(0, 1), kCTRunDelegateAttributeName, delegate);
    CFRelease(delegate);
 
    // 将创建的空白AttributedString插入进当前的attrString中,位置可以随便指定,不能越界
    [attrString insertAttributedString:space atIndex:50];
    
    // 省略步骤5-6
    
    // 步骤10:绘制图片
    UIImage *image = [UIImage imageNamed:@"coretext-img-1.png"];
    CGContextDrawImage(context, [self calculateImagePositionInCTFrame:frame], image.CGImage);
   
    // 省略步骤7
 }
 
 #pragma mark - CTRun delegate 回调方法

 static CGFloat ascentCallback(void *ref) {
    
    return [(NSNumber *)[(__bridge NSDictionary *)ref objectForKey:@"height"] floatValue];
 }

 static CGFloat descentCallback(void *ref) {
    
    return 0;
 }
 
 static CGFloat widthCallback(void *ref) {
    
    return [(NSNumber *)[(__bridge NSDictionary *)ref objectForKey:@"width"] floatValue];
 }
 
 /**
 *  根据CTFrameRef获得绘制图片的区域
 *
 *  @param ctFrame CTFrameRef对象
 *
 *  @return绘制图片的区域
 */
 - (CGRect)calculateImagePositionInCTFrame:(CTFrameRef)ctFrame {
    
    // 获得CTLine数组
    NSArray *lines = (NSArray *)CTFrameGetLines(ctFrame);
    NSInteger lineCount = [lines count];
    CGPoint lineOrigins[lineCount];
    CTFrameGetLineOrigins(ctFrame, CFRangeMake(0, 0), lineOrigins);
    
    // 遍历每个CTLine
    for (NSInteger i = 0 ; i < lineCount; i++) {
        
        CTLineRef line = (__bridge CTLineRef)lines[i];
        NSArray *runObjArray = (NSArray *)CTLineGetGlyphRuns(line);
        
        // 遍历每个CTLine中的CTRun
        for (id runObj in runObjArray) {
            
            CTRunRef run = (__bridge CTRunRef)runObj;
            NSDictionary *runAttributes = (NSDictionary *)CTRunGetAttributes(run);
            CTRunDelegateRef delegate = (__bridge CTRunDelegateRef)[runAttributes valueForKey:(id)kCTRunDelegateAttributeName];
            if (delegate == nil) {
                continue;
            }
            
            NSDictionary *metaDic = CTRunDelegateGetRefCon(delegate);
            if (![metaDic isKindOfClass:[NSDictionary class]]) {
                continue;
            }
            
            CGRect runBounds;
            CGFloat ascent;
            CGFloat descent;
            
            runBounds.size.width = CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &ascent, &descent, NULL);
            runBounds.size.height = ascent + descent;
            
            CGFloat xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, NULL);
            runBounds.origin.x = lineOrigins[i].x + xOffset;
            runBounds.origin.y = lineOrigins[i].y;
            runBounds.origin.y -= descent;
            
            CGPathRef pathRef = CTFrameGetPath(ctFrame);
            CGRect colRect = CGPathGetBoundingBox(pathRef);
            
            CGRect delegateBounds = CGRectOffset(runBounds, colRect.origin.x, colRect.origin.y);
            return delegateBounds;
        }
    }
    return CGRectZero;
 }

至此我们就完成了使用CoreText进行图文混排,上面获得图片位置的方法只能获得第一张图片位置,大家可以自行完善一下,用数组来进行存储图片绘制区域。唐巧在《iOS开发进阶》一书中更多的介绍了对CoreText的封装,感兴趣的可以看看。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,826评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,968评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,234评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,562评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,611评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,482评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,271评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,166评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,608评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,814评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,926评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,644评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,249评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,866评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,991评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,063评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,871评论 2 354

推荐阅读更多精彩内容

  • 系列文章: CoreText实现图文混排 CoreText实现图文混排之点击事件 CoreText实现图文混排之文...
    老司机Wicky阅读 40,161评论 221 432
  • CoreText是一个进阶的比较底层的布局文本和处理字体的技术,CoreText API在OS X v10.5 和...
    smalldu阅读 13,444评论 18 129
  • 1.iOS中的round、ceil、floor函数略解 round如果参数是小数,则求本身的四舍五入.ceil如果...
    K_Gopher阅读 1,186评论 1 0
  • 最近在网上看了一些大牛的文章,自己也试着写了一下,感觉图文混排真的很强大。 废话不多说,开始整 先上效果图跟代码,...
    AllureJM阅读 987评论 0 1
  • 过度手淫会产生内疚感,依赖感,神经衰弱,而且容易反应慢半拍,变得忧郁,对外界敏感,所以注意有欲望的时候多和人相处,...
    ArimaKisho阅读 244评论 0 0