[貝聊科技]如何實現一個 AttributedLabel

貝聊科技發表於2017-12-04

作者:陳浩  貝聊科技移動開發部  iOS 工程師

Core Text 是蘋果提供的富文字排版技術,可以定製開發圖文混排功能,DTCoreText、Nimbus、YYLabel 等優秀的開源庫底層都是基於 Core Text 的封裝和擴充套件。本文將介紹 Core Text 的基本用法,逐步講解我是如何封裝一個 AttributedLabel 的。

本文已發表在個人部落格

文字排版簡述

文字排版是根據給定的文字(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);
複製程式碼

這裡的主要步驟有:

  1. 建立 Attributed String;
  2. 建立 CTFramesetter,這是 Core Text 排版的核心類,它會貫穿整個排版過程;
  3. 建立 CGPath,即繪製的區域;
  4. 通過 CTFramesetter 和 CGPath 建立 CTFrame,然後可將其繪製在當前的 context 上;
  5. 別忘了呼叫 CFRelease 釋放物件。

在繼續深入程式碼之前,先了解以下幾個小概念:

字形與字型

簡單說字型就是對映到字元的字形集合,以下就是字元 a (ascii 碼為 97)的不同字形:

[貝聊科技]如何實現一個 AttributedLabel

而同一字型下字形也可能會有所不同,在英文中比較常見的如連字,典型的就是fi中 i 的點常與 f 的鉤合併:

[貝聊科技]如何實現一個 AttributedLabel

接下來說說字型,在開發中我們常說同一字型不同字號,比如 [UIFont systemFontOfSize: 16][UIFont systemFontOfSize: 18],或者同一字型但是加粗顯示,又如 [UIFont systemFontOfSize: 16][UIFont boldSystemFontOfSize: 16],又或者斜體,然而這對於系統而言是完全不同的字型。這兒想說明的是:不同字號是不同的字型,粗體相對普通也是不同的字型,而給文字新增下劃線卻是個例外(下劃線是系統額外畫的一條裝飾線)。

有時我們在開發中也會接觸到字型的 Ascent 和 Descent,其實就是在於字形度量(Glyph metrics)打交道:

[貝聊科技]如何實現一個 AttributedLabel

由上圖可知,一個字元最高點到基線的偏移叫做 Ascent,最低點到基線的偏移叫做 Descent,單行的行高 Line Height 由 Ascent、Descent 與 Line Gap 相加得出。

文字的繪製

[貝聊科技]如何實現一個 AttributedLabel

Core Text 需要使用 CTFramesetter 對文字進行佈局,位於上圖中最頂端的 CTFramesetter,它要求以 Attributed String 和繪製區域的形狀(CGPath)作為入參,來建立 CTFrame(可以不止一個 CTFrame) ,顧名思義,這就是文字佈局所在的 frame,確定好繪製區域後,framesetter 就能將段落樣式(NSParagraphStyle)的 lineBreakMode、lineSpacing 等屬性應用於此。 這裡有必要提一下 CTRun,從 CTRun 我們可以獲取許多重要的屬性,這在開發排版功能的時候非常有用,下面這張圖有助於我們瞭解什麼是 CTRun:

[貝聊科技]如何實現一個 AttributedLabel

這一行文字可以認為是一個 CTLine 物件,由從左往右的順序依次包含了預設字型樣式、加粗字型樣式、預設字型樣式、小字號藍色樣式、正常字號藍色樣式和預設字型樣式共 6 種 Attributed。每一種樣式的字元則表示一個 CTRun 物件。

瞭解了這些概念之後,就可以實現排版功能了。

實現一個簡單的 AttributedLabel

進入正題之前,再儲備些基礎知識。

Core Foundation 記憶體管理規則

Core Text 使用了 Core Foundation 基於 C 語言的 API,所以需要遵循 Core Foundation 的記憶體管理規則。

  • 建立方法名中含有 “Create” 或 “Copy”,需要呼叫 CFRelease 釋放記憶體
	CTFramesetterRef CTFramesetterCreateWithAttributedString(
CFAttributedStringRef string )
複製程式碼
  • 返回 CF 物件方法名中不含 “Create” 和 “Copy”,無需手動釋放記憶體
	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);
複製程式碼
藉助下面這張類關係圖讓我們直奔主題。

[貝聊科技]如何實現一個 AttributedLabel

1.堪當重任的 CALayer

相對於 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 進行設定。這是做這個專案時最乾淨利落的一個地方。

2.文字高亮互動的處理

如果無需處理高亮互動等定製(截斷、附件)效果,我們在拿到 NSAttributedString 和 CGPath 即可將文字繪製到 context 上。對於連結而言,雖然我們能通過 NSDataDetector 標記出文字中哪些地方需要高亮顯示,但是需求往往要能對連結進行點選跳轉,在使用 CTFrameDraw 方法繪製文字時,既不知道高亮過的文字位置,更無法談及對高亮文字的互動響應了。

幸運的是,Core Text 另外還有個稍微複雜點的繪製方法 CTLineDraw,從名字可以得知它是用來繪製 line 的,感觀上要比 CTFrameDraw 的確要精細許多。我們先看看新增高亮功能的實現思路。

  • 能響應點選的回撥 block
  • 接受高亮顏色、range、backgroundColor 等

假設上述高亮相關屬性都由 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 的陣列渲染即可。

3.字串截斷的處理

當 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 !!!

4.為字串新增附件

我最初是想用“…檢視更多”截斷文字,再剔除“…”後,僅把“檢視更多”當作可支援高亮點選的文字,然而在實現過程中大大破壞了下邊兩個方法的通用性,甚至實現的效果還差強人意。

    - (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…

相關文章