閑來無事想著自己搞個富文本的工具庫,不至于每次遇見這些東西就用別人的第三方。自己研究研究也有助于自己對這方面的理解。通過查找了相關的調研發現CoreText是一個好的框架,我們系統的UILabel等控件就是基于此框架封裝的。由此我也打算搞搞看
一、CoreText框架基礎
從此架構圖可以看出,CoreText是我們平時使用的UILabel、UITextField更底層的框架。它是基于Core Graphics的,所以性能上更加的快速。
UIWebView也是我們處理復雜的文字排本的方案,那CoreText和基于UIWebView相比有哪些異同呢?
優勢:
- CoreText占用的內存更少,渲染的速度更快,UIWebView占用的內存多,渲染速度更慢。
- CoreText 在渲染界面前就可以精確地獲得顯示內容的高度(只要有了CTFrame就可以),而UIWebView只有渲染出內容后,才能獲得內容的高度(通過js代碼獲取)
- CoreText的CTFrame可以在后臺線程渲染,UIWebView的內容只能在主線程(UI線程)渲染。
- 基于CoreText可以做更好的原生交互效果,交互效果可以更細膩。而UIWebView的交互效果都是用JS來實現的,在交互效果上會有一些卡頓情況存在。例如在UIWebView下,一個簡單的按鈕按下操作,都無法做出原生按鈕的即時和細膩效果。
劣勢:
- CoreText渲染出來的內容不能像UIWebView那樣方便的支持內容的復制。
- 基于CoreText來排版需要自己處理很多復雜邏輯,例如需要自己處理圖片與文字混排相關的邏輯,也需要自己實現鏈接點擊操作的支持。
業界很多應用都采用了基于CoreText技術的排版方案,例如:新浪微博客戶端、多看閱讀客戶端。
常用類、屬性
- CTFrameRef
- CTFramesetterRef
- CTLineRef
- CTRunRef
- CTTypesetterRef
- CTGlyhInfoRef (NSGlyphInfo)
- CTParagraphStyleRef (NSParapraphStyle)
- CTFontRef (UIFont)
- CFArrayRef (NSArray)
字體結構:
CTFrame、CTRun、CTLine
- CTFrame可以想象成一個畫布,畫布的大小范圍由CGPath決定
- CTFrame由很多CTLine組成,CTLine表示為一行CTLine由多個CTRun組成,CTRun相當于一行中的多個塊(格式為一致的字為一個塊)
但是CTRun不需要你自己創建,由NSAttributedString的屬性決定,系統自動生成。每個CTRun對應不同屬性。 - CTFramesetter是一個工廠,創建CTFrame,一個界面上可以有多個CTFrame
- CTFrame就是一個基本畫布,然后一行一行繪制。CoreText會自動根據傳入的NSAttributedString屬性創建CTRun,包括字體樣式,顏色,間距等
流程
- 創建AttributedString,定義樣式
- 2、通過CFAttributedStringRef生成CTFramesetter
- 通過CTFramesetter得到CTFrame
- 4.繪制(CTFrameDraw)
- 5.如果有圖片存在,先在AttributedString對應位置添加占位字符(空字符串),因為CoreText是不支持圖片的
- 通過回調函數確定圖片的寬高(CTRunDelegateCallbacks)
- 遍歷到對應CTRun上、獲取對應CGRect、繪制圖片(CGContextDrawImage)
- 8.如果想做點擊對應的圖片的回調可以記錄圖片的位置,同時確定點擊的位置是不是在圖片的位置上做處理
- 如果想做鏈接點擊處理,則需要確定此鏈接上的所有CTRun的位置,然后判斷點擊的點是不是在此位置上做處理
二、基本的文本樣式的具體代碼
CoreText是需要我們自己處理繪制,不像UILabel等最上層的控件,我們必須在drawRect中繪制,為了更好的使用,我們稍微封裝一哈,自定義一個UIView
我們在使用最上層的控件時,坐標系的原點在左上角,而底層的CoreGraphics的坐標原點則在左下角,(垮平臺圖形繪制框架OpenGL的坐標系就是如此的)
- (void)drawRect:(CGRect)rect {
[super drawRect:rect];
//step 1:獲取當前畫布的上下文,用于后續將內容繪制在畫布上
CGContextRef context = UIGraphicsGetCurrentContext();
//step 2: 創建繪制區域,CoreText本身支持各種文字排版的區域,我們這里使用整個UIView作為排版區域
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, self.bounds);
//step 3: 創建繪制的文字,為NSAttributedString類型
NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:@"xXHhofiyYI這是一段中文,前面是大小寫"];
//step 4: 通過NSAttributedString轉換為CTFramesetterRef,然后通過CTFramesetterRef創建CTFrameRef
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attributedString);
CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, [attributedString length]), path, NULL);
//step 5: 開始繪制文字
CTFrameDraw(frame,context);
//step 6: 釋放對象
CFRelease(frame);
CFRelease(path);
CFRelease(framesetter);
//使用Create函數建立的對象引用,必須要使用CFRelease掉。
}
此時得到的效果如下:是翻轉的
結果分析:發現文案是反的。原因就是因為coreText的坐標系和UIKit的坐標系不一樣的。
因此我們需要將坐標系進行翻轉
CGContextSetTextMatrix(context, CGAffineTransformIdentity);
CGContextTranslateCTM(context, 0, self.bounds.size.height);
CGContextScaleCTM(context, 1.0, -1.0);
此時就的得到了一個正常的文字顯示。
以上的繪制方式都是基于CTFrame繪制的,還可以按照CTLine和CTRun繪制:
按CTLine繪制
// 通過CTLine
// 1.獲得CTLine數組
CFArrayRef lines = CTFrameGetLines(frame);
// 2.獲得行數
CFIndex indexCount = CFArrayGetCount(lines);
// 3.獲得每一行的origin, CoreText的origin是在字形的baseLine(基準線)處
CGPoint origins[indexCount];
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), origins);
// 4.遍歷每一行進行繪制
for (int i = 0; i < indexCount; i++) {
CTLineRef line = CFArrayGetValueAtIndex(lines, i);
CTLineDraw(line, context);
}
// 繪制的文字內容是:Worl按季度交發十大減肥;阿技術點發覺啊;啥的積分;阿斯加德發;安靜是的;發jakdfads;fjas;lsd f安靜的首付款撒;時間點發;安靜都是;發覺啊;是的發;啊打發;
結果如下:
從UIView的底部開始繪制的,且沒有繪制安全,還是從基準線分割了文字
按CTRun繪制
用下面函數替換CTLineDraw(line, context)這一句就可以了,
for (int i = 0; i < indexCount; i++) {
CTLineRef line = CFArrayGetValueAtIndex(lines, i);
CFArrayRef runs = CTLineGetGlyphRuns(line);
CFIndex runCount = CFArrayGetCount(runs);
for (int j = 0; j < runCount; j++) {
CTRunRef run = CFArrayGetValueAtIndex(runs, j);
CTRunDraw(run, context, CFRangeMake(0, 0));
}
}
結果如下:
文字疊加了
三、圖文混排
CoreText本身是不提供UIImage的繪制的,所以UIImage肯定只能通過Core Graphics繪制,但是繪制時必須要知道繪制單元的長寬,慶幸的是CoreText繪制的最小單元CTRun提供了CTRunDelegate,也就是當設置了kCTRunDelegateAttributedName之后,CTRun的繪制時所需的參考(長寬等)將可以從委托中獲取,我們即可通過此方法實現圖片的繪。在需要繪制圖片的位置,提前預留空白位。
CTRun有幾個委托用以實現CTRun的幾個參數的獲取
以下是CTRunDelegateCallbacks的幾個委托代理
typedef struct
{
CFIndex version;
CTRunDelegateDeallocateCallback dealloc;
CTRunDelegateGetAscentCallback getAscent;
CTRunDelegateGetDescentCallback getDescent;
CTRunDelegateGetWidthCallback getWidth;
} CTRunDelegateCallbacks;
以下是基本繪制
/// 固定圖文混排
- (void) drawTextAndImg {
// CoreText為了排版,需要將顯示的文本內容,位置,字體,字形傳遞給Quartz
// 步驟1 獲取當前畫布的上下文,用于后續將內容繪制在畫布上
CGContextRef context = UIGraphicsGetCurrentContext();
/*
步驟2
將坐標系上下翻轉。對于底層的繪制引擎來說,屏幕的左下角是(0,0)坐標。
而對于上層的UIKit來說,左上角是(0,0)坐標。所以我們為了之后的坐標系描述按UIKit來做,先在這里做一個坐標系的上下翻轉操作。
翻轉之后,底層和上層的(0,0)坐標就是重合了
*/
CGContextSetTextMatrix(context, CGAffineTransformIdentity); // 設置矩陣(紋理)
CGContextTranslateCTM(context, 0, self.bounds.size.height); // 內容翻轉
CGContextScaleCTM(context, 1.0, -1.0); //
/*
步驟3
創建繪制的區域,CoreText本身支持各種文字排版的區域,我們這里簡單的將UIView的整個界面作為排版的區域。
為了加深理解,我們可以替換區域為下面的橢圓區域
*/
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, self.bounds);
// CGPathAddEllipseInRect(path, NULL, self.bounds); // 橢圓區域
/**
含有圖片的 步驟1
*/
CTRunDelegateCallbacks imageCallBacks;
imageCallBacks.version = kCTRunDelegateCurrentVersion;
imageCallBacks.dealloc = ImgRunDelegateDeallocCallback;
imageCallBacks.getAscent = ImgRunDelegateGetAscentCallback;
imageCallBacks.getDescent = ImgRunDelegateGetDescentCallback;
imageCallBacks.getWidth = ImgRunDelegateGetWidthCallback;
NSString *imgName = @"coretext-image-1.jpg";
CTRunDelegateRef imgRunDelegate = CTRunDelegateCreate(&imageCallBacks, (__bridge void * _Nullable)(imgName)); // 我們也可以傳入其他參數
NSMutableAttributedString *imgAttributedStr = [[NSMutableAttributedString alloc] initWithString:@" "];
[imgAttributedStr addAttribute:(NSString *)kCTRunDelegateAttributeName value:(__bridge id)imgRunDelegate range:NSMakeRange(0, 1)];
// 步驟4
NSMutableAttributedString *attString = [[NSMutableAttributedString alloc] initWithString:@" Worl按季度交發十大減肥;阿技術點發覺啊;啥的積分;阿斯加德發;安靜是的;發jakdfads;fjas;lsd f安靜的首付款撒;時間點發;安靜都是;發覺啊;是的發;啊打發; "];
[attString addAttribute:(NSString *)kCTBackgroundColorAttributeName value:[UIColor redColor] range:NSMakeRange(0, 10)];
[attString addAttribute:(NSString *)kCTFontAttributeName value:[UIFont systemFontOfSize:18] range:NSMakeRange(0, 10)];
/**
含有圖片的 步驟2
*/
#define kImgName @"imgName"
// 圖片占位符添加
[imgAttributedStr addAttribute:kImgName value:imgName range:NSMakeRange(0, 1)];
[attString insertAttributedString:imgAttributedStr atIndex:30];
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attString);
CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, [attString length]), path, NULL);
// 步驟5 開始繪制
CTFrameDraw(frame, context);
/**
含有圖片的 步驟3 繪制圖片
*/
// 通過CTLine
// 1.獲得CTLine數組
CFArrayRef lines = CTFrameGetLines(frame);
// 2.獲得行數
CFIndex indexCount = CFArrayGetCount(lines);
// 3.獲得每一行的origin, CoreText的origin是在字形的baseLine(基準線)處
CGPoint lineOrigins[indexCount];
// 獲得第幾行的起始點
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), lineOrigins);
for (int i = 0; i < indexCount; i++) {
CTLineRef line = CFArrayGetValueAtIndex(lines, i);
CGFloat lineAscent; // 上緣線
CGFloat lineDescent; // 下緣線
CGFloat lineLeading; // 行間距
// 獲取此行的字形參數
CTLineGetTypographicBounds(line, &lineAscent, &lineDescent, &lineLeading);
// 獲取此行中每個CTRun
CFArrayRef runs = CTLineGetGlyphRuns(line);
CFIndex runCount = CFArrayGetCount(runs);
for (int j = 0; j < runCount; j++) {
CGFloat runAscent; // 此CTRun上緣線
CGFloat runDescent; // 此CTRun下緣線
CGFloat runLeading; // CTRun間距
CGPoint lineOrigin = lineOrigins[i]; // 此行起點
// 獲取此CTRun
CTRunRef run = CFArrayGetValueAtIndex(runs, j);
// 獲取該run上的屬性特征
NSDictionary *runAttributeds = (NSDictionary *)CTRunGetAttributes(run);
CGRect runRect;
// 獲取此CTRun的上緣線、下緣線,并由此獲取CTRun和寬
runRect.size.width = CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &runAscent, &runDescent, &runLeading);
// CTRun的X坐標
CGFloat runOrgX = lineOrigin.x + CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, NULL);
#warning ---此處的y結果沒看懂,高也沒看懂---
// 此處的y結果沒看懂 高也沒看懂
runRect = CGRectMake(runOrgX, lineOrigin.y - runDescent, runRect.size.width, runAscent + runDescent);
// 通過run的屬性特征獲得圖片名稱的字符串
NSString *imgName = [runAttributeds objectForKey:kImgName];
NSLog(@"圖片名稱===%@",imgName);
if (imgName != nil) {
UIImage *image = [UIImage imageNamed:imgName];
if (image) {
#warning ---此處的坐標計算也沒看懂---
CGRect imageRect;
imageRect.size = CGSizeMake(40, 20);
imageRect.origin.x = runRect.origin.x + lineOrigin.x;
imageRect.origin.y = lineOrigin.y;
CGContextDrawImage(context, imageRect, image.CGImage);
}
}
}
}
// 步驟6 釋放內存
CFRelease(imgRunDelegate);
CFRelease(frame);
CFRelease(path);
CFRelease(framesetter);
}
#pragma mark ---代理函數CTRunDelegateCallbacks---
void ImgRunDelegateDeallocCallback(void *refCon) {
}
/// 通過此函數設置圖片處上部高
CGFloat ImgRunDelegateGetAscentCallback(void *refCon) {
NSString *imageName = (__bridge NSString *)refCon;
// return [UIImage imageNamed:imageName].size.height;
return 40;
}
/// 通過此函數設置圖片處下部高
CGFloat ImgRunDelegateGetDescentCallback(void *refCon) {
return 0;
}
/// 通過此函數設置圖片位置寬度
CGFloat ImgRunDelegateGetWidthCallback(void *refCon) {
NSString *imageName = (__bridge NSString *)refCon;
// return [UIImage imageNamed:imageName].size.width;
return 40;
}
結果如下:
基于以上這個原型,我們可以封裝一個比較完整的富文本控件了,比如定義HTML協議或者JSON,然后在內部進行解析,然后根據類型與相應的屬性進行繪制。
四、圖片點擊事件
CoreText就是將內容繪制到畫布上,自然沒有事件處理,我們要實現圖片與鏈接的點擊效果就需要使用觸摸事件了。當點擊的位置在圖片的Rect中,那我們做相應的操作即可,所以基本步驟如下:
- 記錄所有圖片所在畫布中作為一個CTRun的位置
- 獲取每個圖片所在畫布中所占的Rect矩形區域
- 當點擊事件發生時,判斷點擊的點是否在某個需要處理的圖片Rect內。
這里為了演示的簡單,我們直接在drawRect中記錄圖片的相應坐標,但是一般我們會在CTDisplayView渲染之前對數據進行相應的處理,比如處理傳入的樣式數據、記錄圖片與鏈接等信息。
用于記錄圖片信息類
@interface CTImageData : NSObject
@property (nonatomic,strong) NSString *imgHolder;
@property (nonatomic,strong) NSURL *imgPath;
@property (nonatomic) NSInteger idx;
@property (nonatomic) CGRect imageRect;
@end
// 記錄圖片信息
//以下操作僅僅是演示示例,實戰時請在渲染之前處理數據,做到最佳實踐。
if(!_imageDataArray){
_imageDataArray = [[NSMutableArray alloc]init];
}
BOOL imgExist = NO;
for (CTImageData *ctImageData in _imageDataArray) {
if (ctImageData.idx == idx) {
imgExist = YES;
break;
}
}
if(!imgExist){
CTImageData *ctImageData = [[CTImageData alloc]init];
ctImageData.imgHolder = imgName;
ctImageData.imageRect = imageRect;
ctImageData.idx = idx;
[_imageDataArray addObject:ctImageData];
}
- (void)setupEvents{
UITapGestureRecognizer *tapRecognizer = [[UITapGestureRecognizer alloc]initWithTarget:self action:@selector(userTapGestureDetected:)];
[self addGestureRecognizer:tapRecognizer];
self.userInteractionEnabled = YES;
}
- (void)userTapGestureDetected:(UIGestureRecognizer *)recognizer{
CGPoint point = [recognizer locationInView:self];
//先判斷是否是點擊的圖片Rect
for(CTImageData *imageData in _imageDataArray){
CGRect imageRect = imageData.imageRect;
CGFloat imageOriginY = self.bounds.size.height - imageRect.origin.y - imageRect.size.height;
CGRect rect = CGRectMake(imageRect.origin.x,imageOriginY, imageRect.size.width, imageRect.size.height);
if(CGRectContainsPoint(rect, point)){
NSLog(@"tap image handle");
return;
}
}
//再判斷鏈接
}
五、鏈接點擊事件
記錄鏈接信息類
@interface CTLinkData : NSObject
@property (nonatomic ,strong) NSString *text;
@property (nonatomic ,strong) NSString *url;
@property (nonatomic ,assign) NSRange range;
@end
記錄鏈接信息
if(!_linkDataArray){
_linkDataArray = [[NSMutableArray alloc]init];
}
CTLinkData *ctLinkData = [[CTLinkData alloc]init];
ctLinkData.text = [attributedString.string substringWithRange:linkRange];
ctLinkData.url = @"http://www.baidu.com";
ctLinkData.range = linkRange;
[_linkDataArray addObject:ctLinkData];
處理鏈接事件
if(!_linkDataArray){
_linkDataArray = [[NSMutableArray alloc]init];
}
CTLinkData *ctLinkData = [[CTLinkData alloc]init];
ctLinkData.text = [attributedString.string substringWithRange:linkRange];
ctLinkData.url = @"http://www.baidu.com";
ctLinkData.range = linkRange;
[_linkDataArray addObject:ctLinkData];
根據點擊點獲取字符串偏移
- (CFIndex)touchPointOffset:(CGPoint)point{
//獲取所有行
CFArrayRef lines = CTFrameGetLines(_ctFrame);
if(lines == nil){
return -1;
}
CFIndex count = CFArrayGetCount(lines);
//獲取每行起點
CGPoint origins[count];
CTFrameGetLineOrigins(_ctFrame, CFRangeMake(0, 0), origins);
//Flip
CGAffineTransform transform = CGAffineTransformMakeTranslation(0, self.bounds.size.height);
transform = CGAffineTransformScale(transform, 1.f, -1.f);
CFIndex idx = -1;
for (int i = 0; i< count; i++) {
CGPoint lineOrigin = origins[i];
CTLineRef line = CFArrayGetValueAtIndex(lines, i);
//獲取每一行Rect
CGFloat ascent = 0.0f;
CGFloat descent = 0.0f;
CGFloat leading = 0.0f;
CGFloat width = (CGFloat)CTLineGetTypographicBounds(line, &ascent, &descent, &leading);
CGRect lineRect = CGRectMake(lineOrigin.x, lineOrigin.y - descent, width, ascent + descent);
lineRect = CGRectApplyAffineTransform(lineRect, transform);
if(CGRectContainsPoint(lineRect,point)){
//將point相對于view的坐標轉換為相對于該行的坐標
CGPoint linePoint = CGPointMake(point.x-lineRect.origin.x, point.y-lineRect.origin.y);
//根據當前行的坐標獲取相對整個CoreText串的偏移
idx = CTLineGetStringIndexForPosition(line, linePoint);
}
}
return idx;
}
下面是我寫的一個demo,封裝好的圖文混排的,可以看看,實戰的話稍微修改修改就好,數據是通過json傳入的。## demo鏈接