基于 CoreText 的排版引擎

本章前言

使用 CoreText 技术,我们可以对富文本进行复杂的排版。经过一些简单的扩展,我们还可以实现对于图片,链接的点击效果。CoreText 技术相对于 UIWebView,有着更少的内存占用,以及可以在后台渲染的优点,非常适合用于内容的排版工作。

本章我们将从最基本的开始,一步一步完成一个支持图文混排、支持图片和链接点击的排版引擎。

CoreText 简介

CoreText 是用于处理文字和字体的底层技术。它直接和 Core Graphics(又被称为 Quartz)打交道。Quartz 是一个 2D 图形渲染引擎,能够处理 OSX 和 iOS 中的图形显示。

Quartz 能够直接处理字体(font)和字形(glyphs),将文字渲染到界面上,它是基础库中唯一能够处理字形的模块。因此,CoreText 为了排版,需要将显示的文本内容、位置、字体、字形直接传递给 Quartz。相比其它 UI 组件,由于 CoreText 直接和 Quartz 来交互,所以它具有高速的排版效果。

下图是 CoreText 的架构图,可以看到,CoreText 处于非常底层的位置,上层的 UI 控件(包括 UILabel,UITextField 以及 UITextView)和 UIWebView 都是基于 CoreText 来实现的。

注意:这个是 iOS7 之后的架构图,在 iOS7 以前,并没有图中的 Text Kit 类,不过 CoreText 仍然是处在最底层直接和 Core Graphics 打交道的模块。

UIWebView 也是处理复杂的文字排版的备选方案。对于排版,基于 CoreText 和基于 UIWebView 相比,前者有以下好处:

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

当然,基于 CoreText 的排版方案也有一些劣势:

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

在业界,很多应用都采用了基于 CoreText 技术的排版方案,例如:新浪微博客户端,多看阅读客户端。我所在的创业公司的猿题库,也使用了自己基于 CoreText 技术实现的排版引擎,下图是我们产品的一个图文混排的界面(其中所有公式都是用图片的方式呈现的),可以看到,图片和文字排版效果很好。

基于 CoreText 的基础排版引擎

不带图片的排版引擎

下面我们来尝试完成一个基于 CoreText 的排版引擎。我们将从最简单的排版功能开始,然后逐步支持图文混排,链接点击等功能。

首先我们来尝试完成一个不支持图片内容的纯文字排版引擎。

注意 1:由于整个排版引擎的代码太多,为方便读者阅读,文章中只会列出最关键的核心代码,完整的代码请参考本书对应的 github 项目,项目地址是:https://github.com/tangqiaoboy/iOS-Pro

能输出 Hello World 的 CoreText 工程

操作步骤

我们首先新建一个 Xcode 工程,步骤如下:

  1. 打开 Xcode,选择 “File”–>“New”–>“Project”, 在弹出的对话框中,选择 “Single View Application”,然后点击 “Next”。(图 2)
  2. 接着填上项目名 CoreTextDemo,然后点击 “Next”。(图 3)
  3. 选择保存目录后,我们就成功创建了一个空的工程。

图 2

图 3

在工程目录 “CoreTextDemo” 上右击,选择 “New File”, 然后填入类名CTDisplayView, 并且让它的父类是 UIView。(如下图)

接着,我们在CTDisplayView.m文件中,让其 import 头文件CoreText/CoreText.h,接着输入以下代码来实现其drawRect方法:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
#import "CTDisplayView.h" #import "CoreText/CoreText.h"  @implementation CTDisplayView  - (void)drawRect:(CGRect)rect {  [super drawRect:rect];   // 步骤 1  CGContextRef context = UIGraphicsGetCurrentContext();   // 步骤 2  CGContextSetTextMatrix(context, CGAffineTransformIdentity);  CGContextTranslateCTM(context, 0, self.bounds.size.height);  CGContextScaleCTM(context, 1.0, -1.0);   // 步骤 3  CGMutablePathRef path = CGPathCreateMutable();  CGPathAddRect(path, NULL, self.bounds);   // 步骤 4  NSAttributedString *attString = [[NSAttributedString alloc] initWithString:@"Hello World!"];  CTFramesetterRef framesetter =  CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attString);  CTFrameRef frame =  CTFramesetterCreateFrame(framesetter,  CFRangeMake(0, [attString length]), path, NULL);   // 步骤 5  CTFrameDraw(frame, context);   // 步骤 6  CFRelease(frame);  CFRelease(path);  CFRelease(framesetter); }  @end 

打开程序的 Storyboard 文件:Main_iPhone.storyboard:执行下面 2 步:

  1. 将一个 UIView 控件拖动到主界面正中间。(如下图步骤 1)
  2. 将该 UIView 控件的类名从UIView修改为CTDisplayView。(如下图步骤 2)

之后,我们运行程序,就可以看到,Hello World 出现在程序正中间了。如下图。

代码解释

下面解释一下drawRect方法主要的步骤:

  1. 得到当前绘制画布的上下文,用于后续将内容绘制在画布上。
  2. 将坐标系上下翻转。对于底层的绘制引擎来说,屏幕的左下角是(0, 0)坐标。而对于上层的 UIKit 来说,左上角是 (0, 0) 坐标。所以我们为了之后的坐标系描述按 UIKit 来做,所以先在这里做一个坐标系的上下翻转操作。翻转之后,底层和上层的 (0, 0) 坐标就是重合的了。

    为了加深理解,我们将这部分的代码块注释掉,你会发现,整个Hello World界面将上下翻转,如下图所示。

  3. 创建绘制的区域,CoreText 本身支持各种文字排版的区域,我们这里简单地将 UIView 的整个界面作为排版的区域。

为了加深理解,我们将该步骤的代码替换成如下代码,测试设置不同的绘制区域带来的界面变化。

1 2 3 4 5 6 7 8 9 10
// 步骤 3 CGMutablePathRef path = CGPathCreateMutable(); CGPathAddEllipseInRect(path, NULL, self.bounds);  // 步骤 4 NSAttributedString *attString = [[NSAttributedString alloc] initWithString:@"Hello World! "  " 创建绘制的区域,CoreText 本身支持各种文字排版的区域,"  " 我们这里简单地将 UIView 的整个界面作为排版的区域。"  " 为了加深理解,建议读者将该步骤的代码替换成如下代码,"  " 测试设置不同的绘制区域带来的界面变化。"]; 

执行结果如下图所示:

代码基本的宏定义和 Category

为了方便我们的代码编写,我在CoreTextDemo-Prefix.pch文件中增加了以下基本的宏定义,以方便我们使用 NSLog 和 UIColor。

1 2 3 4 5 6 7 8 9 10
 #ifdef DEBUG #define debugLog(...) NSLog(__VA_ARGS__) #define debugMethod() NSLog(@"%s", __func__) #else #define debugLog(...) #define debugMethod() #endif  #define RGB(A, B, C)    [UIColor colorWithRed:A/255.0 green:B/255.0 blue:C/255.0 alpha:1.0] 

我也为 UIView 的 frame 调整增加了一些扩展,可以方便地调整 UIView 的 x, y, width, height 等值。部分关键代码如下(完整的代码请查看示例工程):

UIView+frameAdjust.h 文件:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
#import <Foundation/Foundation.h>  @interface UIView (frameAdjust)  - (CGFloat)x; - (void)setX:(CGFloat)x;  - (CGFloat)y; - (void)setY:(CGFloat)y;  - (CGFloat)height; - (void)setHeight:(CGFloat)height;  - (CGFloat)width; - (void)setWidth:(CGFloat)width;  @end

UIView+frameAdjust.m 文件:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
@implementation UIView (frameAdjust) - (CGFloat)x {  return self.frame.origin.x; }  - (void)setX:(CGFloat)x {  self.frame = CGRectMake(x, self.y, self.width, self.height); }  - (CGFloat)y {  return self.frame.origin.y; }  - (void)setY:(CGFloat)y {  self.frame = CGRectMake(self.x, y, self.width, self.height); }  - (CGFloat)height {  return self.frame.size.height; } - (void)setHeight:(CGFloat)height {  self.frame = CGRectMake(self.x, self.y, self.width, height); }  - (CGFloat)width {  return self.frame.size.width; } - (void)setWidth:(CGFloat)width {  self.frame = CGRectMake(self.x, self.y, width, self.height); }  @end 

文章中的其余代码默认都#import 了以上提到的宏定义和 UIView Category。

排版引擎框架

上面的 Hello World 工程仅仅展示了 Core Text 排版的基本能力。但是要制作一个较完善的排版引擎,我们不能简单的将所有代码都放到 CTDisplayView 的drawRect方法里面。根据设计模式中的 “ 单一功能原则 ”(Single responsibility principle),我们应该把功能拆分,把不同的功能都放到各自不同的类里面。

对于一个复杂的排版引擎来说,可以将其功能拆成以下几个类来完成:

  1. 一个显示用的类,仅负责显示内容,不负责排版
  2. 一个模型类,用于承载显示所需要的所有数据
  3. 一个排版类,用于实现文字内容的排版
  4. 一个配置类,用于实现一些排版时的可配置项

注:” 单一功能原则 “(Single responsibility principle) 参考链接:http://zh.wikipedia.org/wiki/%E5%8D%95%E4%B8%80%E5%8A%9F%E8%83%BD%E5%8E%9F%E5%88%99

按照以上原则,我们将CTDisplayView中的部分内容拆开,由 4 个类构成:

  1. CTFrameParserConfig类,用于配置绘制的参数,例如:文字颜色,大小,行间距等。
  2. CTFrameParser类,用于生成最后绘制界面需要的CTFrameRef实例。
  3. CoreTextData类,用于保存由CTFrameParser类生成的CTFrameRef实例以及CTFrameRef实际绘制需要的高度。
  4. CTDisplayView类,持有CoreTextData类的实例,负责将CTFrameRef绘制到界面上。

关于这 4 个类的关键代码如下:

CTFrameParserConfig类:

1 2 3 4 5 6 7 8 9
#import <Foundation/Foundation.h> @interface CTFrameParserConfig : NSObject  @property (nonatomic, assign) CGFloat width; @property (nonatomic, assign) CGFloat fontSize; @property (nonatomic, assign) CGFloat lineSpace; @property (nonatomic, strong) UIColor *textColor;  @end 
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
 #import "CTFrameParserConfig.h"  @implementation CTFrameParserConfig  - (id)init {  self = [super init];  if (self) {  _width = 200.0f;  _fontSize = 16.0f;  _lineSpace = 8.0f;  _textColor = RGB(108, 108, 108);  }  return self; }  @end 

CTFrameParser类:

1 2 3 4 5 6 7 8 9
#import <Foundation/Foundation.h> #import "CoreTextData.h" #import "CTFrameParserConfig.h"  @interface CTFrameParser : NSObject  + (CoreTextData *)parseContent:(NSString *)content config:(CTFrameParserConfig*)config;  @end 
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72
 #import "CTFrameParser.h" #import "CTFrameParserConfig.h"  @implementation CTFrameParser  + (NSDictionary *)attributesWithConfig:(CTFrameParserConfig *)config {  CGFloat fontSize = config.fontSize;  CTFontRef fontRef = CTFontCreateWithName((CFStringRef)@"ArialMT", fontSize, NULL);  CGFloat lineSpacing = config.lineSpace;  const CFIndex kNumberOfSettings = 3;  CTParagraphStyleSetting theSettings[kNumberOfSettings] = {  { kCTParagraphStyleSpecifierLineSpacingAdjustment, sizeof(CGFloat), &lineSpacing },  { kCTParagraphStyleSpecifierMaximumLineSpacing, sizeof(CGFloat), &lineSpacing },  { kCTParagraphStyleSpecifierMinimumLineSpacing, sizeof(CGFloat), &lineSpacing }  };   CTParagraphStyleRef theParagraphRef = CTParagraphStyleCreate(theSettings, kNumberOfSettings);   UIColor * textColor = config.textColor;   NSMutableDictionary * dict = [NSMutableDictionary dictionary];  dict[(id)kCTForegroundColorAttributeName] = (id)textColor.CGColor;  dict[(id)kCTFontAttributeName] = (__bridge id)fontRef;  dict[(id)kCTParagraphStyleAttributeName] = (__bridge id)theParagraphRef;   CFRelease(theParagraphRef);  CFRelease(fontRef);  return dict; }  + (CoreTextData *)parseContent:(NSString *)content config:(CTFrameParserConfig*)config {  NSDictionary *attributes = [self attributesWithConfig:config];  NSAttributedString *contentString =  [[NSAttributedString alloc] initWithString:content  attributes:attributes];   // 创建 CTFramesetterRef 实例  CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)contentString);   // 获得要绘制的区域的高度  CGSize restrictSize = CGSizeMake(config.width, CGFLOAT_MAX);  CGSize coreTextSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter, CFRangeMake(0,0), nil, restrictSize, nil);  CGFloat textHeight = coreTextSize.height;   // 生成 CTFrameRef 实例  CTFrameRef frame = [self createFrameWithFramesetter:framesetter config:config height:textHeight];   // 将生成好的 CTFrameRef 实例和计算好的绘制高度保存到 CoreTextData 实例中,最后返回 CoreTextData 实例  CoreTextData *data = [[CoreTextData alloc] init];  data.ctFrame = frame;  data.height = textHeight;   // 释放内存  CFRelease(frame);  CFRelease(framesetter);  return data; }  + (CTFrameRef)createFrameWithFramesetter:(CTFramesetterRef)framesetter  config:(CTFrameParserConfig *)config  height:(CGFloat)height {   CGMutablePathRef path = CGPathCreateMutable();  CGPathAddRect(path, NULL, CGRectMake(0, 0, config.width, height));   CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL);  CFRelease(path);  return frame; }  @end 

CoreTextData类:

1 2 3 4 5 6 7 8
#import <Foundation/Foundation.h>  @interface CoreTextData : NSObject  @property (assign, nonatomic) CTFrameRef ctFrame; @property (assign, nonatomic) CGFloat height;  @end
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
#import "CoreTextData.h"  @implementation CoreTextData  - (void)setCtFrame:(CTFrameRef)ctFrame {  if (_ctFrame != ctFrame) {  if (_ctFrame != nil) {  CFRelease(_ctFrame);  }  CFRetain(ctFrame);  _ctFrame = ctFrame;  } }  - (void)dealloc {  if (_ctFrame != nil) {  CFRelease(_ctFrame);  _ctFrame = nil;  } }  @end 

CTDisplayView类:

1 2 3 4 5 6 7 8
#import <Foundation/Foundation.h> #import "CoreTextData.h"  @interface CTDisplayView : UIView  @property (strong, nonatomic) CoreTextData * data;  @end 
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
#import "CTDisplayView.h"  @implementation CTDisplayView  - (void)drawRect:(CGRect)rect {  [super drawRect:rect];  CGContextRef context = UIGraphicsGetCurrentContext();  CGContextSetTextMatrix(context, CGAffineTransformIdentity);  CGContextTranslateCTM(context, 0, self.bounds.size.height);  CGContextScaleCTM(context, 1.0, -1.0);   if (self.data) {  CTFrameDraw(self.data.ctFrame, context);  } }  @end 

以上 4 个类中的逻辑与之前 Hello World 那个项目的逻辑基本一致,只是分拆到了 4 个类中完成。另外,CTFrameParser 增加了方法来获得要绘制的区域的高度,并将高度信息保存到CoreTextData类的实例中。之所以要获得绘制区域的高度,是因为在很多实际使用场景中,我们需要先知道所要显示内容的高度,之后才可以进行绘制。

例如,在 UITableView 在渲染时,UITableView 首先会向 delegate 回调如下方法来获得每个将要渲染的 cell 的高度:

1
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath;

之后,UITableView 会计算当前滚动的位置具体需要绘制的 UITableViewCell 是哪些,然后对于那些需要绘制的 Cell,UITableView 才会继续向其 data source 回调如下方法来获得 UITableViewCell 实例:

1
- (UITableViewCell *)cellForRowAtIndexPath:(NSIndexPath *)indexPath;

对于上面的情况,如果我们使用 CoreText 来作为 TableViewCell 的内容,那么就必须在每个 Cell 绘制之前,就知道其需要的绘制高度,否则 UITableView 将无法正常工作。

完成以上 4 个类之后,我们就可以简单地在ViewController.m文件中,加入如下代码来配置CTDisplayView的显示内容,位置,高度,字体,颜色等信息。代码如下所示。

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
#import "ViewController.h"  @interface ViewController ()  @property (weak, nonatomic) IBOutlet CTDisplayView *ctView;  @end  @implementation ViewController  - (void)viewDidLoad {  [super viewDidLoad];   CTFrameParserConfig *config = [[CTFrameParserConfig alloc] init];  config.textColor = [UIColor redColor];  config.width = self.ctView.width;   CoreTextData *data = [CTFrameParser parseContent:@" 按照以上原则,我们将`CTDisplayView`中的部分内容拆开。" config:config];  self.ctView.data = data;  self.ctView.height = data.height;  self.ctView.backgroundColor = [UIColor yellowColor]; }  @end 

注意:从 Xcode4.0 开始,默认的界面编辑就开启了对于Use Autolayout的使用,但因为我们在代码中直接修改了变量ctView的 frame 信息,所以需要在Main_iPhone.storyboard中将Use Autolayout这一项取消勾选。如下图所示:

以下是本框架的 UML 示意图,从图中我们可以看出,这 4 个 Core Text 类的关系是这样的:

  1. CTFrameParser通过CTFrameparserConfig实例来生成CoreTextData实例。
  2. CTDisplayView通过持有CoreTextData实例来获得绘制所需要的所有信息。
  3. ViewController类通过配置CTFrameparserConfig实例,进而获得生成的CoreTextData实例,最后将其赋值给他的CTDisplayView成员,达到将指定内容显示在界面上的效果。

说明 1:整个工程代码在名为basic_arch的分支下,读者可以在示例的源代码工程中使用git checkout basic_arch来切换到当前讲解的工程示例代码。

说明 2:为了方便操作UIView的frame属性,项目中增加了一个名为UIView+frameAdjust.m文件,它通过Category来给UIView增加了直接设置height属性的方法。

定制排版文件格式

对于上面的例子,我们给 CTFrameParser 使增加了一个将 NSString 转换为 CoreTextData 的方法。但这样的实现方式有很多局限性,因为整个内容虽然可以定制字体大小,颜色,行高等信息,但是却不能支持定制内容中的某一部分。例如,如果我们只想让内容的前三个字显示成红色,而其它文字显示成黑色,那么就办不到了。

解决的办法很简单,我们让CTFrameParser支持接受 NSAttributeString 作为参数,然后在ViewController类中设置我们想要的 NSAttributeString 信息。

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
@implementation ViewController  - (void)viewDidLoad {  [super viewDidLoad];   CTFrameParserConfig *config = [[CTFrameParserConfig alloc] init];  config.width = self.ctView.width;  config.textColor = [UIColor blackColor];   NSString *content =  @" 对于上面的例子,我们给 CTFrameParser 增加了一个将 NSString 转 "  " 换为 CoreTextData 的方法。"  " 但这样的实现方式有很多局限性,因为整个内容虽然可以定制字体 "  " 大小,颜色,行高等信息,但是却不能支持定制内容中的某一部分。"  " 例如,如果我们只想让内容的前三个字显示成红色,而其它文字显 "  " 示成黑色,那么就办不到了。"  "\n\n"  " 解决的办法很简单,我们让`CTFrameParser`支持接受 "  "NSAttributeString 作为参数,然后在 NSAttributeString 中设置好 "  " 我们想要的信息。";  NSDictionary *attr = [CTFrameParser attributesWithConfig:config];  NSMutableAttributedString *attributedString =  [[NSMutableAttributedString alloc] initWithString:content  attributes:attr];  [attributedString addAttribute:NSForegroundColorAttributeName  value:[UIColor redColor]  range:NSMakeRange(0, 7)];   CoreTextData *data = [CTFrameParser parseAttributedContent:attributedString  config:config];  self.ctView.data = data;  self.ctView.height = data.height;  self.ctView.backgroundColor = [UIColor yellowColor]; }  @end

结果如下图所示,我们很方便就把前面 7 个字变成了红色。

更进一步地,实际工作中,我们更希望通过一个排版文件,来设置需要排版的文字的内容、颜色、字体大小等信息。我在开发猿题库应用时,自己定义了一个基于 UBB 的排版模版,但是实现该排版文件的解析器要花费大量的篇幅,考虑到这并不是本章的重点,所以我们以一个较简单的排版文件来讲解其思想。

我们规定排版的模版文件为 JSON 格式。JSON(JavaScript Object Notation) 是一种轻量级的数据交换格式,易于阅读和编写,同时也易于机器解析和生成。iOS 从 5.0 开始,提供了名为NSJSONSerialization的类库来方便开发者对 JSON 的解析。在 iOS5.0 之前,业界也有很多相关的 JSON 解析开源库,例如 JSONKit 可供大家使用。

我们的排版模版示例文件如下所示:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
[ { "color" : "blue",  "content" : " 更进一步地,实际工作中,我们更希望通过一个排版文件,来设置需要排版的文字的 ",  "size" : 16,  "type" : "txt"  },  { "color" : "red",  "content" : " 内容、颜色、字体 ",  "size" : 22,  "type" : "txt"  },  { "color" : "black",  "content" : " 大小等信息。\n",  "size" : 16,  "type" : "txt"  },  { "color" : "default",  "content" : " 我在开发猿题库应用时,自己定义了一个基于 UBB 的排版模版,但是实现该排版文件的解析器要花费大量的篇幅,考虑到这并不是本章的重点,所以我们以一个较简单的排版文件来讲解其思想。",  "type" : "txt"  } ]

通过苹果提供的NSJSONSerialization类,我们可以将上面的模版文件转换成 NSArray 数组,每一个数组元素是一个 NSDictionary,代表一段相同设置的文字。为了简单,我们的配置文件只支持配置颜色和字号,但是读者可以依据同样的思想,很方便地增加其它配置信息。

接下来我们要为CTFrameParser增加一个方法,让其可以从如上格式的模版文件中生成CoreTextData。最终我们的实现代码如下:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99
 // 方法一 + (CoreTextData *)parseTemplateFile:(NSString *)path config:(CTFrameParserConfig*)config {  NSAttributedString *content = [self loadTemplateFile:path config:config];  return [self parseAttributedContent:content config:config]; }  // 方法二 + (NSAttributedString *)loadTemplateFile:(NSString *)path config:(CTFrameParserConfig*)config {  NSData *data = [NSData dataWithContentsOfFile:path];  NSMutableAttributedString *result = [[NSMutableAttributedString alloc] init];  if (data) {  NSArray *array = [NSJSONSerialization JSONObjectWithData:data  options:NSJSONReadingAllowFragments  error:nil];  if ([array isKindOfClass:[NSArray class]]) {  for (NSDictionary *dict in array) {  NSString *type = dict[@"type"];  if ([type isEqualToString:@"txt"]) {  NSAttributedString *as =  [self parseAttributedContentFromNSDictionary:dict  config:config];  [result appendAttributedString:as];  }  }  }  }  return result; }  // 方法三 + (NSAttributedString *)parseAttributedContentFromNSDictionary:(NSDictionary *)dict  config:(CTFrameParserConfig*)config {  NSMutableDictionary *attributes = [self attributesWithConfig:config];  // set color  UIColor *color = [self colorFromTemplate:dict[@"color"]];  if (color) {  attributes[(id)kCTForegroundColorAttributeName] = (id)color.CGColor;  }  // set font size  CGFloat fontSize = [dict[@"size"] floatValue];  if (fontSize > 0) {  CTFontRef fontRef = CTFontCreateWithName((CFStringRef)@"ArialMT", fontSize, NULL);  attributes[(id)kCTFontAttributeName] = (__bridge id)fontRef;  CFRelease(fontRef);  }  NSString *content = dict[@"content"];  return [[NSAttributedString alloc] initWithString:content attributes:attributes]; }  // 方法四 + (UIColor *)colorFromTemplate:(NSString *)name {  if ([name isEqualToString:@"blue"]) {  return [UIColor blueColor];  } else if ([name isEqualToString:@"red"]) {  return [UIColor redColor];  } else if ([name isEqualToString:@"black"]) {  return [UIColor blackColor];  } else {  return nil;  } }  // 方法五 + (CoreTextData *)parseAttributedContent:(NSAttributedString *)content config:(CTFrameParserConfig*)config {  // 创建 CTFramesetterRef 实例  CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)content);   // 获得要缓制的区域的高度  CGSize restrictSize = CGSizeMake(config.width, CGFLOAT_MAX);  CGSize coreTextSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter, CFRangeMake(0,0), nil, restrictSize, nil);  CGFloat textHeight = coreTextSize.height;   // 生成 CTFrameRef 实例  CTFrameRef frame = [self createFrameWithFramesetter:framesetter config:config height:textHeight];   // 将生成好的 CTFrameRef 实例和计算好的缓制高度保存到 CoreTextData 实例中,最后返回 CoreTextData 实例  CoreTextData *data = [[CoreTextData alloc] init];  data.ctFrame = frame;  data.height = textHeight;   // 释放内存  CFRelease(frame);  CFRelease(framesetter);  return data; }  // 方法六 + (CTFrameRef)createFrameWithFramesetter:(CTFramesetterRef)framesetter  config:(CTFrameParserConfig *)config  height:(CGFloat)height {   CGMutablePathRef path = CGPathCreateMutable();  CGPathAddRect(path, NULL, CGRectMake(0, 0, config.width, height));   CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL);  CFRelease(path);  return frame; } 

以上代码主要由 6 个子方法构成:

  • 方法一用于提供对外的接口,调用方法二实现从一个 JSON 的模版文件中读取内容,然后调用方法五生成CoreTextData。
  • 方法二读取 JSON 文件内容,并且调用方法三获得从NSDictionary到NSAttributedString的转换结果。
  • 方法三将NSDictionary内容转换为NSAttributedString。
  • 方法四提供将NSString转为UIColor的功能。
  • 方法五接受一个NSAttributedString和一个config参数,将NSAttributedString转换成CoreTextData返回。
  • 方法六是方法五的一个辅助函数,供方法五调用。

然后我们将ViewController中的调用代码作一下更改,使其从模版文件中加载内容,如下所示:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
 @implementation ViewController  - (void)viewDidLoad {  [super viewDidLoad];   CTFrameParserConfig *config = [[CTFrameParserConfig alloc] init];  config.width = self.ctView.width;  NSString *path = [[NSBundle mainBundle] pathForResource:@"content" ofType:@"json"];  CoreTextData *data = [CTFrameParser parseTemplateFile:path config:config];  self.ctView.data = data;  self.ctView.height = data.height;  self.ctView.backgroundColor = [UIColor whiteColor]; }  @end 

最后运行得到的结果如下所示,可以看到,通过一个简单的模板文件,我们已经可以很方便地定义排版的配置信息了。

说明:读者可以在示例工程中使用git checkout json_template,查看可以运行的示例代码。

本章前言

在上一篇《基于 CoreText 的排版引擎:基础》中,我们学会了排版的基础知识,现在我们来增加复杂性,让我们的排版引擎支持图片和链接的点击。

支持图文混排的排版引擎

改造模版文件

下面我们来进一步改造,让排版引擎支持对于图片的排版。在上一小节中,我们在设置模版文件的时候,就专门在模板文件里面留了一个名为type的字段,用于表示内容的类型。之前的type的值都是txt,这次,我们增加一个值为img的值,用于表示图片。

我们将上一节的content.json文件修改为如下内容,增加了 2 个type值为img的配置项。由于是图片的配置项,所以我们不需要设置颜色,字号这些图片不具有的属性,但是,我们另外增加了 3 个图片的配置属性:

  1. 一个名为width的属性,用于设置图片显示的宽度。
  2. 一个名为height的属性,用于设置图片显示的高度。
  3. 一个名为name的属性,用于设置图片的资源名。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
[ {  "type" : "img",  "width" : 200,  "height" : 108,  "name" : "coretext-image-1.jpg"  },  { "color" : "blue",  "content" : " 更进一步地,实际工作中,我们更希望通过一个排版文件,来设置需要排版的文字的 ",  "size" : 16,  "type" : "txt"  },  { "color" : "red",  "content" : " 内容、颜色、字体 ",  "size" : 22,  "type" : "txt"  },  { "color" : "black",  "content" : " 大小等信息。\n",  "size" : 16,  "type" : "txt"  },  {  "type" : "img",  "width" : 200,  "height" : 130,  "name" : "coretext-image-2.jpg"  },  { "color" : "default",  "content" : " 我在开发猿题库应用时,自己定义了一个基于 UBB 的排版模版,但是实现该排版文件的解析器要花费大量的篇幅,考虑到这并不是本章的重点,所以我们以一个较简单的排版文件来讲解其思想。",  "type" : "txt"  } ]

按理说,图片本身的内容信息中,是包含宽度和高度信息的,为什么我们要在这里指定图片的宽高呢?这主要是因为,在真实的开发中,应用的模版和图片通常是通过服务器获取的,模版是纯文本的内容,获取速度比图片快很多,而图片不但获取速度慢,而且为了省流量,通常的做法是直到需要显示图片的时候,再加载图片内容。

如果我们不将图片的宽度和高度信息设置在模板里面,那么 CoreText 在排版的时候就无法知道绘制所需要的高度,我们就无法设置CoreTextData类中的height信息,没有高度信息,就会对 UITableView 一类的控件排版造成影响。所以,除非你的应用图片能够保证在绘制前都能全部在本地,否则就应该另外提前提供图片宽度和高度信息。

在完成模板文件修改后,我们选取两张测试用的图片,分别将其命名为coretext-image-1.jpg和coretext-image-2.jpg(和模板中的值一致),将其拖动增加到工程中。向 Xcode 工程增加图片资源是基础知识,在此就不详细介绍过程了。

CTLine 与 CTRun

接下来我们需要改造的是CTFrameParser类,让解析模板文件的方法支持type为img的配置。

在改造前,我们先来了解一下CTFrame内部的组成。通过之前的例子,我们可以看到,我们首先通过NSAttributeString和配置信息创建 CTFrameSetter, 然后,再通过CTFrameSetter来创建CTFrame。

在CTFrame内部,是由多个CTLine来组成的,每个CTLine代表一行,每个CTLine又是由多个CTRun来组成,每个CTRun代表一组显示风格一致的文本。我们不用手工管理CTLine和CTRun的创建过程。

下图是一个CTLine和CTRun的示意图,可以看到,第三行的CTLine是由 2 个CTRun构成的,第一个CTRun为红色大字号的左边部分,第二个CTRun为右边字体较小的部分。

虽然我们不用管理CTRun的创建过程,但是我们可以设置某一个具体的CTRun的CTRunDelegate来指定该文本在绘制时的高度、宽度、排列对齐方式等信息。

对于图片的排版,其实 CoreText 本质上不是直接支持的,但是,我们可以在要显示文本的地方,用一个特殊的空白字符代替,同时设置该字体的CTRunDelegate信息为要显示的图片的宽度和高度信息,这样最后生成的CTFrame实例,就会在绘制时将图片的位置预留出来。

因为我们的CTDisplayView的绘制代码是在drawRect里面的,所以我们可以方便地把需要绘制的图片,用CGContextDrawImage方法直接绘制出来就可以了。

改造模版解析类

在了解了以上原理后,我们就可以开始进行改造了。

我们需要做的工作包括:

  1. 改造CTFrameParser的parseTemplateFile:(NSString *)path config:(CTFrameParserConfig*)config;方法,使其支持对type为img的节点解析。并且对type为img的节点,设置其CTRunDelegate信息,使其在绘制时,为图片预留相应的空白位置。
  2. 改造CoreTextData类,增加图片相关的信息,并且增加计算图片绘制区域的逻辑。
  3. 改造CTDisplayView类,增加绘制图片相关的逻辑。

首先介绍对于CTFrameParser的改造:

我们修改了parseTemplateFile方法,增加了一个名为imageArray的参数来保存解析时的图片信息。

1 2 3 4 5 6 7
+ (CoreTextData *)parseTemplateFile:(NSString *)path config:(CTFrameParserConfig*)config {  NSMutableArray *imageArray = [NSMutableArray array];  NSAttributedString *content = [self loadTemplateFile:path config:config imageArray:imageArray];  CoreTextData *data = [self parseAttributedContent:content config:config];  data.imageArray = imageArray;  return data; }

接着我们修改loadTemplateFile方法,增加了对于type是img的节点处理逻辑,该逻辑主要做 2 件事情:

  1. 保存当前图片节点信息到imageArray变量中
  2. 新建一个空白的占位符。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
+ (NSAttributedString *)loadTemplateFile:(NSString *)path  config:(CTFrameParserConfig*)config  imageArray:(NSMutableArray *)imageArray {  NSData *data = [NSData dataWithContentsOfFile:path];  NSMutableAttributedString *result = [[NSMutableAttributedString alloc] init];  if (data) {  NSArray *array = [NSJSONSerialization JSONObjectWithData:data  options:NSJSONReadingAllowFragments  error:nil];  if ([array isKindOfClass:[NSArray class]]) {  for (NSDictionary *dict in array) {  NSString *type = dict[@"type"];  if ([type isEqualToString:@"txt"]) {  NSAttributedString *as =  [self parseAttributedContentFromNSDictionary:dict  config:config];  [result appendAttributedString:as];  } else if ([type isEqualToString:@"img"]) {  // 创建 CoreTextImageData  CoreTextImageData *imageData = [[CoreTextImageData alloc] init];  imageData.name = dict[@"name"];  imageData.position = [result length];  [imageArray addObject:imageData];  // 创建空白占位符,并且设置它的 CTRunDelegate 信息  NSAttributedString *as = [self parseImageDataFromNSDictionary:dict config:config];  [result appendAttributedString:as];  }  }  }  }  return result; }

最后我们新建一个最关键的方法:parseImageDataFromNSDictionary,生成图片空白的占位符,并且设置其CTRunDelegate信息。其代码如下:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
 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]; }  + (NSAttributedString *)parseImageDataFromNSDictionary:(NSDictionary *)dict  config:(CTFrameParserConfig*)config {  CTRunDelegateCallbacks callbacks;  memset(&callbacks, 0, sizeof(CTRunDelegateCallbacks));  callbacks.version = kCTRunDelegateVersion1;  callbacks.getAscent = ascentCallback;  callbacks.getDescent = descentCallback;  callbacks.getWidth = widthCallback;  CTRunDelegateRef delegate = CTRunDelegateCreate(&callbacks, (__bridge void *)(dict));   // 使用 0xFFFC 作为空白的占位符  unichar objectReplacementChar = 0xFFFC;  NSString * content = [NSString stringWithCharacters:&objectReplacementChar length:1];  NSDictionary * attributes = [self attributesWithConfig:config];  NSMutableAttributedString * space =  [[NSMutableAttributedString alloc] initWithString:content  attributes:attributes];  CFAttributedStringSetAttribute((CFMutableAttributedStringRef)space,  CFRangeMake(0, 1), kCTRunDelegateAttributeName, delegate);  CFRelease(delegate);  return space; } 

接着我们对CoreTextData进行改造,增加了imageArray成员变量,用于保存图片绘制时所需的信息。

1 2 3 4 5 6 7 8 9 10 11
#import <Foundation/Foundation.h> #import "CoreTextImageData.h"  @interface CoreTextData : NSObject  @property (assign, nonatomic) CTFrameRef ctFrame; @property (assign, nonatomic) CGFloat height; // 新增加的成员 @property (strong, nonatomic) NSArray * imageArray;  @end

在设置imageArray成员时,我们还会调一个新创建的fillImagePosition方法,用于找到每张图片在绘制时的位置。

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64
 - (void)setImageArray:(NSArray *)imageArray {  _imageArray = imageArray;  [self fillImagePosition]; }  - (void)fillImagePosition {  if (self.imageArray.count == 0) {  return;  }  NSArray *lines = (NSArray *)CTFrameGetLines(self.ctFrame);  int lineCount = [lines count];  CGPoint lineOrigins[lineCount];  CTFrameGetLineOrigins(self.ctFrame, CFRangeMake(0, 0), lineOrigins);   int imgIndex = 0;  CoreTextImageData * imageData = self.imageArray[0];   for (int i = 0; i < lineCount; ++i) {  if (imageData == nil) {  break;  }  CTLineRef line = (__bridge CTLineRef)lines[i];  NSArray * runObjArray = (NSArray *)CTLineGetGlyphRuns(line);  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(self.ctFrame);  CGRect colRect = CGPathGetBoundingBox(pathRef);   CGRect delegateBounds = CGRectOffset(runBounds, colRect.origin.x, colRect.origin.y);   imageData.imagePosition = delegateBounds;  imgIndex++;  if (imgIndex == self.imageArray.count) {  imageData = nil;  break;  } else {  imageData = self.imageArray[imgIndex];  }  }  } }

添加对图片的点击支持

实现方式

为了实现对图片的点击支持,我们需要给CTDisplayView类增加用户点击操作的检测函数,在检测函数中,判断当前用户点击的区域是否在图片上,如果在图片上,则触发点击图片的逻辑。苹果提供的UITapGestureRecognizer可以很好的满足我们的要求,所以我们这里用它来检测用户的点击操作。

我们这里实现的是点击图片后,先用NSLog打印出一行日志。实际应用中,读者可以根据业务需求自行调整点击后的效果。

我们先为CTDisplayView类增加UITapGestureRecognizer:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
- (id)initWithCoder:(NSCoder *)aDecoder {  self = [super initWithCoder:aDecoder];  if (self) {  [self setupEvents];  }  return self; }  - (void)setupEvents {  UIGestureRecognizer * tapRecognizer =  [[UITapGestureRecognizer alloc] initWithTarget:self  action:@selector(userTapGestureDetected:)];  tapRecognizer.delegate = self;  [self addGestureRecognizer:tapRecognizer];  self.userInteractionEnabled = YES; } 

然后增加UITapGestureRecognizer的回调函数:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
- (void)userTapGestureDetected:(UIGestureRecognizer *)recognizer {  CGPoint point = [recognizer locationInView:self];  for (CoreTextImageData * imageData in self.data.imageArray) {  // 翻转坐标系,因为 imageData 中的坐标是 CoreText 的坐标系  CGRect imageRect = imageData.imagePosition;  CGPoint imagePosition = imageRect.origin;  imagePosition.y = self.bounds.size.height - imageRect.origin.y  - imageRect.size.height;  CGRect rect = CGRectMake(imagePosition.x, imagePosition.y, imageRect.size.width, imageRect.size.height);  // 检测点击位置 Point 是否在 rect 之内  if (CGRectContainsPoint(rect, point)) {  // 在这里处理点击后的逻辑  NSLog(@"bingo");  break;  }  } }

事件处理

在界面上,CTDisplayView通常在UIView的树形层级结构中,一个 UIView 可能是最外层 View Controller 的 View 的孩子的孩子的孩子(如下图所示)。在这种多级层次结构中,很难通过delegate模式将图片点击的事件一层一层往外层传递,所以最好使用NSNotification,来处理图片点击事件。

在 Demo 中,我们在最外层的 View Controller 中监听图片点击的通知,当收到通知后,进入到一个新的界面来显示图片点击内容。

注:读者可以将 demo 工程切换到image_click分支,查看示例代码。

添加对链接的点击支持

修改模板文件

我们修改模版文件,增加一个名为 link 的类型,用于表示链接内容。如下所示:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
[  { "color" : "default",  "content" : " 这在这里尝试放一个参考链接:",  "type" : "txt"  },  { "color" : "blue",  "content" : " 链接文字 ",  "url" : "http://blog.devtang.com",  "type" : "link"  },  { "color" : "default",  "content" : " 大家可以尝试点击一下 ",  "type" : "txt"  } ] 

解析模版中的链接信息

我们首先增加一个CoreTextLinkData类,用于记录解析 JSON 文件时的链接信息:

1 2 3 4 5 6 7
@interface CoreTextLinkData : NSObject  @property (strong, nonatomic) NSString * title; @property (strong, nonatomic) NSString * url; @property (assign, nonatomic) NSRange range;  @end 

然后我们修改 CTFrameParser 类,增加解析链接的逻辑:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
+ (NSAttributedString *)loadTemplateFile:(NSString *)path  config:(CTFrameParserConfig*)config  imageArray:(NSMutableArray *)imageArray  linkArray:(NSMutableArray *)linkArray {  NSData *data = [NSData dataWithContentsOfFile:path];  NSMutableAttributedString *result = [[NSMutableAttributedString alloc] init];  if (data) {  NSArray *array = [NSJSONSerialization JSONObjectWithData:data  options:NSJSONReadingAllowFragments  error:nil];  if ([array isKindOfClass:[NSArray class]]) {  for (NSDictionary *dict in array) {  NSString *type = dict[@"type"];  if ([type isEqualToString:@"txt"]) {  // 省略  } else if ([type isEqualToString:@"img"]) {  // 省略  } else if ([type isEqualToString:@"link"]) {  NSUInteger startPos = result.length;  NSAttributedString *as =  [self parseAttributedContentFromNSDictionary:dict  config:config];  [result appendAttributedString:as];  // 创建 CoreTextLinkData  NSUInteger length = result.length - startPos;  NSRange linkRange = NSMakeRange(startPos, length);  CoreTextLinkData *linkData = [[CoreTextLinkData alloc] init];  linkData.title = dict[@"content"];  linkData.url = dict[@"url"];  linkData.range = linkRange;  [linkArray addObject:linkData];  }  }  }  }  return result; } 

然后,我们增加一个 Utils 类来专门处理检测用户点击是否在链接上。主要的方法是使用 CTLineGetStringIndexForPosition 函数来获得用户点击的位置与 NSAttributedString 字符串上的位置的对应关系。这样就知道是点击的哪个字符了。然后判断该字符串是否在链接上即可。该 Util 在实现逻辑如下:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
// 检测点击位置是否在链接上 + (CoreTextLinkData *)touchLinkInView:(UIView *)view atPoint:(CGPoint)point data:(CoreTextData *)data {  CTFrameRef textFrame = data.ctFrame;  CFArrayRef lines = CTFrameGetLines(textFrame);  if (!lines) return nil;  CFIndex count = CFArrayGetCount(lines);  CoreTextLinkData *foundLink = nil;   // 获得每一行的 origin 坐标  CGPoint origins[count];  CTFrameGetLineOrigins(textFrame, CFRangeMake(0,0), origins);   // 翻转坐标系  CGAffineTransform transform =  CGAffineTransformMakeTranslation(0, view.bounds.size.height);  transform = CGAffineTransformScale(transform, 1.f, -1.f);   for (int i = 0; i < count; i++) {  CGPoint linePoint = origins[i];  CTLineRef line = CFArrayGetValueAtIndex(lines, i);  // 获得每一行的 CGRect 信息  CGRect flippedRect = [self getLineBounds:line point:linePoint];  CGRect rect = CGRectApplyAffineTransform(flippedRect, transform);   if (CGRectContainsPoint(rect, point)) {  // 将点击的坐标转换成相对于当前行的坐标  CGPoint relativePoint = CGPointMake(point.x-CGRectGetMinX(rect),  point.y-CGRectGetMinY(rect));  // 获得当前点击坐标对应的字符串偏移  CFIndex idx = CTLineGetStringIndexForPosition(line, relativePoint);  // 判断这个偏移是否在我们的链接列表中  foundLink = [self linkAtIndex:idx linkArray:data.linkArray];  return foundLink;  }  }  return nil; }  + (CGRect)getLineBounds:(CTLineRef)line point:(CGPoint)point {  CGFloat ascent = 0.0f;  CGFloat descent = 0.0f;  CGFloat leading = 0.0f;  CGFloat width = (CGFloat)CTLineGetTypographicBounds(line, &ascent, &descent, &leading);  CGFloat height = ascent + descent;  return CGRectMake(point.x, point.y - descent, width, height); }  + (CoreTextLinkData *)linkAtIndex:(CFIndex)i linkArray:(NSArray *)linkArray {  CoreTextLinkData *link = nil;  for (CoreTextLinkData *data in linkArray) {  if (NSLocationInRange(i, data.range)) {  link = data;  break;  }  }  return link; } 

最后改造一下CTDisplayView,使其在检测到用户点击后,调用上面的 Util 方法即可。我们这里实现的是点击链接后,先用NSLog打印出一行日志。实际应用中,读者可以根据业务需求自行调整点击后的效果。

1 2 3 4 5 6 7 8 9 10
- (void)userTapGestureDetected:(UIGestureRecognizer *)recognizer {  CGPoint point = [recognizer locationInView:self];  // 此处省略上一节中介绍的,对图片点击检测的逻辑   CoreTextLinkData *linkData = [CoreTextUtils touchLinkInView:self atPoint:point data:self.data];  if (linkData) {  NSLog(@"hint link!");  return;  } }

注:在 Demo 中工程中,我们实现了点击链接跳转到一个新的界面,然后用 UIWebView 来显示链接内容的逻辑。读者可以将 demo 工程切换到link_click分支,查看示例代码。

Demo 工程的 Gif 效果图如下,读者可以将示例工程用git checkout image_support切换到当前章节状态,查看相关代码逻辑。

时间: 2024-10-09 03:44:22

基于 CoreText 的排版引擎的相关文章

基于 CoreText 的排版引擎:基础

本文节选自我的图书:<iOS 开发进阶>. 本文涉及的 Demo 工程在这里:https://github.com/tangqiaoboy/iOS-Pro. 扫码关注我的「iOS 开发」微信公众帐号: 本章前言 使用 CoreText 技术,我们可以对富文本进行复杂的排版.经过一些简单的扩展,我们还可以实现对于图片,链接的点击效果.CoreText 技术相对于 UIWebView,有着更少的内存占用,以及可以在后台渲染的优点,非常适合用于内容的排版工作. 本章我们将从最基本的开始,一步一步完成

基于 CoreText 的排版引擎:进阶

版权说明 原创文章,转载请保留以下信息: 本文节选自我的图书:<iOS 开发进阶>. 本文涉及的 Demo 工程在这里:https://github.com/tangqiaoboy/iOS-Pro. 扫码关注我的「iOS 开发」微信公众帐号: 本章前言 在上一篇<基于 CoreText 的排版引擎:基础>中,我们学会了排版的基础知识,现在我们来增加复杂性,让我们的排版引擎支持图片和链接的点击. 支持图文混排的排版引擎 改造模版文件 下面我们来进一步改造,让排版引擎支持对于图片的排版

浏览器内核、排版引擎、js引擎

[定义] 浏览器最重要或者说核心的部分是“Rendering Engine”,可大概译为“渲染引擎”,不过我们一般习惯将之称为“浏览器内核”.负责对网页语法的解释(如标准通用标记语 言下的一个应用HTML.JavaScript)并渲染(显示)网页. 所以,通常所谓的浏览器内核也就是浏览器所采用的渲染引擎,渲染引擎决定了浏览器如何显示网页的内容以及 页面的格式信息.不同的浏览器内核对网页编写语法的解释也有不同,因此同一网页在不同的内核的浏览器里的渲染(显示)效果也可能不同,这也是网页编写者需要在不

Rendering Engine 主流的浏览器内核(排版引擎、渲染引擎、解释引擎)有哪几种,分别的特点

一.A web browser engine A rendering engine is software that draws text and images on the screen. The engine draws structured text from a document (often HTML), and formats it properly based on the given style declarations (often given in CSS). Example

基于ε-NFA的正则表达式引擎

正则表达式几乎每个程序员都会用到,对于这么常见的一个语言,有没有想过怎么去实现一个呢?乍想一下,也许觉得困难,实际上实现一个正则表达式的引擎并没有想像中的复杂,<编译原理>一书中有一章专门讲解了怎么基于状态机来构建基本的正则表达式引擎,讲这个初衷是为词法分析服务,不过书里的东西相对偏理论了些,实现起来还是要费些功夫的,只是它到底指明了一条路,当然,书里只针对基本的语法进行了分析讲解,对于在实际中有些非常有用的很多扩展语法,它就基本没有涉及了,这些扩展的语法中有些是比较好实现的,有些则很难. 基

Lucene:基于Java的全文检索引擎简介 (zhuan)

http://www.chedong.com/tech/lucene.html ********************************************** Lucene是一个基于Java的全文索引工具包. 基于Java的全文索引引擎Lucene简介:关于作者和Lucene的历史 全文检索的实现:Luene全文索引和数据库索引的比较 中文切分词机制简介:基于词库和自动切分词算法的比较 具体的安装和使用简介:系统结构介绍和演示 Hacking Lucene:简化的查询分析器,删除的

关于主流浏览器内核(排版引擎)(渲染引擎) 的整理介绍

 前情提要: 众多的浏览器,主流的内核就几个,那么各种浏览器究竟使用的哪种内核,各种内核又有什么优劣,使用浏览器的我们应有适当了解,选择自己喜欢的浏览器. 注 :内容多为查阅网络资料整理,少量个人主观意见,如有错误,谢谢指正. 一.什么是 浏览器内核 谷歌浏览器,火狐浏览器,360浏览器,Safari浏览器,欧朋浏览器,qq,uc,傲游,世界之窗...没错他们是浏览器.什么是浏览器内核呢 ,叫做“内核”基本上可以定义为浏览器最为重要的一部分,那么它的重要在哪里?它的作用是什么? 维基百科上这样解

Lucene:基于Java的全文检索引擎简介

Lucene是一个基于Java的全文索引工具包. 基于Java的全文索引引擎Lucene简介:关于作者和Lucene的历史 全文检索的实现:Luene全文索引和数据库索引的比较 中文切分词机制简介:基于词库和自动切分词算法的比较 具体的安装和使用简介:系统结构介绍和演示 Hacking Lucene:简化的查询分析器,删除的实现,定制的排序,应用接口的扩展 从Lucene我们还可以学到什么 另外,如果是在选择全文引擎,现在也许是试试 Sphinx的时候了:相比Lucene速度更快, 有中文分词的

第四十二课:基于CSS的动画引擎

由于低版本浏览器不支持css3 animation,因此我们需要根据浏览器来选择不同的动画引擎.如果浏览器支持css3 animation,那么就使用此动画引擎,如果不支持,就使用javascript的动画引擎. 首先,我们看一下判定条件,方便切换.前面说过,浏览器把所有事件类型的构造器放在window上,只不过不可遍历.我们用Object.getOwnPropertyNames(window),可以得到window对象中的所有属性,然后再filter一下,就能得到所有的事件构造器了.最后,我们