首页 > 代码库 > iOS阅读器实践系列(三)图文混排
iOS阅读器实践系列(三)图文混排
本篇介绍coretext中的图文混排,这里暂用静态的内容,即在文本中某一固定位置插入图片,而不是插入位置是根据文本内容动态插入的(要实现这一效果需要写一个文本解析器,将原信息内容解析为某些特定格式的结构来标示出特定的类型(比如文字、图片、链接等),然后按照其结构中的属性配置,生成属性字符串,之后渲染到视图中)。
这部分的思路参考唐巧大神的blog。
在第一篇介绍过coretext是离屏渲染的,即在将内容渲染到屏幕上之前,coretext已完成排版工作。coretext排版的第一步是组织数据,即由原始字符串通过特定的配置来得到属性字符串。实现在文字中插入图片的大部分工作都是在这一步中完成的。
大体思路是:首先将原始纯文本数据通过预定义的配置生成相应的属性字符串A,然后生成一个字符作为占位符(绘制时在这个占位符中填充图片),根据特定的配置生成属性字符串B,然后将B插入到A相应位置,最后将合并后的A和图片渲染到视图中。
具体步骤如下:
一、创建存储A的数据结构CoreTextData与存储B的数据结构CTImgData
二、生成属性字符串A
三、生成属性字符串B
四、检测图片位置,以便后续对图片操作进行处理
1、创建存储A的数据结构CoreTextData与存储B的数据结构CTImgData
在实际开发中我们需要一个结构来存储排版和业务的一些数据,首先介绍CoreTextData:
@interface CoreTextData : NSObject @property (assign, nonatomic) CTFrameRef ctFrame; @property (assign, nonatomic) CGFloat height; @property (strong, nonatomic) NSMutableAttributedString *content; @property (nonatomic, strong) NSMutableArray *imgDataArray; @property (nonatomic, assign) NSInteger characterNum; @end
代码中列出了,排版和业务上可能用的一些数据(当然你完全可以根据自己的需求来定义结构,这里只是举了一些我觉得可能用到的字段),其中CTFrame用于文本渲染,height记录的属性字符串排版时所需的高度,content保存了文本内容,imgDataArray是保存CTImgData的数组,characterNum记录的content的字数。
CoreTextImgData结构:
@interface CoreTextImgData : NSObject @property (strong, nonatomic) NSString *name; @property (nonatomic) NSUInteger position; @property (nonatomic, assign) CGFloat leftMargin; @property (nonatomic, assign) CGFloat topMargin; //坐标系为coreText坐标系,而不是UIKit坐标系 @property (nonatomic) CGRect imgPosition; @property (nonatomic, assign) BOOL isResponseTap; - (void)handleImgTapped:(NSInteger)chapterId chapterTitle:(NSString *)title; @end
name表示所用图片的名字,position表示图片在文本中的字符索引,leftMargin和topMargin用于排版时图片位置的调整(距左边与上边的间隔),图片在视图中坐标(coretext坐标系),isResponseTap表示图片是否响应点击,下面的方法是处理图片的事件的,后面介绍。
生成文本与图片的属性字符串
contentString = [[NSMutableAttributedString alloc] initWithString:content attributes:attributes]; //bottom line CGFloat bottomLineW = viewWidth - 20; NSDictionary *bottomImgDict = [NSDictionary dictionaryWithObjectsAndKeys:[NSNumber numberWithFloat:bottomLineW], @"width", [NSNumber numberWithFloat:1], @"height", @"Line", @"imgName", nil]; CoreTextImgData *bottomImgData =http://www.mamicode.com/ [[CoreTextImgData alloc] init]; bottomImgData.position = contentString.length; bottomImgData.name = bottomImgDict[@"imgName"]; [imgDataArray addObject:bottomImgData]; NSDictionary *bottomImgAttributes = [manager getAditionLineAttribute]; NSAttributedString *bottomLineContent = [self getImageAttributeContentWithDictionary:bottomImgDict attribute:bottomImgAttributes isLineFeed:YES imgName:bottomImgData.name imgData:bottomImgData leftMargin:10 topMargin:0]; [contentString appendAttributedString:bottomLineContent];
+ (NSAttributedString *)getImageAttributeContentWithDictionary:(NSDictionary *)dict attribute:(NSDictionary *)attribute isLineFeed:(BOOL)isLineFeed imgName:(NSString *)imgName imgData:(CoreTextImgData *)imgData leftMargin:(CGFloat)leftMargin topMargin:(CGFloat)topMargin { CTRunDelegateCallbacks callbacks; memset(&callbacks, 0, sizeof(CTRunDelegateCallbacks)); callbacks.version = kCTRunDelegateVersion1; callbacks.getAscent = ascentCallback; callbacks.getDescent = descentCallback; callbacks.getWidth = widthCallback; CTRunDelegateRef delegate = CTRunDelegateCreate(&callbacks, (__bridge void * _Nullable)(dict)); unichar objectReplacementChar = 0xFFFC; NSString *imgContent = [NSString stringWithCharacters:&objectReplacementChar length:1]; NSMutableAttributedString *space = nil; if (isLineFeed) { imgContent = [NSString stringWithFormat:@"\n%@", imgContent]; space = [[NSMutableAttributedString alloc] initWithString:imgContent attributes:attribute]; CFAttributedStringSetAttribute((CFMutableAttributedStringRef)space, CFRangeMake(1, space.length - 1), kCTRunDelegateAttributeName, delegate); [space addAttribute:@"imgName" value:imgName range:NSMakeRange(1, space.length - 1)]; imgData.position += 1; } else { space = [[NSMutableAttributedString alloc] initWithString:imgContent attributes:attribute]; CFAttributedStringSetAttribute((CFMutableAttributedStringRef)space, CFRangeMake(0, space.length), kCTRunDelegateAttributeName, delegate); [space addAttribute:@"imgName" value:imgName range:NSMakeRange(0, space.length)]; } imgData.leftMargin = leftMargin; imgData.topMargin = topMargin; CFRelease(delegate); return space; }
static CGFloat ascentCallback(void *ref) { return [[(__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]; }
这里是在contentString后插入一条直线。bottomImgDict定义了图片的一些属性然后将某些属性存入CoreTextImgData中,然后将这个CoretextImgData存入imgDataArray中用于往视图中依次渲染。
下面方法用于生成图片的属性字符串,首先是根据传入的属性字典得到图片的宽高信息,然后定义占位字符0xFFFC,isLineFeed表示是否需要换行,利用占位字符生成属性字符串,配置相应属性,在换行条件中因为在拼接字符串时在占位符前加了一个换行符,故占位符的索引需要加一,对应 imgData.position += 1,返回占位符生成的属性字符串,最后将其拼接到原有属性字符串的后面。
检测图片位置,以便后续对图片操作进行处理:
+ (void)fillImagePositionWithCTFrame:(CTFrameRef)ctFrame coreTextData:(CoreTextData *)coreTextData imageDataArray:(NSMutableArray *)arrImgData { if (arrImgData =http://www.mamicode.com/= nil || arrImgData.count == 0) { return; } int imgIndex = 0; CoreTextImgData *imageData = http://www.mamicode.com/arrImgData[0]; CFRange frameRange = CTFrameGetVisibleStringRange(ctFrame);
//在多页显示的情况下,while循环确保当前的imgData是包含在当前CTFrame中的,比如第一页有两个图片,第二页有一个图片,如果当前的CTFrame是第二页的,那么while循环完成时imgIndex = 2 while ( imageData.position < frameRange.location ) { imgIndex++; if (imgIndex>=[arrImgData count]) return; //quit if no images for this column imageData =http://www.mamicode.com/ [arrImgData objectAtIndex:imgIndex]; } NSArray *lines = (NSArray *)CTFrameGetLines(ctFrame); NSUInteger lineCount = lines.count; CGPoint lineOrigins[lineCount]; CTFrameGetLineOrigins(ctFrame, CFRangeMake(0, 0), lineOrigins); for (int i = 0; i < lineCount; ++i) { if (imageData =http://www.mamicode.com/= nil) { break; } CTLineRef line = (__bridge CTLineRef)(lines[i]); NSArray *runObjArray = (NSArray *)CTLineGetGlyphRuns(line); for (id runObj in runObjArray) { CTRunRef run = (__bridge CTRunRef)runObj; CFRange runRange = CTRunGetStringRange(run); // NSDictionary *runAttributesDict = (NSDictionary *)CTRunGetAttributes(run); // NSString *imgName = [runAttributesDict objectForKey:@"imgName"];
//确保图片的字符索引在当前runRange范围内 if (runRange.location <= imageData.position && runRange.location + runRange.length > imageData.position) { NSDictionary *runAttributes = (NSDictionary *)CTRunGetAttributes(run); CTRunDelegateRef delegate = (__bridge CTRunDelegateRef)[runAttributes valueForKey:(id)kCTRunDelegateAttributeName]; if (delegate == nil) { 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 + imageData.leftMargin; runBounds.origin.y = lineOrigins[i].y; runBounds.origin.y -= descent + imageData.topMargin; CGPathRef pathRef = CTFrameGetPath(ctFrame); CGRect colRect = CGPathGetBoundingBox(pathRef); CGRect delegateBounds = CGRectOffset(runBounds, colRect.origin.x, colRect.origin.y); //得到图片在其所在CTFrame包含区域中的位置区域 imageData.imgPosition = delegateBounds; [coreTextData.imgDataArray addObject:imageData]; imgIndex++; if (imgIndex == arrImgData.count) { imageData = nil; break; } else { imageData = arrImgData[imgIndex]; } } } } }
上面方法主要目的是获取图片的位置区域,用于图片点击。即代码中的注释部分,前面的的代码都是为获取这个区域所做的准备。
我是在排版内容时调用上述方法,内容生成多少个CTFrame该方法就会调用多少次:
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)mabStr); NSMutableArray *coreTextDatas = [[NSMutableArray alloc] init]; int textPos = 0; while (textPos < mabStr.length) { //得到每页的尺寸 CGFloat aOriginY; CGFloat frameHeight; if (textPos == 0) { aOriginY = firstOriginY; } else { aOriginY = originY; } frameHeight = [self getFrameHeight:framesetter viewWidth:viewWidth viewHeight:viewHeight flipDirection:manager.flipOverDirection] - aOriginY - bottomMargin; // 生成 CTFrameRef 实例 CGFloat finalOriginY = bottomMargin; //将UIKit坐标系下y轴的偏移aOriginY转化为coretext坐标系下的偏移 CTFrameRef frame = [self createFrameWithFramesetter:framesetter frameWidth:viewWidth stringRange:CFRangeMake(textPos, 0) orginY:finalOriginY height:frameHeight]; CFRange frameRange = CTFrameGetVisibleStringRange(frame); // 将生成好的 CTFrameRef 实例和计算好的绘制高度保存到 CoreTextData 实例中,最后返回 CoreTextData 实例 CoreTextData *data =http://www.mamicode.com/ [[CoreTextData alloc] init]; data.ctFrame = frame; data.height = frameHeight;
[self fillImagePositionWithCTFrame:data.ctFrame coreTextData:data imageDataArray:imgDataArray]; NSAttributedString *aStr = [mabStr attributedSubstringFromRange:NSMakeRange(textPos, frameRange.length)]; data.content = aStr; [coreTextDatas addObject:data]; textPos += frameRange.length; // 释放内存 CFRelease(frame); } // 释放内存 CFRelease(framesetter); return coreTextDatas;
当在视图中点击图片时:可在视图类中定义如下方法:
- (void)setupEvents { UIGestureRecognizer * tapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(userTapGestureDetected:)]; tapRecognizer.delegate = self; [self addGestureRecognizer:tapRecognizer]; self.userInteractionEnabled = YES; }
//处理点击事件 - (void)userTapGestureDetected:(UIGestureRecognizer *)recognizer { CGPoint point = [recognizer locationInView:self]; CoreTextImgData *imgData =http://www.mamicode.com/ [self getTheResponsedImgData:point]; if (imgData != nil) { [imgData handleImgTapped:_chapterId chapterTitle:_chapterTitle]; } } -(BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer { CGPoint point = [gestureRecognizer locationInView:self]; BOOL isDispatch = [self isPointInResponsedImgRect:point]; if (isDispatch) { return YES; } return NO; } //翻转坐标系 - (CGRect)transformCTM:(CGRect)rect { CGPoint originPoint = rect.origin; originPoint.y = self.bounds.size.height - rect.origin.y - rect.size.height; CGRect aRect = CGRectMake(originPoint.x, originPoint.y, rect.size.width, rect.size.height); return aRect; } - (BOOL)isPointInResponsedImgRect:(CGPoint)point { for (CoreTextImgData *imgData in self.data.imgDataArray) { CGRect rect = [self transformCTM:imgData.imgPosition]; if (CGRectContainsPoint(rect, point)) { if (imgData.isResponseTap) { return NO; } } } return YES; } - (CoreTextImgData *)getTheResponsedImgData:(CGPoint)point { for (CoreTextImgData *imgData in self.data.imgDataArray) { CGRect rect = [self transformCTM:imgData.imgPosition]; if (CGRectContainsPoint(rect, point)) { if (imgData.isResponseTap) { return imgData; } } } return nil; }
然后在ImgData中处理具体的图片点击事件:
- (void)handleImgTapped:(NSInteger)chapterId chapterTitle:(NSString *)title { if ([self.name isEqualToString:@"xxx"]) { NSDictionary *userInfo = [NSDictionary dictionaryWithObjectsAndKeys:[NSNumber numberWithInteger:chapterId], @"chapterId", title, @"chapterTitle", nil]; NSNotification *notification =[NSNotification notificationWithName:@"chpaterReply" object:nil userInfo:userInfo]; [[NSNotificationCenter defaultCenter] postNotification:notification]; } }
这里通过imgData中name属性区分不同图片,进行不同处理。
PS:写的有点仓促,有些系统函数的作用没有介绍,如有不太清楚或错误的地方,欢迎交流。
iOS阅读器实践系列(三)图文混排