標籤:
iOS沒有現成的支援圖文混排的控制項,而要用多個基礎控制群組合拼成圖文混排這樣複雜的排版,是件很苦逼的事情。對此的解決方案有使用CoreText進行繪製,或者使用TextKit。本文主要講解對於CoreText的使用。
案例
https://github.com/ClavisJ/CoreTextDemo
環境資訊:
Mac OS X 10.10.1
Xcode 6.1.1
iOS 8.1
本文:一、Core Text簡介
CoreText是基於IOS3.2及OSX10.5的用於文字精細排版的文字框架。它直接與Core Graphics(又稱:Quartz)互動,將需要顯示的常值內容,位置,字型,字形直接傳遞給Quartz,與其他UI組件相比,能更高效的進行渲染。
Core Text 架構圖
二、CoreText與UIWebView在排版方面的優劣比較
UIWebView也常用於處理複雜的排版,對應排版他們之間的優劣如下(摘自 《iOS開發進階》—— 唐巧):
- CoreText佔用的內容更少,渲染速度更快。UIWebView佔用的記憶體多,渲染速度慢。
- CoreText在渲染介面的前就可以精確地獲得顯示內容的高度(只要有了CTFrame即可),而WebView只有渲染出內容後,才能獲得內容的高度(而且還需要用JavaScript代碼來擷取)。
- CoreText的CTFrame可以在後台線程渲染,UIWebView的內容只能在主線程(UI線程)渲染。
- 基於CoreText可以做更好的原生互動效果,互動效果可以更加細膩。而UIWebView的互動效果都是用JavaScript來實現的,在 互動效果上會有一些卡頓的情況存在。例如,在UIWebView下,一個簡單的按鈕按下的操作,都無法做出原生按鈕的即時和細膩的按下效果。
CoreText排版的劣勢:
- CoreText渲染出來的內容不能像UIWebView那樣方便地支援內容的複製。
- 基於CoreText來排版需要自己處理很多複製的邏輯,例如需要自己處理圖片與文字混排相關的邏輯,也需要自己實現連接點擊操作的支援。
在業界有很多應用都採用CoreText技術進行排版,例如新浪微部落格戶端,多看閱讀用戶端,猿題庫等等。
三、繪製純文字
我們建立一個繼承於UIView的類,重寫他的drawRect方法,來繪製純文字。
- (void)drawRect:(CGRect)rect { [super drawRect:rect]; // 步驟1:得到當前用於繪製畫布的上下文,用於後續將內容繪製在畫布上 // 因為Core Text要配合Core Graphic 配合使用的,如Core Graphic一樣,繪圖的時候需要獲得當前的上下文進行繪製 CGContextRef context = UIGraphicsGetCurrentContext(); // 步驟2:翻轉當前的座標系(因為對於底層繪製引擎來說,螢幕左下角為(0,0)) CGContextSetTextMatrix(context, CGAffineTransformIdentity); CGContextTranslateCTM(context, 0, self.bounds.size.height); CGContextScaleCTM(context, 1.0, -1.0); // 步驟3:建立繪製地區 CGMutablePathRef path = CGPathCreateMutable(); CGPathAddEllipseInRect(path, NULL, self.bounds); // 步驟4:建立需要繪製的文字與計算需要繪製的地區 NSMutableAttributedString *attrString = [[NSMutableAttributedString alloc] initWithString:@"iOS程式在啟動時會建立一個主線程,而在一個線程只能執行一件事情,如果在主線程執行某些耗時操作,例如載入網狀圖片,下載資源檔等會阻塞主線程(導致介面卡死,無法互動),所以就需要使用多線程技術來避免這類情況。iOS中有三種多線程技術 NSThread,NSOperation,GCD,這三種技術是隨著IOS發展引入的,抽象層次由低到高,使用也越來越簡單。"]; // 步驟5:根據AttributedString產生CTFramesetterRef CTFramesetterRef frameSetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attrString); CTFrameRef frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, [attrString length]), path, NULL); // 步驟6:進行繪製 CTFrameDraw(frame, context); // 步驟7.記憶體管理 CFRelease(frame); CFRelease(path); CFRelease(frameSetter);}
啟動並執行效果如
CoreText繪製純文字
四、關於座標系
上訴代碼的步驟2對繪圖的座標系進行了處理,因為在iOS UIKit中,UIView是以左上方為原點,而Core Text一開始的定位是使用與案頭應用的排版系統,案頭應用的座標系是以左下角為原點,即Core Text在繪製的時候也是參照左下角為原點進行繪製的,所以需要對當前的座標系進行處理。
實際上,Core Graphic 中的context也是以左下角為原點的, 但是為什麼我們用Core Graphic 繪製一些簡單的圖形的時候不需要對座標系進行處理喃,是因為通過這個方法UIGraphicsGetCurrentContext()來獲得的當前context是已經被處理過的了,用下面方法可以查看指定的內容相關的當前圖形狀態變換矩陣。
NSLog(@"當前context的變換矩陣 %@", NSStringFromCGAffineTransform(CGContextGetCTM(context)));
列印結果為[2, 0, 0, -2, 0, 654],可以發現變換矩陣與CGAffineTransformIdentity的值[1, 0, 0, 1, 0, 0]是 不相同的,並且與裝置是否為Retina屏和裝置尺寸相關。他的作用是將上下文空間座標系進行翻轉,並使原來的左下角原點變成右上方是原點,並將向上為正 y軸變為向下為正y軸。 所以在使用drawRect的時候,當前的context已經被做了一次翻轉,如果不對當前的座標系進行處理,會發現,繪製出來的文字是鏡像上下顛倒的,
不處理context
所以需要先重設當前的座標系翻轉狀態,在進行一次翻轉,處理之後的矩陣為[2, 0, -0, 2, 0, 0],函數CGContextTranslateCTM的作用變換座標系中的原點,函數CGContextScaleCTM的作用是改變使用者座標系統的規模比例。
五、自訂文本的顏色,字型與行間距
可以看到我們使用了NSMutableAttributedString這個類來描述需要繪製的文字,而一個NSMutableAttributedString對象可以包含很多屬性,每一個屬性都有起對應的字元地區,我們可以用這些屬性來描述文本中特殊的顏色和字型。
- (void)drawRect:(CGRect)rect { // 省略前面的步驟1-4 // 步驟8:設定部分文字顏色 [attrString addAttribute:(id)kCTForegroundColorAttributeName value:[UIColor greenColor] range:NSMakeRange(10, 10)]; // 設定部分文字 CGFloat fontSize = 20; CTFontRef fontRef = CTFontCreateWithName((CFStringRef)@"ArialMT", fontSize, NULL); [attrString addAttribute:(id)kCTFontAttributeName value:(__bridge id)fontRef range:NSMakeRange(15, 10)]; CFRelease(fontRef); // 設定行間距 CGFloat lineSpacing = 10; const CFIndex kNumberOfSettings = 3; CTParagraphStyleSetting theSettings[kNumberOfSettings] = { {kCTParagraphStyleSpecifierLineSpacingAdjustment, sizeof(CGFloat), &lineSpacing}, {kCTParagraphStyleSpecifierMaximumLineSpacing, sizeof(CGFloat), &lineSpacing}, {kCTParagraphStyleSpecifierMinimumLineSpacing, sizeof(CGFloat), &lineSpacing} }; CTParagraphStyleRef theParagraphRef = CTParagraphStyleCreate(theSettings, kNumberOfSettings); [attrString addAttribute:(id)kCTParagraphStyleAttributeName value:(__bridge id)theParagraphRef range:NSMakeRange(0, attrString.length)]; CFRelease(theParagraphRef); // 省略之後的步驟5-7}
最終的效果如下
自訂文字屬性
提示:在配置NSMutableAttributedString?的Attribute的時候,用到了很多這樣的(__bridge?id)標識,來解釋下:這個因為addAttribute:是OC的方法,需要Object C 對象,而CTParagraphStyleRef這 些是由C語言實現的Core Foundation Framework 架構中的對象,這兩種類型可以相互轉換和操作。Core Foundation Framework 架構中的對象也有引用計數的概念,但是不是Cocoa Framework中的release/retain不同,而是使用自身的CFRetain/CFRelease介面,在使用的時候要多加註意引用和釋放 的問題, 更加詳細的解釋可以參照這篇文章。
六、圖文混排
終於要開始進行圖文混排了,上面說了那麼多,我們來進行一個小結,是CoreText繪製的流程圖與CTFrame和CTLine,CTRun之間的關係:
CoreText繪製的流程圖,CTFrame和CTLine CTRun之間的關係
我們來解釋一下這些類:
CFAttributedStringRef :屬性字串,用於儲存需要繪製的文字字元和字元屬性
CTFramesetterRef:通過CFAttributedStringRef進行初始化,作為CTFrame對象的生產工廠,負責根據path建立對應的CTFrame
CTFrame:用於繪製文字的類,可以通過CTFrameDraw函數,直接將文字繪製到context上
CTLine:在CTFrame內部是由多個CTLine來組成的,每個CTLine代表一行
CTRun:每個CTLine又是由多個CTRun組成的,每個CTRun代表一組顯示風格一致的文本
實際上CoreText是不直接支援繪製圖片的,但是我們可以先在需要顯示圖片的地方用一個特殊的空白預留位置代替,同時設定 該字型的CTRunDelegate資訊為要顯示的圖片的寬度和高度,這樣繪製文字的時候就會先把圖片的位置留出來,再在drawRect方法裡面用 CGContextDrawImage繪製圖片。
- (void)drawRect:(CGRect)rect { [super drawRect:rect]; // 省略步驟1-4 ,步驟8 // 步驟9:圖文混排部分 // CTRunDelegateCallbacks:一個用於儲存指標的結構體,由CTRun delegate進行回調 CTRunDelegateCallbacks callbacks; memset(&callbacks, 0, sizeof(CTRunDelegateCallbacks)); callbacks.version = kCTRunDelegateVersion1; callbacks.getAscent = ascentCallback; callbacks.getDescent = descentCallback; callbacks.getWidth = widthCallback; // 圖片資訊字典 NSDictionary *imgInfoDic = @{@"width":@100,@"height":@30}; // 設定CTRun的代理 CTRunDelegateRef delegate = CTRunDelegateCreate(&callbacks, (__bridge void *)imgInfoDic); // 使用0xFFFC作為空白的預留位置 unichar objectReplacementChar = 0xFFFC; NSString *content = [NSString stringWithCharacters:&objectReplacementChar length:1]; NSMutableAttributedString *space = [[NSMutableAttributedString alloc] initWithString:content]; CFAttributedStringSetAttribute((CFMutableAttributedStringRef)space, CFRangeMake(0, 1), kCTRunDelegateAttributeName, delegate); CFRelease(delegate); // 將建立的空白AttributedString插入進當前的attrString中,位置可以隨便指定,不能越界 [attrString insertAttributedString:space atIndex:50]; // 省略步驟5-6 // 步驟10:繪製圖片 UIImage *image = [UIImage imageNamed:@"coretext-img-1.png"]; CGContextDrawImage(context, [self calculateImagePositionInCTFrame:frame], image.CGImage); // 省略步驟7 } #pragma mark - CTRun delegate 回調方法 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]; } /** * 根據CTFrameRef獲得繪製圖片的地區 * * @param ctFrame CTFrameRef對象 * * @return繪製圖片的地區 */ - (CGRect)calculateImagePositionInCTFrame:(CTFrameRef)ctFrame { // 獲得CTLine數組 NSArray *lines = (NSArray *)CTFrameGetLines(ctFrame); NSInteger lineCount = [lines count]; CGPoint lineOrigins[lineCount]; CTFrameGetLineOrigins(ctFrame, CFRangeMake(0, 0), lineOrigins); // 遍曆每個CTLine for (NSInteger i = 0 ; i < lineCount; i++) { CTLineRef line = (__bridge CTLineRef)lines[i]; NSArray *runObjArray = (NSArray *)CTLineGetGlyphRuns(line); // 遍曆每個CTLine中的CTRun 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(ctFrame); CGRect colRect = CGPathGetBoundingBox(pathRef); CGRect delegateBounds = CGRectOffset(runBounds, colRect.origin.x, colRect.origin.y); return delegateBounds; } } return CGRectZero; }
?至此我們就完成了使用CoreText進行圖文混排,上面獲得圖片位置的方法只能獲得第一張圖片位置,大家可以自行完善一下,用數組來進行儲存圖片繪製地區。唐巧在《iOS開發進階》一書中更多的介紹了對CoreText的封裝,感興趣的可以看看。
參考資料:
http://geeklu.com/2013/03/core-text/
http://xiangwangfeng.com/2014/03/06/iOS%E6%96%87%E5%AD%97%E6%8E%92%E7%89%88(CoreText)%E9%82%A3%E4%BA%9B%E4%BA%8B/
http://blog.devtang.com/blog/2013/10/21/the-tech-detail-of-ape-client-3/
http://www.yifeiyang.net/development-of-the-iphone-simply-6/
【iOS】使用CoreText實現圖文混排