本人今年主要在负责猿题库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/