本人今年主要在負責猿題庫iOS客戶端的開發,本文旨在經過分享猿題庫iOS客戶端開發過程當中的技術細節,達到總結和交流的目的。javascript
這是本技術分享系列文章的第三篇。本文涉及的技術細節是:基於CoreText的排版引擎。java
由於猿題庫的作題和解析界面須要複雜的排版,因此咱們基於CoreText實現了本身的富文本排版引擎。咱們的排版引擎對公式、圖片和連接有着良好支持,而且支持各類字體效果混排。對於內容中的圖片,支持點擊查看大圖功能,對於內容中的連接,支持點擊操做。ios
下圖是咱們應用的一個截圖,能夠看到公式,圖片與文字混排良好。git
對於富文本排版,除了能夠用CoreText實現外,還能夠用UIWebView實現。我之前寫過一篇介紹如何用UIWebView進行復雜內容顯示和交互的文章《關於UIWebView和PhoneGap的總結》,裏面介紹了使用UIWebView如何處理參數傳遞,同步與異步等問題,感興趣的同窗也能夠翻看。github
基於CoreText來實現和基於UIWebView來實現相比,前者有如下好處:web
固然基於CoreText的方案也有一些劣勢:服務器
咱們最初的猿題庫行測初版採用了基於UIWebView來實現,可是作出來發現一些小的交互細節沒法作到精緻。因此後來的第二版咱們就所有轉成用CoreText實現,雖然實現成本上增長了很多,可是應用的交互效果好多了。數據結構
使用CoreText也爲咱們後來的iPad版提供了技術積累,由於iPad版的頁面排版更加複雜,用UIWebView是徹底沒法完成相應的交互和排版需求的。app
關於如何基於CoreText來作一個排版引擎,我主要參考的是這篇教程:《Core Text Tutorial for iOS: Making a Magazine App》 以及Nimbus 中的NIAttributeLabel.m 的實現,在這裏我就不重複教程中的內容了,我主要講一些實現細節。異步
咱們在後臺實現了一個基於UBB 的富文本編譯器。使用UBB的緣由是:
爲了簡化iOS端的實現,咱們將UBB的語法解析在服務器端完成。服務器端提供了接口,能夠直接得到將UBB解析成相似HTML的文件對象模型(DOM) 的樹型數據結構。有了這個樹型數據結構,iOS端渲染就簡單多了,無非就是遞歸遍歷樹型節點,將相關的內容轉換成 NSAttributeString便可,以後將NSAttrubiteString轉成CoreText的CTFrame便可用於界面的繪製。
支持圖文混排在教程:《Core Text Tutorial for iOS: Making a Magazine App》 中有介紹,咱們在解析DOM樹遇到圖片節點時,則將該內容轉成一個空格,隨後設置該空格在繪製時,須要咱們本身指定寬高相關信息,而寬高信息在圖片節點中都有提供。這樣,CoreText引擎在繪製時,就會把相關的圖片位置留空,以後咱們將圖片異步下來下來後,使用CoreGraph相關的API將圖片再畫在界面上,就實現了圖文混排功能。
下面的相關的示例代碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
/* Callbacks */ static void deallocCallback( void* ref ){ [(id)ref release]; } static CGFloat ascentCallback( void *ref ){ CGFloat height = [(NSString*)[(NSDictionary*)ref objectForKey:@"height"] floatValue]; return height/2 + [FrameParserConfig sharedInstance].baselineFromMid; } static CGFloat descentCallback( void *ref ){ CGFloat height = [(NSString*)[(NSDictionary*)ref objectForKey:@"height"] floatValue]; return height/2 - [FrameParserConfig sharedInstance].baselineFromMid; } static CGFloat widthCallback( void* ref ){ return [(NSString*)[(NSDictionary*)ref objectForKey:@"width"] floatValue]; } + (void)appendDelegateData:(NSDictionary *)delegateData ToString:(NSMutableAttributedString*)contentString { //render empty space for drawing the image in the text //1 CTRunDelegateCallbacks callbacks; callbacks.version = kCTRunDelegateCurrentVersion; callbacks.getAscent = ascentCallback; callbacks.getDescent = descentCallback; callbacks.getWidth = widthCallback; callbacks.dealloc = deallocCallback; CTRunDelegateRef delegate = CTRunDelegateCreate(&callbacks, delegateData); [delegateData retain]; // Character to use as recommended by kCTRunDelegateAttributeName documentation. // use " " will lead to wrong width in CTFramesetterSuggestFrameSizeWithConstraints unichar objectReplacementChar = 0xFFFC; NSString * objectReplacementString = [NSString stringWithCharacters:&objectReplacementChar length:1]; NSDictionary * attributes = [self getAttributesWithStyleArray:nil]; //try to apply linespacing attributes to this placeholder NSMutableAttributedString * space = [[NSMutableAttributedString alloc] initWithString:objectReplacementString attributes:attributes]; CFAttributedStringSetAttribute((CFMutableAttributedStringRef)space, CFRangeMake(0, 1), kCTRunDelegateAttributeName, delegate); CFRelease(delegate); [contentString appendAttributedString:space]; [space release]; } |
這裏須要注意的是,用來代替圖片的佔位符使用空格會帶來排版上的異常,具體緣由未知,咱們猜想是CoreText的bug,參考Nimbus 的實現後,咱們使用 0xFFFC
做爲佔位符,就沒有遇到問題了。
支持連接點擊的主要實現的方式是:
CTLineGetStringIndexForPosition
函數來得到用戶點擊的位置對應 NSAttributedString
字符串上的位置信息(index) 3.判斷第2步獲得的index是否在第一步記錄的各個連接的區間範圍內,若是在範圍內,則表示用戶點擊了某一個連接。這段邏輯的關鍵代碼以下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
// test touch point is on link or not + (LinkData *)touchLinkInView:(UIView *)view atPoint:(CGPoint)point data:(CTTableViewCellData *)data { CTFrameRef textFrame = data.ctFrame; CFArrayRef lines = CTFrameGetLines(textFrame); if (!lines) return nil; CFIndex count = CFArrayGetCount(lines); LinkData *foundLink = nil; CGPoint origins[count]; CTFrameGetLineOrigins(textFrame, CFRangeMake(0,0), origins); // CoreText context coordinates are the opposite to UIKit so we flip the bounds CGAffineTransform transform = CGAffineTransformScale(CGAffineTransformMakeTranslation(0, view.bounds.size.height), 1.f, -1.f); for (int i = 0; i < count; i++) { CGPoint linePoint = origins[i]; CTLineRef line = CFArrayGetValueAtIndex(lines, i); CGRect flippedRect = [self getLineBounds:line point:linePoint]; CGRect rect = CGRectApplyAffineTransform(flippedRect, transform); if (CGRectContainsPoint(rect, point)) { CGPoint relativePoint = CGPointMake(point.x-CGRectGetMinX(rect), point.y-CGRectGetMinY(rect)); CFIndex idx = CTLineGetStringIndexForPosition(line, relativePoint); foundLink = [self linkAtIndex:idx linkArray:data.linkArray]; return foundLink; } } return nil; } |
咱們在使用CoreText時,還遇到一個具體排版上的問題。正常狀況下,在生成CTFrame以後,只須要調用:CTFrameDraw(self.data.ctFrame, context);
便可完成界面的繪製。可是產品提出了一個需求,對於某些界面,當顯示不下的時候,須要將多餘內容用...
來表示。這讓咱們的繪製邏輯須要特別處理,如下是具體的實現:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
static NSString* const kEllipsesCharacter = @"\u2026"; CGPathRef path = CTFrameGetPath(_data.ctFrame); CGRect rect = CGPathGetBoundingBox(path); CFArrayRef lines = CTFrameGetLines(_data.ctFrame); CFIndex lineCount = CFArrayGetCount(lines); NSInteger numberOfLines = MIN(_numberOfLines, lineCount); CGPoint lineOrigins[numberOfLines]; CTFrameGetLineOrigins(_data.ctFrame, CFRangeMake(0, numberOfLines), lineOrigins); NSAttributedString *attributedString = _data.attributedString; for (CFIndex lineIndex = 0; lineIndex < numberOfLines; lineIndex++) { CGPoint lineOrigin = lineOrigins[lineIndex]; lineOrigin.y = self.frame.size.height + (lineOrigin.y - rect.size.height); CGContextSetTextPosition(context, lineOrigin.x, lineOrigin.y); CTLineRef line = CFArrayGetValueAtIndex(lines, lineIndex); BOOL shouldDrawLine = YES; if (lineIndex == numberOfLines - 1) { CFRange lastLineRange = CTLineGetStringRange(line); if (lastLineRange.location + lastLineRange.length < (CFIndex)attributedString.length) { CTLineTruncationType truncationType = kCTLineTruncationEnd; NSUInteger truncationAttributePosition = lastLineRange.location + lastLineRange.length - 1; NSDictionary *tokenAttributes = [attributedString attributesAtIndex:truncationAttributePosition effectiveRange:NULL]; NSAttributedString *tokenString = [[NSAttributedString alloc] initWithString:kEllipsesCharacter attributes:tokenAttributes]; CTLineRef truncationToken = CTLineCreateWithAttributedString((__bridge CFAttributedStringRef)tokenString); NSMutableAttributedString *truncationString = [[attributedString attributedSubstringFromRange:NSMakeRange(lastLineRange.location, lastLineRange.length)] mutableCopy]; if (lastLineRange.length > 0) { // Remove any whitespace at the end of the line. unichar lastCharacter = [[truncationString string] characterAtIndex:lastLineRange.length - 1]; if ([[NSCharacterSet whitespaceAndNewlineCharacterSet] characterIsMember:lastCharacter]) { [truncationString deleteCharactersInRange:NSMakeRange(lastLineRange.length - 1, 1)]; } } [truncationString appendAttributedString:tokenString]; CTLineRef truncationLine = CTLineCreateWithAttributedString((__bridge CFAttributedStringRef)truncationString); CTLineRef truncatedLine = CTLineCreateTruncatedLine(truncationLine, self.size.width, truncationType, truncationToken); if (!truncatedLine) { // If the line is not as wide as the truncationToken, truncatedLine is NULL truncatedLine = CFRetain(truncationToken); } CFRelease(truncationLine); CFRelease(truncationToken); CTLineDraw(truncatedLine, context); CFRelease(truncatedLine); shouldDrawLine = NO; } } if (shouldDrawLine) { CTLineDraw(line, context); } } |
以上源碼不少都參考了Nimbus的實現,在此再一次表達一下對開源社區的感謝。
在大約2年前,CoreText仍是一個新玩意。那時候微博的界面都仍是用控件組合獲得的。慢慢的,你們都開始接受CoreText,不少應用都普遍地將CoreText應用於本身的界面中,作出來了更加複雜的排版、交互效果。在iOS7以後,蘋果推出了更加易於使用的TextKit,使得富文本排版更加容易,相信之後的iOS應用界面會更加美觀,交互更加絢麗。
轉自:http://blog.devtang.com/blog/2013/10/21/the-tech-detail-of-ape-client-3/