首页 > 代码库 > 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阅读器实践系列(三)图文混排