做者:陳浩 貝聊科技移動開發部 iOS 工程師git
Core Text 是蘋果提供的富文本排版技術,能夠定製開發圖文混排功能,DTCoreText、Nimbus、YYLabel 等優秀的開源庫底層都是基於 Core Text 的封裝和擴展。本文將介紹 Core Text 的基本用法,逐步講解我是如何封裝一個 AttributedLabel 的。github
本文已發表在我的博客。數組
文本排版是根據給定的文本(text)、字體(font)、繪製區域(shape)、行高(line height)等相關屬性,生成出字形(glyphs)佈局在屏幕繪製區的適當位置。排版的核心就是將字符(characters)轉換成字形,將字形排列成行(lines),再將行排成段落(paragraphs)。用代碼表達就是下邊寥寥幾行。工具
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attributedString);
CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, [attributedString length]), path, NULL);
CTFrameDraw(frame, context);
複製代碼
這裏的主要步驟有:佈局
在繼續深刻代碼以前,先了解如下幾個小概念:字體
簡單說字體就是映射到字符的字形集合,如下就是字符 a (ascii 碼爲 97)的不一樣字形:動畫
而同一字體下字形也可能會有所不一樣,在英文中比較常見的如連字,典型的就是fi中 i 的點常與 f 的鉤合併:ui
接下來講說字體,在開發中咱們常說同一字體不一樣字號,好比 [UIFont systemFontOfSize: 16]
和 [UIFont systemFontOfSize: 18]
,或者同一字體可是加粗顯示,又如 [UIFont systemFontOfSize: 16]
和 [UIFont boldSystemFontOfSize: 16]
,又或者斜體,然而這對於系統而言是徹底不一樣的字體。這兒想說明的是:不一樣字號是不一樣的字體,粗體相對普通也是不一樣的字體,而給文本添加下劃線倒是個例外(下劃線是系統額外畫的一條裝飾線)。spa
有時咱們在開發中也會接觸到字體的 Ascent 和 Descent,其實就是在於字形度量(Glyph metrics)打交道:設計
由上圖可知,一個字符最高點到基線的偏移叫作 Ascent,最低點到基線的偏移叫作 Descent,單行的行高 Line Height 由 Ascent、Descent 與 Line Gap 相加得出。
Core Text 須要使用 CTFramesetter 對文本進行佈局,位於上圖中最頂端的 CTFramesetter,它要求以 Attributed String 和繪製區域的形狀(CGPath)做爲入參,來建立 CTFrame(能夠不止一個 CTFrame) ,顧名思義,這就是文本佈局所在的 frame,肯定好繪製區域後,framesetter 就能將段落樣式(NSParagraphStyle)的 lineBreakMode、lineSpacing 等屬性應用於此。 這裏有必要提一下 CTRun,從 CTRun 咱們能夠獲取許多重要的屬性,這在開發排版功能的時候很是有用,下面這張圖有助於咱們瞭解什麼是 CTRun:
這一行文本能夠認爲是一個 CTLine 對象,由從左往右的順序依次包含了默認字體樣式、加粗字體樣式、默認字體樣式、小字號藍色樣式、正常字號藍色樣式和默認字體樣式共 6 種 Attributed。每一種樣式的字符則表示一個 CTRun 對象。
瞭解了這些概念以後,就能夠實現排版功能了。
進入正題以前,再儲備些基礎知識。
Core Text 使用了 Core Foundation 基於 C 語言的 API,因此須要遵循 Core Foundation 的內存管理規則。
CTFramesetterRef CTFramesetterCreateWithAttributedString(
CFAttributedStringRef string )
複製代碼
CFStringRef CFAttributedStringGetString(CFAttributedStringRef aStr)
複製代碼
明白了這點,就對項目中何時該調用 CFRelease,何時不應調用作到心中有數了。
關於 __bridge
關鍵字
__bridge
只是聲明類型轉變,但不作內存管理規則的轉變。__bridge_retained
表示指針類型轉變的同時,將內存管理由原來的 Objective-C 交給 Core Foundation 處理,即 ARC to MRC。__bridge_transfer
表示內存管理由 Core Foundation 交給 Objective-C,即 MRC to ARC。另外,Core Text 最初是設計給 mac 的,它的座標系是 mac 座標系(原點在左下角),因此一般須要對座標進行翻轉,這也是下文說起爲何須要翻轉的原因。
CGContextSetTextMatrix(context, CGAffineTransformIdentity);
CGContextTranslateCTM(context, 0, size.height);
CGContextScaleCTM(context, 1.0, -1.0);
複製代碼
相對於 UIView,CALayer 一般是比較「輕」的,咱們在平常開發中接觸 layer 比較多的仍是設置 cornerRadius、contents、mask 或者作個動畫等,而在這個項目中,依靠 layer 的 - (void)display
方法,讓其充當了一個 「橋樑」 的做用。
先來了解下 - (void)display
方法,如文檔裏所說,layer 會在適當的時候調用該方法來更新 layer 的 contents,可是並不建議直接調用該方法,子類化能夠重寫該方法,並能直接設置 layer 的 contents。文檔的最後一句話大大盤活了自定義的 AttributedLabel,當 AttributedLabel 須要改變 text、frame、font、attributedString…時,AttributedLabel 不用關心具體的繪製,只需告知下 layer 須要 display 便可。因爲將 AttributedLabel 的 + (Class)layerClass
返回了子類化的 layer。
+ (Class)layerClass {
return [ZPLabelLayer class];
}
複製代碼
layer 的 delegate 對象就是 AttributedLabel,因此 layer 就能經過它的 delegate 屬性獲取到 AttributedLabel 的上述屬性,進一步調用 Core Text 繪製出新的 contents 進行設置。這是作這個項目時最乾淨利落的一個地方。
若是無需處理高亮交互等定製(截斷、附件)效果,咱們在拿到 NSAttributedString 和 CGPath 便可將文本繪製到 context 上。對於連接而言,雖然咱們能經過 NSDataDetector 標記出文本中哪些地方須要高亮顯示,可是需求每每要能對連接進行點擊跳轉,在使用 CTFrameDraw 方法繪製文本時,既不知道高亮過的文本位置,更沒法談及對高亮文本的交互響應了。
幸運的是,Core Text 另外還有個稍微複雜點的繪製方法 CTLineDraw,從名字能夠得知它是用來繪製 line 的,感觀上要比 CTFrameDraw 的確要精細許多。咱們先看看添加高亮功能的實現思路。
假設上述高亮相關屬性都由 AttributedLabel 處理,使用者每次添加高亮不只要讓 AttributedLabel 改變內部文本的 Attributed 屬性,考慮到一段文本可能有多處高亮,其自己也還須要維護一個處理高亮的數組。然而對設置高亮來講,這本就是 NSAttributedString 能作到的事,若讓 UI 層來處理這些邏輯並非很好。再者,對於調用者來講,雖然能夠將上述屬性封裝成 model 方便 AttributedLabel 使用,但若是想複用 NSAttributedString 就變得不可能了。看來交由 NSAttributedString 來處理高亮相關屬性是最合適不過的了,這裏經過建立 NSAttributedString 的 category 和 AssociatedObject 知足了需求。
最終從 NSAttributedString 中獲取到高亮的 ranges,再配合 CTLineDraw 繪製行的時候獲取到 run (文章前面介紹)的 range,先來看看代碼:
self.ranges = attributedStr.highlightRangeArray; // 獲取 ranges
...
// 遍歷行
for (CFIndex lineIndex = 0; lineIndex < numberOfLines; lineIndex++) {
CTLineRef line = CFArrayGetValueAtIndex(lines, lineIndex);
...
CFArrayRef runs = CTLineGetGlyphRuns(line);
// 遍歷行的每個 run
for (int j = 0; j < CFArrayGetCount(runs); j++) {
...
CFRange range = CTRunGetStringRange(run);
for (NSString *rangeString in self.ranges) {
NSRange hightlightRange = NSRangeFromString(rangeString);
NSRange lineRange = NSMakeRange(range.location, range.length);
// 獲得屬於高亮的 range
if (NSIntersectionRange(hightlightRange, lineRange).length > 0) {
複製代碼
接下來獲取具體的 CGRect,注意在獲取 CGRect 時還需將座標翻轉:
CGAffineTransform transform = CGAffineTransformMakeTranslation(0, contentHeight);
transform = CGAffineTransformScale(transform, 1.f, -1.f);
CGRect flipRect = CGRectApplyAffineTransform(runRect, transform);
// 保存連接的CGRect
NSRange nRange = NSMakeRange(range.location, range.length);
self.framesDict[NSStringFromRange(nRange)] = [NSValue valueWithCGRect:flipRect];
複製代碼
到這已經基本獲取到高亮文本的位置,爲何說是基本呢?由於漏了個連接換行的問題,當連接換行顯示時,就會產生多個 CTRun 對象,這些 CTRun 對應的 CGRect 都會存在 framesDict 中,當用戶點擊換行的連接某部分(range)時,它只能響應到 framesDict 中的一個 CGRect,而正確的作法是應該響應某個連接在 framesDict 中的全部 CGRect,只有這樣才能完整的高亮出一條連接的全部部分,本質就是要未來自同一條連接的若干 CGRect 關聯起來。
說了這麼多,實現起來卻不困難,這裏採用了連接的 range 作爲 key,CGRect 的數組作爲 value,而後判斷用戶的 range 在不在連接的 range 中,若屬於某條連接的 range,經過連接的 range 取出 CGRect 的數組渲染便可。
當 UILabel 顯示不全字符串的時候,系統會在文本的最後添加「…」。一樣,AttributedLabel 也提供了添加「…」的默認處理,並在此基礎上提供了讓用戶自定義截斷內容的功能。這裏的實現並不難,直接截取最後一行的文本,再不斷倒序刪除最後一行的字符直到最後一行能容納得下 TruncationText 爲止。
首先咱們仍是要調用 CoreText 的 API 獲取到最後一行的 range:
CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, [self length]), path, NULL);
CFArrayRef lines = CTFrameGetLines(frame);
NSInteger numberOfLines = CFArrayGetCount(lines);
...
NSInteger lastLineIndex = numberOfLines - 1 < 0 ? 0 : numberOfLines - 1;
CTLineRef line = CFArrayGetValueAtIndex(lines, lastLineIndex);
CFRange lastLineRange = CTLineGetStringRange(line);
複製代碼
接着使用最後一行的 range 從 AttributedString 中獲取到子文本:
//截到最後一行
NSUInteger truncationAttributePosition = lastLineRange.location + lastLineRange.length;
NSMutableAttributedString *cutAttributedString = [[self attributedSubstringFromRange:NSMakeRange(0, truncationAttributePosition)] mutableCopy];
NSMutableAttributedString *lastLineAttributeString = [[cutAttributedString attributedSubstringFromRange:NSMakeRange(lastLineRange.location, lastLineRange.length)] mutableCopy];
複製代碼
遞歸調用每次刪除子文本最後一個字符的方法:
- (NSMutableAttributedString *)handleLastLineAttributeString:(NSMutableAttributedString *)attributeString withTruncationText:(NSMutableAttributedString *)truncationText width:(CGFloat)width {
CTLineRef truncationToken = CTLineCreateWithAttributedString((CFAttributedStringRef)attributeString);
CGFloat lastLineWidth = (CGFloat)CTLineGetTypographicBounds(truncationToken, nil, nil,nil);
CFRelease(truncationToken);
if (lastLineWidth > width) {
NSString *lastLineString = attributeString.string;
NSRange r = [lastLineString rangeOfComposedCharacterSequencesForRange:NSMakeRange(lastLineString.length - truncationText.string.length - 1, 1)];
[attributeString deleteCharactersInRange:r];
return [self handleLastLineAttributeString:attributeString withTruncationText:truncationText width:width];
} else {
return attributeString;
}
}
複製代碼
之因此遞歸刪除是由於試過一會兒截取 truncationText 的長度時會有用 CTLineGetTypographicBounds
計算寬度不許確的問題,不清楚這是否與不一樣字符的高矮胖瘦有關,若是你有更好的方法,歡迎 pr !!!
我最初是想用「…查看更多」截斷文本,再剔除「…」後,僅把「查看更多」看成可支持高亮點擊的文本,然而在實現過程當中大大破壞了下邊兩個方法的通用性,甚至實現的效果還差強人意。
- (void)zp_highlightColor:(UIColor *)highlightColor backgroundColor:(UIColor *)backgroundColor highlightRange:(NSRange)highlightRange tapAction:(ZPTapHightlightBlock)tapAction;
- (NSMutableAttributedString *)zp_joinWithTruncationText:(NSMutableAttributedString *)truncationText textRect:(CGRect)textRect maximumNumberOfRows:(NSInteger)maximumNumberOfRows;
複製代碼
一般實現某個功能感到彆扭時,每每都是方法沒用對。最終經過查詢文檔及資料發現 Core Text 竟還有個 CTRunDelegate 的對象,CTRunDelegate 是 CTRun 的 delegate,它可被用來修改佈局時的字形信息(glyph metrics), 好比控制字符的 ascent、descent、width 等。換句話說,咱們能夠「撐開」一個字符到咱們想要的高寬,在這個佔位字符之上就能夠添加自定義的視圖(好比 UIButton)。unicode 中剛好有空白字符 \uFFFC
的表示,咱們在字符串適當的位置插入空白字符來佔位,再獲取到空白字符的 CGRect 信息,就能夠添加子視圖在這之上了。
static void zp_deallocCallback(void *ref) {
ZPTextRunDelegate *delegate = (__bridge_transfer ZPTextRunDelegate *)(ref);
delegate = nil;
}
static CGFloat zp_ascentCallback(void *ref) {
ZPTextRunDelegate *delegate = (__bridge ZPTextRunDelegate *)(ref);
return delegate.ascent;
}
static CGFloat zp_descentCallback(void *ref) {
ZPTextRunDelegate *delegate = (__bridge ZPTextRunDelegate *)(ref);
return delegate.descent;
}
static CGFloat zp_widthCallback(void *ref) {
ZPTextRunDelegate *delegate = (__bridge ZPTextRunDelegate *)(ref);
return delegate.width;
}
...
CTRunDelegateCallbacks callbacks;
callbacks.version = kCTRunDelegateCurrentVersion;
callbacks.dealloc = zp_deallocCallback;
callbacks.getAscent = zp_ascentCallback;
callbacks.getDescent = zp_descentCallback;
callbacks.getWidth = zp_widthCallback;
複製代碼
最後要注意的是 CTRunDelegate 須要實現代理的委託,在委託方法中,對象並不遵循 ARC 內存管理,這裏封裝了 ZPTextRunDelegate 來管理屬性,使用 __bridge_transfer
進行內存的轉換,避免了內存泄露和過早釋放的 bug。獲取附件的位置和高亮那塊的處理相似,就再也不贅述。
本文記錄瞭如何造一個 AttributedLabel 的輪子,相信讀者結合代碼一塊兒看會發現實現簡單的 Core Text 排版功能並不難,而筆者在剝離業務代碼、實現通用性、封裝工具類上仍是遇到很多技術挑戰。建議你們在日常開發中能多造點輪子鍛鍊鍛鍊技術,也能提升 iOS 技術社區的活力。同時但願你們在用慣了業界標準的 YYText 時,順帶了解下 Core Text 的使用流程。
Github 地址:github.com/hawk0620/PY…