基於 CoreText 的排版引擎

本章前言

使用 CoreText 技術,咱們能夠對富文本進行復雜的排版。通過一些簡單的擴展,咱們還能夠實現對於圖片,連接的點擊效果。CoreText 技術相對於 UIWebView,有着更少的內存佔用,以及能夠在後臺渲染的優勢,很是適合用於內容的排版工做。 javascript

本章咱們將從最基本的開始,一步一步完成一個支持圖文混排、支持圖片和連接點擊的排版引擎。 java

CoreText 簡介

CoreText 是用於處理文字和字體的底層技術。它直接和 Core Graphics(又被稱爲 Quartz)打交道。Quartz 是一個 2D 圖形渲染引擎,可以處理 OSX 和 iOS 中的圖形顯示。 git

Quartz 可以直接處理字體(font)和字形(glyphs),將文字渲染到界面上,它是基礎庫中惟一可以處理字形的模塊。所以,CoreText 爲了排版,須要將顯示的文本內容、位置、字體、字形直接傳遞給 Quartz。相比其它 UI 組件,因爲 CoreText 直接和 Quartz 來交互,因此它具備高速的排版效果。 github

下圖是 CoreText 的架構圖,能夠看到,CoreText 處於很是底層的位置,上層的 UI 控件(包括 UILabel,UITextField 以及 UITextView)和 UIWebView 都是基於 CoreText 來實現的。 json

注意:這個是 iOS7 以後的架構圖,在 iOS7 之前,並無圖中的 Text Kit 類,不過 CoreText 仍然是處在最底層直接和 Core Graphics 打交道的模塊。 設計模式

CoreText 的架構圖

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 的基礎排版引擎

不帶圖片的排版引擎

下面咱們來嘗試完成一個基於 CoreText 的排版引擎。咱們將從最簡單的排版功能開始,而後逐步支持圖文混排,連接點擊等功能。 app

首先咱們來嘗試完成一個不支持圖片內容的純文字排版引擎。

注意 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圖 2

圖 3圖 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)

圖 4

以後,咱們運行程序,就能夠看到,Hello World 出如今程序正中間了。以下圖。

圖 5

代碼解釋

下面解釋一下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這一項取消勾選。以下圖所示:

圖:取消勾選 Autolayout

如下是本框架的 UML 示意圖,從圖中咱們能夠看出,這 4 個 Core Text 類的關係是這樣的:

  1. CTFrameParser經過CTFrameparserConfig實例來生成CoreTextData實例。
  2. CTDisplayView經過持有CoreTextData實例來得到繪製所須要的全部信息。
  3. ViewController類經過配置CTFrameparserConfig實例,進而得到生成的CoreTextData實例,最後將其賦值給他的CTDisplayView成員,達到將指定內容顯示在界面上的效果。

圖:UML 示意圖

說明 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切換到當前章節狀態,查看相關代碼邏輯。

相關文章
相關標籤/搜索