iOS - 按纯色行切页

2017-03-28

DTCoreText库是iOS端富文本渲染库,开发阅读类产品时可以使用该库进行富文本渲染,它提供了CSS样式解析和HTML富文本样式的排版,支持图片、视频这类附件内容的混合排版。

简单介绍

它的底层使用的Apple提供的CoreText库,命名几乎和CoreText库保持一致,上手简单快速,步骤十分简单容易:

  1. 加载CSS样式。

    1
    2
    3
    4
    NSString* cssPath = [[NSBundle mainBundle] pathForResource:@"style" ofType:@"css"];
    NSString* cssString = [NSString stringWithContentsOfFile:cssPath encoding:NSUTF8StringEncoding error:nil] ;
    DTCSSStylesheet* css = [[DTCSSStylesheet alloc] initWithStyleBlock:cssString];
    [[DTCSSStylesheet defaultStyleSheet] mergeStylesheet:css];
  2. 加载HTML内容,将它转换为富文本对象。

    1
    2
    3
    4
    5
    6
    NSString *htmlString = @"TODO";
    NSData *data = [htmlString dataUsingEncoding:NSUTF8StringEncoding];
    NSAttributedString* attributedContent = [[NSAttributedString alloc] initWithHTMLData:data
    options:nil
    documentAttributes:nil];
    DTCoreTextLayouter* layouter = [[DTCoreTextLayouter alloc] initWithAttributedString:attributedContent];
  3. 生成渲染对象。(分页和不分页的区别在于生成渲染对象时指定的分页高度,不分页需要设置为CGFLOAT_HEIGHT_UNKNOWN)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 假设画布为屏幕大小,不分页,所有内容在一页显示
    NSInteger position = 0;
    CGRect rect = [[UIScreen mainScreen] bounds];
    rect.size.height = CGFLOAT_HEIGHT_UNKNOWN;
    DTCoreTextLayoutFrame *layoutFrame = [dtLayouter layoutFrameWithRect:rect range:NSMakeRange(position, 0)]; // 渲染对象

    // 假设画布为屏幕大小,按照屏幕大小分页
    NSInteger position = 0;
    CGRect rect = [[UIScreen mainScreen] bounds];
    DTCoreTextLayoutFrame *layoutFrame = [dtLayouter layoutFrameWithRect:rect range:NSMakeRange(position, 0)]; // 渲染对象

上面代码中使用了它提供的一个简单易懂的分页API:

1
2
3
4
5
6
/**
Creates a layout frame with a given rectangle and string range. The layouter fills the layout frame with as many lines as fit. You can query [DTCoreTextLayoutFrame visibleStringRange] for the range the fits and create another layout frame that continues the text from there to create multiple pages, for example for an e-book.
@param frame The rectangle to fill with text
@param range The string range to fill, pass {0,0} for the entire string (as much as fits)
*/
- (DTCoreTextLayoutFrame *)layoutFrameWithRect:(CGRect)frame range:(NSRange)range;

该API给定一个frame大小,然后告诉它从什么range开始分页,返回切好的富文本渲染对象,该对象包含了该页所有渲染的lines和attachments对象,将DTCoreTextLayoutFrame设置到DTAttributedTextContentView中,即可展示出分页的富文本内容。

  1. 将渲染对象画在视图上。

    1
    2
    3
    DTAttributedTextContentView *attributedTextContentView = [DTAttributedTextContentView new];
    attributedTextContentView.frame = [[UIScreen mainScreen] bounds];
    attributedTextContentView.layoutFrame = layoutFrame;
  2. 附件的处理
    如果内容中有附件:图片、视频、占位,需要在代理回调中返回UIView,该view的坐标和大小是在HTML中提前指定好的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    - (UIView *)attributedTextContentView:(DTAttributedTextContentView *)attributedTextContentView viewForAttachment:(DTTextAttachment *)attachment frame:(CGRect)frame {
    if ([attachment isKindOfClass:[DTIframeTextAttachment class]]) {
    DTWebVideoView *videoView = [[DTWebVideoView alloc] initWithFrame:frame];
    videoView.attachment = attachment;

    return videoView;
    } else if ([attachment isKindOfClass:[DTObjectTextAttachment class]]) {
    // somecolorparameter has a HTML color
    NSString *colorName = [attachment.attributes objectForKey:@"somecolorparameter"];
    UIColor *someColor = DTColorCreateWithHTMLName(colorName);

    UIView *someView = [[UIView alloc] initWithFrame:frame];
    someView.backgroundColor = someColor;
    someView.layer.borderWidth = 1;
    someView.layer.borderColor = [UIColor blackColor].CGColor;

    someView.accessibilityLabel = colorName;
    someView.isAccessibilityElement = YES;

    return someView;
    }
    return nil;
    }

仅需5步就可完成富文本的排版渲染,是不是简单且高效?

为了增加用户粘性,在每个章节末页增加作者头像、昵称、感言和本章精彩评论,营造一个良好的社区环境,引起读者的共鸣。在开发章末非正文的附加页(作家感言、精彩评论、推荐内容等自定页)时,技术预言了不同的几种技术方案:

  1. 正文页和附加页都使用DTCoreText进行排版渲染。该种技术实现方式在技术预言过程中发现,随着页面复杂度的提高,曲线陡升式的提高了附加页的开发成本,大大增加开发人员的学习成本,且DTCoreText支持的CSS样式和HTML标签非常有限,如果遇到设计稿中复杂的排版样式,这种风险无意是颗定时炸弹,最终将会导致项目难以扩展和维护。
  2. 正文页使用DTCoreText排版渲染,附加页使用传统的UIView开发方式,该种技术实现方式有2种完全不一样的解耦实现方案:

    • 方案一:附加页自带分页算法,即编写附加页的开发人员在编写UI时,需要提供一个分页算法,将其拼接到正文页末。该方案对开发人员对分页算法技术要求较高,需要计算每行的排版坐标,在分页计算时对每行进行坐标位置计算,最终确定每页填充的渲染内容。

    • 方案二:附加页不自带分页算法,由一个公共的切页算法对附加页进行页面切分,需要抽象出分页的规则,定制一套附加页UI开发标准。该方案对开发人员要求较低,开发人员无需了解分页排版算法,按照UI开发标准完成组件开发后,交由阅读开发人员编写的统一切页算法器完成附加页接入正文页末。

    方案二更优不是吗?任何开发人员都能写阅读章末页的附加UI,从此再也不害怕产品大大对章末附加页有天马行空的想法了,是不是很赞?

确定方案前的疑问

  1. 真的有一种通用的算法来切分任意的附加页吗?
  2. 它是否真的不需要附加页开发参与到分页算法的开发中?
  3. 它是否能满足产品天马行空的想法呢?
  4. 通常一个通用的算法多多少少会在性能上有一定的损耗,这个损耗在用户侧是否能忽略不计呢?
  5. 它能否成为SDK,提供给其他项目使用?

技术方案确定 - 利用纯色行进行分页判断

确定使用技术方案二之后,纵观APP内所有UI页面,能够发现分页算法可以使用纯色行来判断,能否分页要看Y坐标这行的每个像素点是否是一样的色值(RGBA四个都一样,则色值相等),假设这行Y坐标中有不一样的色值,可能是该行有文字、图片、按钮等内容,这时分页的Y坐标要继续换行判断。

任何过早的优化都是浪费时间和精力,所以先假设UI规范不搞特殊化,即任何页面都能

技术预言使用DTCoreText + 自定义UIView排版,即正文内容使用DTCoreText的HTML格式来进行排版渲染,末页的附加页使用UIView的传统页面开发方式,
了2种不同方向的解耦做法:

优秀的APP都会增加用户粘性,阅读APP以优秀的内容取胜,

实际的开发中还会遇到DTCoreText + 自定义UIView排版,产品大大在每章的章末页添加用户黏性页面,例如作家感言、精彩评论、推荐内容等自定页,这里简称这些非正文内容页面为附加页,该附加页样式复杂,如果附加页也使用DTCoreText库中的HTML、CSS样式来渲染,很可能遇到许多不支持的标签,同时会改变客户端开发习惯,无法使用已开发好的公共组件。如果不使用该库,就会遇到另外一个问题,如何把附加页跟随在正文之后进行分页排版?例如:在正文末页添加一个作者头像、名称、作者感言页和本章精彩评论,该附加页需要和正文一样进行分页排版,每章的附加页都是动态内容,即附加页是动态高度。

方案一:
编写附加页代码时,自带算法进行切页。这种方案对开发人员编码能力要求较高,开发页面时需对不同排版进行适配切页规则,每开发一个新页面时,都需要自带切页算法。

方案二:
编写附加页代码时,不带算法进行分页。这种方案需要制定一套附加页开发标准和切页规则,编写附加页的开发人员要按照该套标准进行页面开发,再交由统一的切页算法进行切页。

附加页标准:

  1. 按照什么算法进行页面切分。
  2. 切页后,当中页面的顶部和底部间距。
  3. 尾页的处理。

开发过程中又需要解耦模块,不希望这个新增的页面影响到阅读渲染本身,我这里有一种尝试思路,如有问题希望轻拍。

PS:目前还未运用到实际项目中,只是一个解耦的想法而已💡

解决思路

  1. 如何解决在已渲染好的阅读页中动态加入自定义页,不影响阅读页排版功能,不侵入阅读代码?
    答:假设自定页面只有一页,且高度就是该阅读页正文显示完剩余的区域,那么直接将自定义页面以addSubView的形式加入到该阅读页。
  2. 当自定义页超过一屏时,如何尽可能小影响或不影响阅读翻页逻辑?
    答:前提:目前我们的阅读框架中每页是否有下一页是一个动态询问过程,翻页手势会询问容器中的当前页是否有前、后一页。修改点:询问逐层传递的思想,修改后,阅读页中有自定义子页时,应优先询问子页是否有下一页,如果有,下一页显示自定义子页,如果没有,再看阅读页自身是否有下一页。

核心:如何切页

自定义页面转为像素data,注意不要用高清,节省内存,给一个起始锚点,逐行扫描,当遇到色值不一样时,换行。
代码如下:

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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
typedef enum {
eFindPureSeparateLinePointDirection_down = 0,
eFindPureSeparateLinePointDirection_up
}eFindPureSeparateLinePointDirection;

@interface UIView (WtExt)
- (CGPoint)wt_findPureSeparateLinePointWithAnchor:(CGPoint)point direction:(eFindPureSeparateLinePointDirection)direction;
@end

@implementation UIView (WtExt)
- (CGPoint)wt_findPureSeparateLinePointWithAnchor:(CGPoint)point direction:(eFindPureSeparateLinePointDirection)direction {
int preR = -1, preG = -1, preB = -1, preA = -1;

size_t pixelsWidth = CGRectGetWidth(self.frame);
size_t pixelsHeight = CGRectGetHeight(self.frame);
int x = point.x;
int y = point.y;

CGFloat scale = 1;
CGSize size = CGSizeMake(self.frame.size.width*scale, self.frame.size.height*scale) ;
int bitPerRow = size.width * 4;
int bitCount = bitPerRow * size.height;
UInt8 *bitdata = malloc(bitCount);
if (bitdata == NULL) {
return CGPointMake(-1, INT_MAX);
}

CGColorSpaceRef deviceRGB = CGColorSpaceCreateDeviceRGB();
if (deviceRGB == NULL) {
return CGPointMake(-1, INT_MAX);
}

CGContextRef contex = CGBitmapContextCreate(bitdata, size.width, size.height, 8, bitPerRow, deviceRGB, kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big);
if (contex == NULL) {
CFRelease(deviceRGB);
return CGPointMake(-1, INT_MAX);
}

CFRelease(deviceRGB);

CGContextTranslateCTM(contex, 0, size.height);
CGContextScaleCTM(contex, scale, -scale);

[self.layer renderInContext:contex];

int i = (direction==eFindPureSeparateLinePointDirection_down)?1:-1;
while (true) {
if (y >= pixelsHeight || y < 0) {
x = -1;
y = INT_MAX;
break;
}

if (x >= pixelsWidth) {
x = point.x;
break;
}

int offset = 4*((pixelsWidth*round(y))+round(x));
if (preR == -1) {
preR = bitdata[offset];
preG = bitdata[offset+1];
preB = bitdata[offset+2];
preA = bitdata[offset+3];
}else {
if (preR != bitdata[offset] ||
preG != bitdata[offset+1] ||
preB != bitdata[offset+2] ||
preA != bitdata[offset+3]) {

x = point.x;
y += i;
preR = -1, preG = -1, preB = -1, preA = -1;
}else {
x++;
}
}
}
CGContextRelease(contex);
free(bitdata);
return CGPointMake(x, y);
}
@end