轉自:infoQ : 深入理解 iOS 圖文混排原理并自定義圖文控件
GitHub地址:CJLabel
iOS開發中一般用UILabel來展示文字、UIImageView用來顯示圖片、UIButton用于簡單的圖文點擊響應事件,稍復雜一點的可以借助NSAttributedString
來實現圖文混排需求,又或者將圖文內容轉換為HTML由WKWebView(UIWebView)來展示。然而以上方案都有各自的局限性:UILabel繪制NSAttributedString不能靈活定位文本內的點擊錨點區域,轉換為HTML展示則帶來Native與Web端交互成本以及WKWebView自身的性能問題。
那么,是否能有一種控件,在滿足富文本圖文混排的同時還能響應自定義錨點點擊事件?要實現以上需求,我們首先從iOS圖文展示原理說起。
圖文繪制架構
iOS7之后的圖文繪制架構如下圖所示,越往上封裝程度越高,但可定制程度也越低,本文涉及講解的主要是CoreText
以及CoreGraphics
層級 。
CoreGraphics
從下往上說,首先是CoreGraphics。這是一個C語言接口的核心圖形庫,而且它是跨平臺的類庫,iOS和macOS系統均可使用。雖然它很偏底層,但很多情況下其實你已經使用過它了:比如CGAffineTransform用于形變,CGBitmapContext用于截圖或者圖片繪制,CGContext用于獲取上下文進行直線、曲線、不規則圖形繪制等。
這里著重說明下CGContext上下文。上下文類似于進行繪畫時候的畫布,使用UIGraphicsGetCurrentContext()
可以快捷得到當前上下文,同時需要注意在CoreText下坐標系的原點為視圖的左下角,x軸向右為正方向,y軸向上為正方向。而UIKit坐標系的原點是視圖的左上角,x軸向右為正方向,y軸向下為正方向,所以我們在進行圖文繪制前需要進行坐標反轉,如圖所示:
//獲取上下文
CGContextRef c = UIGraphicsGetCurrentContext();
// 將當前圖形狀態推入堆棧
CGContextSaveGState(c);
// 設置字形變換矩陣為CGAffineTransformIdentity,也就是說每一個字形都不做圖形變換
CGContextSetTextMatrix(c, CGAffineTransformIdentity);
// 坐標轉換,UIKit 坐標原點在左上角,CoreText 坐標原點在左下角
CGContextTranslateCTM(c, 0.0f, insetRect.size.height);
CGContextScaleCTM(c, 1.0f, -1.0f);
// TODO:進行圖文繪制操作
//...
// 繪制完成,將堆棧頂部的狀態彈出,返回到之前的圖形狀態
CGContextRestoreGState(c);
以上使用CoreGraphics進行圖文繪制的過程,可以在drawRect:
或 drawTextInRect:
等相關方法中進行操作。
CoreText框架
CoreText是iOS中用于文本繪制的引擎,其位于UIKit
中和CoreGraphics/Quartz
之間。查看開發文檔,可以看到CoreText架構主要包含以下類,其中標紅部分是圖文繪制需要使用到的相關類,我們逐個介紹。
CTFramesetter
CTFramesetter是管理生成CTFrame的工廠類,其中記錄了需要繪制的文本內容中不同字符串對應的富文本屬性(加粗、顏色、字號等),通過NSAttributedString可生成CTFrameSetter。
NSMutableAttributedString *attributedStr = [[NSMutableAttributedString alloc] initWithString:text attributes:attributes];
//生成CTFramesetter
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attributedStr);
CFRelease(framesetter);
CTFrame
CTFrame描述了總的文本繪制區域的frame
,通過它你可以得到在指定區域內繪制的文本一共有多少行。
CGRect rect = CGRectMake(0, 0, 100, 100);
//生成繪制區域路徑
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, rect);
//生成CTFrame
CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, [attributedStr length]), path, NULL);
//獲取一共有多少行
CFArrayRef lines = CTFrameGetLines(frame);
CFRelease(frame);
CGPathRelease(path);
CTLine
CTLine記錄了需要繪制的單行文本信息,通過它你可以得到當前行的上行高、下行高以及行間距等信息。
//獲取第一行信息
CTLineRef line = CFArrayGetValueAtIndex(lines, 0);
//上行高、下行高、行間距
CGFloat lineAscent = 0.0f, lineDescent = 0.0f, lineLeading = 0.0f;
//獲取行寬、行高信息
CGFloat lineWidth = CTLineGetTypographicBounds(line, &lineAscent, &lineDescent, &lineLeading);
關于行文本的上下行高、行間距、原點、基線等的說明,可參照下圖:
系統繪制文本的時候,首先會以基線(Baseline)為基準,從當前行的基線最左側的原點(Origin)開始,計算得到上行高(Ascent),下行高(Descent),不同行之間的行間距(Leading),以及行寬信息。
CTRun
CTRun描述了單行文本中具有相同富文本屬性的字符實體,每一行文字中可能有多個CTRun,也有可能只包含一個CTRun。如下圖,這行文字中包含三個CTRun,分別為:這是
一段
測試數據
與CTLine一樣,同樣可以計算得到單個CTRun的繪制區域大小。
//初始化CTRun的區域大小為CGRectZero
CGRect runBounds = CGRectZero;
//初始化CTRun的上行高、下行高、行間距
CGFloat runAscent = 0.0f, runDescent = 0.0f, runLeading = 0.0f;
//計算得到上下行高、行間距以及CTRun繪制區域寬度
runBounds.size.width = (CGFloat)CTRunGetTypographicBounds(glyphRun, CFRangeMake(0, 0), &runAscent, &runDescent, &runLeading);
//計算高度,注意下行高為負數的情況
CGFloat runHeight = runAscent + fabs(runDescent);
runBounds.size.height = runHeight;
CTRunDelegate
CTRunDelegate用于圖文混排時候的圖片繪制,因為CoreText本身并不能進行圖文混排,但是可以使用CTRunDelegate在需要顯示圖片的地方添加占位符,當CoreText繪制到該位置的時候,會觸發CTRunDelegate代理,在代理方法中可以獲取到該區域的大小以及圖片信息,然后調用 CGContextDrawImage(c, runBounds, image.CGImage)
繪制圖片即可。
NSDictionary *imgInfoDic = @{kCJImage:image,//需要繪制的圖片
kCJImageWidth:@(size.width),//需要繪制的圖片區域寬度
kCJImageHeight:@(size.height),//需要繪制的圖片區域高度};
//創建CTRunDelegateRef并設置回調函數
CTRunDelegateCallbacks imageCallbacks;
imageCallbacks.version = kCTRunDelegateVersion1;
imageCallbacks.dealloc = RunDelegateDeallocCallback;
imageCallbacks.getWidth = RunDelegateGetWidthCallback;//圖片區域寬度回調
imageCallbacks.getAscent = RunDelegateGetAscentCallback;//圖片區域上行高回調
imageCallbacks.getDescent = RunDelegateGetDescentCallback;//圖片區域下行高回調
CTRunDelegateRef runDelegate = CTRunDelegateCreate(&imageCallbacks, (__bridge void *)imgInfoDic);
//初始化空白占位字符
unichar imgReplacementChar = 0xFFFC;
NSString *imgReplacementString = [NSString stringWithCharacters:&imgReplacementChar length:1];
//插入圖片 空白占位符
NSMutableString *imgPlaceholderStr = [[NSMutableString alloc]initWithCapacity:3];
[imgPlaceholderStr appendString:imgReplacementString];
NSRange imgRange = NSMakeRange(0, imgPlaceholderStr.length);
NSMutableAttributedString *imageAttributedString = [[NSMutableAttributedString alloc] initWithString:imgPlaceholderStr];
//將CTRunDelegate記錄到NSMutableAttributedString的富文本信息中
[imageAttributedString addAttribute:(NSString *)kCTRunDelegateAttributeName value:(__bridge id)runDelegate range:imgRange];
//kCJImageAttributeName為自定義的記錄圖片信息的富文本屬性
[imageAttributedString addAttribute:kCJImageAttributeName value:imgInfoDic range:imgRange];
看完上面的概念介紹,相信你已經對iOS的圖文繪制原理有了基礎的認識,以上各個類的關聯關系如圖所示:
完整的繪制流程如下:
自定義圖文混排控件
自定義圖文混排控件,可以基于UILabel來實現,UILabel本身已支持NSAttributedString富文本展示,我們只需在原有基礎上擴展指定字符區域背景色、插入圖片(或自定義view)展示、指定錨點點擊響應事件、點擊時候的字符高亮展示等功能即可。
繪制關鍵點說明
首先在設置NSAttributedString富文本屬性的時候,增加自定義屬性。NSAttributedString的富文本屬性Attributes其實就是字典,那么可以在其中添加自定義的key-value配置,類似以下說明:
/**
刪除線寬度。值為NSNumber。默認 `0.0f`,表示無刪除線
*/
extern NSString * const kCJStrikethroughStyleAttributeName;
/**
刪除線顏色。值為UIColor。默認 `[UIColor blackColor]`。
*/
extern NSString * const kCJStrikethroughColorAttributeName;
/**
對NSAttributedString文本設置錨點屬性時候的唯一標識
*/
extern NSString * const kCJLinkStringIdentifierAttributesName;
第二步就是在遍歷獲取CTRun時,將屬于錨點的CTRun的frame
區域信息記錄起來,同時還要記錄該錨點對應的擴展參數,以及合并具有相同屬性的CTRun。
第三步是繪制字符,如果包含自定義屬性,那么需要調用CoreGraphics的相關方法進行擴展屬性的設置,比如先填充背景色CGContextSetFillColorWithColor(c,color);
再繪制文字CTRunDraw(runRef, c, CFRangeMake(0, 0));
再添加邊框線、刪除線CGContextSetStrokeColorWithColor(c,color);
你可以把這個過程想象是成在一張畫布上繪畫,繪制時候需要注意不同圖層的層級關系,不然上面的圖層會將下面的圖層覆蓋。
最后一步是圖片展示,如果CTRun是包含CTRunDelegate的顯示區域,那么系統會將你設置好大小的區域空白出來,你只需在該位置上畫出圖片即可:CGContextDrawImage(c, runBounds, image.CGImage);
另外我還在此基礎上做了擴展,不單單支持圖文混排,還可以在指定區域上插入任意UIView。原理是同樣借助CTRunDelegate在對應位置上預留出指定大小的空白區域,然后將需要插入的UIView存儲在NSAttributedString的Attributes屬性中,當繪制到該位置時只需調用[self addSubview:view];
即可。
點擊響應
UILabel繼承自UIView和UIResponder,那么可以基于iOS的事件響應鏈機制來實現錨點點擊事件。
重寫hitTest: withEvent:
方法,在其中判斷是否需要響應點擊事件,否則將響應事件向下傳遞。
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
// ![self linkAtPoint:point extendsLinkTouchArea:NO]表示不在錨點點擊范圍內
if (![self linkAtPoint:point extendsLinkTouchArea:NO] || !self.userInteractionEnabled || self.hidden || self.alpha < 0.01) {
//如果支持選擇復制
if (self.enableCopy) {
return [super hitTest:point withEvent:event];
}else{
return nil;
}
}
return self;
}
至于如何判斷是否在錨點點擊范圍內,可參照linkAtPoint: extendsLinkTouchArea:
偽函數說明:
- (CJGlyphRunStrokeItem *)linkAtPoint:(CGPoint)point extendsLinkTouchArea:(BOOL)extendsLinkTouchArea {
// CJGlyphRunStrokeItem 對應 CTRun,其中記錄了字符區域(bounds)的大小
CJGlyphRunStrokeItem *resultItem = nil;
// _linkStrokeItemArray 表示記錄了所有錨點信息的數組
for (CJGlyphRunStrokeItem *item in _linkStrokeItemArray) {
if (CGRectContainsPoint(item.bounds, point)) {
resultItem = item;
}
}
return resultItem;
}
重寫touches系列方法,首先在touchesBegan: withEvent:
中判斷是否點擊了錨點區域,如果是則觸發重繪以達到點擊高亮效果。
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
UITouch *touch = [touches anyObject];
CJGlyphRunStrokeItem *item = [self linkAtPoint:[touch locationInView:self] extendsLinkTouchArea:self.extendsLinkTouchArea];
//點擊錨點存在
if (item) {
//TODO: 觸發重繪操作,達到點擊錨點高亮效果
}else{
[super touchesBegan:touches withEvent:event];
}
}
然后在touchesEnded: withEvent:
中處理點擊事件的響應操作
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
// 如果是長按點擊,交由長按點擊手勢UILongPressGestureRecognizer響應
if (_longPress) {
[super touchesEnded:touches withEvent:event];
}else{
if (_currentClickRunStrokeItem) {
// 如果當前是點擊錨點事件,拋出點擊回調事件
if (self.delegate && [self.delegate respondsToSelector:@selector(CJLable:didClickLink:)]) {
[self.delegate CJLable:self didClickLink:linkModel];
}
// TODO: 再次重繪,還原點擊前的文本顯示
}
else {
[super touchesEnded:touches withEvent:event];
}
}
}
這里需要注意一下,如果是雙擊點擊事件或者長按點擊事件,那么在touches系列的回調方法中是不能處理的,交互處理應該放到UITapGestureRecognizer
和UILongPressGestureRecognizer
的響應方法中判斷。另外手勢事件的響應方法中是無法得到當前點擊位置的點坐標CGPoint
的,這里用到了比較取巧的方式(通過Rumtime關聯屬性)達到了判斷點擊響應的效果。
// 在此時剛接收到手勢事件的回調中,將UITouch關聯到 UIGestureRecognizer實例
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch {
if (gestureRecognizer == self.longPressGestureRecognizer) {
objc_setAssociatedObject(self.longPressGestureRecognizer, &kAssociatedUITouchKey, touch, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
else if (gestureRecognizer == self.doubleTapGes) {
objc_setAssociatedObject(self.doubleTapGes, &kAssociatedUITouchKey, touch, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
return YES;
}
// 雙擊點擊事件
- (void)tapTwoAct:(UITapGestureRecognizer *)sender {
// Runtime屬性關聯,獲取到對應的UITouch,并計算得到點擊坐標:[touch locationInView:self]
UITouch *touch = objc_getAssociatedObject(self.doubleTapGes, &kAssociatedUITouchKey);
CJGlyphRunStrokeItem *item = [self linkAtPoint:[touch locationInView:self] extendsLinkTouchArea:self.extendsLinkTouchArea];
if (item) {
//TODO: 如果當前是雙擊點擊錨點事件,拋出點擊回調事件
if (self.delegate && [self.delegate respondsToSelector:@selector(CJLable:didClickLink:)]) {
[self.delegate CJLable:self didClickLink:linkModel];
}
// TODO: 再次重繪,還原點擊前的文本顯示
}
//如果不是點擊錨點且開啟了選擇復制功能
else {
if (self.enableCopy) {
//TODO: 顯示選擇復制提示視圖
}
}
}
// 長按點擊事件
- (void)longPressGestureDidFire:(UILongPressGestureRecognizer *)sender {
// Runtime屬性關聯,獲取到對應的UITouch,并計算得到點擊坐標:[touch locationInView:self]
UITouch *touch = objc_getAssociatedObject(self.longPressGestureRecognizer, &kAssociatedUITouchKey);
CGPoint point = [touch locationInView:self];
BOOL isLinkItem = [self containslinkAtPoint:[touch locationInView:self]];
switch (sender.state) {
//TODO: 如果當前是長按錨點事件,拋出長按回調事件
case UIGestureRecognizerStateBegan: {
if (isLinkItem) {
if (self.delegate && [self.delegate respondsToSelector:@selector(CJLable:didLongPressLink:)]) {
[self.delegate CJLable:self didLongPressLink:linkModel];
}
}
//如果不是點擊錨點且開啟了選擇復制功能
else {
if (self.enableCopy) {
//TODO: 長按顯示放大鏡
}
}
}
break;
}
case UIGestureRecognizerStateEnded:{
//TODO: 再次重繪,還原長按前的文本顯示
//如果支持復制
if (self.enableCopy) {
//TODO: 顯示選擇復制提示視圖
}
break;
}
case UIGestureRecognizerStateChanged:
//如果支持復制
if (self.enableCopy) {
//TODO: 更新放大鏡位置,以及更新選擇復制區域
}
break;
default:
break;
}
}
來看一下自定義點擊控件的效果圖
選擇、復制
自定義圖文混排控件還可以支持選擇復制功能,當然這里說的選擇復制不是指點擊喚起UIMenuController
菜單,然后出現復制剪切選項,點擊則只能復制所有文本。那樣的例子網上已經有很多,沒有必要在這里再大費周章地羅列說明。 其需要具備的是類似于UITextView或UIWebView那樣雙擊或長按,可出現拷貝、選擇、全選
選項,同時選中字符左右出現指示大頭針,拖動則有放大鏡提示當前選中的字符,并且要盡量做到與系統行為一致。
需求細化后選擇復制的要點主要包含以下:
- 選中字符后出現
拷貝、選擇 全選
菜單,這個使用系統的UIMenuController
功能即可實現,不存在難點問題。 - 對于選中的文字,起始要有大頭針標識,中間填充淺藍色背景,而且這一部分區域會是一塊不規則多邊形。系統沒有提供現成可復用的對應UI控件,但只要我們能夠判斷到選中區域,就能在左右畫上大頭針,中間填充顏色,所以這一塊也不存在問題。
- 拖動選擇的過程中,出現放大鏡來提示選中字符的更改。在能夠準確獲取到當前觸摸點坐標的前提下,只需要將觸摸點周圍區域的圖層截取并作
CGContextScaleCTM
縮放,然后再將放大后的圖層顯示出來即可,所以這個也是可以實現的。 - 最后便是重點了,如何判斷每一個字符對應的
frame
坐標位置,并要求在手指移動時能夠準確判斷選擇區域的變化。
前面已經講到,單行文本中具有相同富文本屬性的字符會被繪制到同一個CTRun
中,而通過CTRun
可以計算得到它的frame
大小。那么重點則變成如何使得每一個字符(圖片或插入UIView)對應一個CTRun
。
解決很簡單:只要保證NSAttributedString中每一個字符的Attributes屬性不一樣就可以了。我開始的做法是添加一個自定義屬性kCJIndexAttributesName
,然后給每個字符存儲不同的index值,并且在全部圖文遍歷繪制過一次后將kCJIndexAttributesName
移除,這樣在后續的重繪中就會減少CTRun
的拆分數量,提高了效率。
然而,理想很美好,現實很打擊。就算自定義屬性kCJIndexAttributesName
移除了,可CTRun
還是會被拆分為單個字符,但是如果使用系統自帶的屬性則不會如此。無奈只能從系統方法中尋找解決思路,幸好發現了NSLinkAttributeName
屬性,這是UITextView中用來設置http鏈接的擴展屬性,存儲的對象是NSURL
或NSString
類型,而UILabel默認是不支持http鏈點的,使用NSLinkAttributeName
屬性可以最大限度的降低UILabel對默認NSAttributedString展示的影響。同時為了更好的判斷計算,我將存儲的對象改為NSURL的子類CJCTRunUrl
。
//給每一個字符設置index值,enableCopy=YES時生效
__block NSInteger index = 0;
[attText.string enumerateSubstringsInRange:NSMakeRange(0, [attText length]) options:NSStringEnumerationByComposedCharacterSequences usingBlock:
^(NSString *substring, NSRange substringRange, NSRange enclosingRange, BOOL *stop) {
CJCTRunUrl *runUrl = nil;
if (!runUrl) {
NSString *urlStr = [NSString stringWithFormat:@"https://www.CJLabel%@",@(index)];
runUrl = [CJCTRunUrl URLWithString:urlStr];
}
runUrl.index = index;
runUrl.rangeValue = [NSValue valueWithRange:substringRange];
[attText addAttribute:NSLinkAttributeName
value:runUrl
range:substringRange];
index++;
}];
選擇復制視圖展示的交互邏輯則在雙擊或長按手勢中實現,前面 點擊響應 的偽代碼示意中已經說明。另外講解一下選擇復制視圖的實現細節:
其中的拷貝、選擇、全選
菜單使用系統提供的UIMenuController
實現,在雙擊或長按時只要將它顯示到手指點擊對應的位置上就行。
放大鏡則是自定義UIView,并在上面添加一個CALayer
,再在CALayer
上根據更新的點坐標做放大效果,CALayer
的放大境處理邏輯如下:
- (void)drawInContext:(CGContextRef)ctx {
CGContextTranslateCTM(ctx, self.frame.size.width/2, self.frame.size.height/2);
CGContextScaleCTM(ctx, 1.40, 1.40);
//self.pointToMagnify是更新的放大點坐標
CGContextTranslateCTM(ctx, -1 * self.pointToMagnify.x, -1 * self.pointToMagnify.y);
[CJkeyWindow().layer renderInContext:ctx];
CJkeyWindow().layer.contents = (id)nil;
}
大頭針包含的提示用戶選中區域,同樣由自定義UIView實現,在自定義view上面填充顏色以及在起始結束位置畫出大頭針,其中的填充顏色區域包含三部分headRect
middleRect
tailRect
這三部分存在任意組合的情況,填充顏色的時候要對這三部分區分開來分別進行填充。因為有可能存在只有headRect
middleRect
或只有middleRect
tailRect
,又或者只有 middleRect
的情況,而且填充顏色使用的是CoreGraphics
中的繪圖API:
CGContextRef ctx = UIGraphicsGetCurrentContext();
//填充的背景色
UIColor *backColor = CJUIRGBColor(0,84,166,0.2);
[backColor set];
CGContextAddRect(ctx, self.headRect);
CGContextStrokePath(ctx);
接下來便是如何顯示這三個選擇復制相關的視圖了,一開始我只是簡單的將它們添加到Label
上面來統一管理,但這樣會存在一個問題。那就是當頁面中存在多個Label
,并且對每個Label
分別執行選擇復制操作時,那么不同的label
上都會出現選擇復制視圖,這是與系統的默認行為是不一致的。權衡之后將以上三個視圖的顯示作為單例處理,全局只初始化一次,避免了重復初始化的開銷。并且將它添加到UIWindow
層,這樣在不同的Label
之間進行選擇復制時,也只會顯示一個選擇復制視圖。
寫在最后
以上便是iOS圖文繪制原理以及自定義圖文控件的說明,關鍵點是充分理解你看到的每一個字符在底層繪制顯示的時候與CTFrame
CTLine
CTRun
等實體類的對應關系,并借助其計算得到每一個字符的區域frame
信息,有了區域frame
信息便能夠擴展實現各種自定義功能(點擊響應、插入圖片、選擇拷貝等)。
全文完,更多的實現可以查看源碼CJLabel。