NSAttributedString 的 AutoLayout 行高计算错误

AutoLayout row height miscalculating for NSAttributedString

我的应用程序从 API 中提取 HTML,将其转换为 NSAttributedString(以允许点击链接)并将其写入 AutoLayout table 中的一行。问题是,每当我调用这种类型的单元格时,高度计算错误并且内容被截断。我已经尝试了不同的行高计算实现,但都无法正常工作。

我怎样才能准确、动态地计算其中一行的高度,同时仍然保持点击 HTML 个链接的能力?

Example of undesired behavior

我的代码如下。

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    switch(indexPath.section) {
        ...
        case kContent:
        {
            FlexibleTextViewTableViewCell* cell = (FlexibleTextViewTableViewCell*)[TableFactory getCellForIdentifier:@"content" cellClass:FlexibleTextViewTableViewCell.class forTable:tableView withStyle:UITableViewCellStyleDefault];
            
            [self configureContentCellForIndexPath:cell atIndexPath:indexPath];
            [cell.contentView setNeedsLayout];
            [cell.contentView layoutIfNeeded];
            cell.selectionStyle = UITableViewCellSelectionStyleNone;
            cell.desc.font = [UIFont fontWithName:[StringFactory defaultFontType] size:14.0f];
                        
            return cell;
        }
        ...
        default:
            return nil;
    }
}

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
    UIFont *contentFont = [UIFont fontWithName:[StringFactory defaultFontType] size:14.0f];
    switch(indexPath.section) {
        ...
        case kContent:
            return [self textViewHeightForAttributedText:[self convertHTMLtoAttributedString:myHTMLString] andFont:contentFont andWidth:self.tappableCell.width];
            break;
        ...
        default:
            return 0.0f;
    }
}

-(NSAttributedString*) convertHTMLtoAttributedString: (NSString *) html {
    return [[NSAttributedString alloc] initWithData:[html dataUsingEncoding:NSUTF8StringEncoding]
                                            options:@{NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType,
                                                      NSCharacterEncodingDocumentAttribute: @(NSUTF8StringEncoding)}
                                 documentAttributes:nil
                                              error:nil];
}

- (CGFloat)textViewHeightForAttributedText:(NSAttributedString*)text andFont:(UIFont *)font andWidth:(CGFloat)width {
    NSMutableAttributedString *mutableText = [[NSMutableAttributedString alloc] initWithAttributedString:text];
    
    [mutableText addAttribute:NSFontAttributeName value:font range:NSMakeRange(0, text.length)];
    
    UITextView *calculationView = [[UITextView alloc] init];
    [calculationView setAttributedText:mutableText];
    
    CGSize size = [self text:mutableText.string sizeWithFont:font constrainedToSize:CGSizeMake(width,FLT_MAX)];
    CGSize sizeThatFits = [calculationView sizeThatFits:CGSizeMake(width, FLT_MAX)];
    
    return sizeThatFits.height;
}

如果您可以针对 iOS8 使用动态单元格大小调整是解决您的问题的理想方法。

要使用动态调整单元格大小,请删除 heightForRowAtIndexPath: 并将 self.tableView.rowHeight 设置为 UITableViewAutomaticDimension。

这是一个包含更多详细信息的视频: https://developer.apple.com/videos/wwdc/2014/?include=226#226

在我正在开发的应用程序中,该应用程序从其他人编写的糟糕 API 中提取了糟糕的 HTML 字符串,并将 HTML 字符串转换为 NSAttributedString对象。没办法只能用这个烂API。很伤心。任何必须解析可怕的 HTML 字符串的人都知道我的痛苦。我使用 Text Kit。方法如下:

  1. 解析html字符串得到DOM对象。我将 libxml 与轻型包装器一起使用,hpple。这种组合非常快速且易于使用。强烈推荐。
  2. 递归遍历DOM对象构造NSAttributedString对象,使用自定义属性标记link,使用NSTextAttachment标记图片。我称之为富文本。
  3. 创建或重用主要 Text Kit 对象。即 NSLayoutManagerNSTextStorageNSTextContainer。分配后将它们连接起来。
  4. 布局过程
    1. 将步骤2中构造的富文本传递给步骤3中的NSTextStorage对象。with[NSTextStorage setAttributedString:]
    2. 使用方法 [NSLayoutManager ensureLayoutForTextContainer:] 强制进行布局
  5. 用方法[NSLayoutManager usedRectForTextContainer:]计算绘制富文本所需的帧数。如果需要,添加填充或边距。
  6. 渲染过程
    1. return[tableView: heightForRowAtIndexPath:]
    2. 中第5步计算的高度
    3. [NSLayoutManager drawGlyphsForGlyphRange:atPoint:]绘制第2步中的富文本。我在这里使用了屏幕外绘图技术,所以结果是一个 UIImage 对象。
    4. 使用UIImageView渲染最终结果图像。或者将结果图片对象传给contents 属性 of layer 属性 of contentView 属性 of UITableViewCell object in [tableView:cellForRowAtIndexPath:].
  7. 事件处理
    1. 捕获触摸事件。我使用 table 视图附带的点击手势识别器。
    2. 获取触摸事件的位置。使用此位置检查用户是否点击了 link 或带有 [NSLayoutManager glyphIndexForPoint:inTextContainer:fractionOfDistanceThroughGlyph][NSAttributedString attribute:atIndex:effectiveRange:] 的图像。

事件处理代码片段:

CGPoint location = [tap locationInView:self.tableView];
// tap is a tap gesture recognizer

NSIndexPath *indexPath = [self.tableView indexPathForRowAtPoint:location];
if (!indexPath) {
    return;
}

CustomDataModel *post = [self getPostWithIndexPath:indexPath];
// CustomDataModel is a subclass of NSObject class.

UITableViewCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
location = [tap locationInView:cell.contentView];
// the rich text is drawn into a bitmap context and rendered with 
// cell.contentView.layer.contents

// The `Text Kit` objects can  be accessed with the model object.
NSUInteger index = [post.layoutManager 
                        glyphIndexForPoint:location 
                           inTextContainer:post.textContainer 
            fractionOfDistanceThroughGlyph:NULL];

CustomLinkAttribute *link = [post.content.richText 
                                 attribute:CustomLinkAttributeName 
                                   atIndex:index 
                            effectiveRange:NULL];
// CustomLinkAttributeName is a string constant defined in other file
// CustomLinkAttribute is a subclass of NSObject class. The instance of 
// this class contains information of a link
if (link) {
    // handle tap on link
}

// same technique can be used to handle tap on image

在呈现相同的 html 字符串时,此方法比 [NSAttributedString initWithData:options:documentAttributes:error:] 更快且更可自定义。即使没有分析,我也可以看出 Text Kit 方法更快。即使我必须自己解析 html 并构造属性字符串,它也非常快速且令人满意。 NSDocumentTypeDocumentAttribute 方法太慢因此不被接受table。使用Text Kit,我还可以创建复杂的布局,如带有可变缩进的文本块、边框、任意深度的嵌套文本块等。但是它确实需要编写更多代码来构造NSAttributedString 和控制布局过程。我不知道如何计算使用 NSDocumentTypeDocumentAttribute 创建的属性字符串的边界矩形。我相信使用 NSDocumentTypeDocumentAttribute 创建的属性字符串由 Web Kit 而不是 Text Kit 处理。因此不适用于可变高度 table 视图单元格。

编辑: 如果你必须使用 NSDocumentTypeDocumentAttribute,我认为你必须弄清楚布局过程是如何发生的。也许你可以设置一些断点来查看哪个对象负责布局过程。然后也许您可以查询该对象或使用其他方法来模拟布局过程以获取布局信息。有些人使用临时单元格或 UITextView 对象来计算高度,我认为这不是一个好的解决方案。因为在这种情况下,应用程序必须至少两次布局相同的文本块。无论您是否知道,在您的应用程序的某个地方,某些对象必须对文本进行布局,以便您可以获得布局信息,如边界矩形。既然你提到了 NSAttributedString class,最好的解决方案是 Text Kit 在 iOS 7 之后。或者 Core Text 如果你的应用程序是针对较早的 iOS 版本.

我强烈推荐Text Kit,因为这样,对于从API中拉出的每个html字符串,布局过程只发生一次,布局信息如边界矩形和每个字符串的位置字形由 NSLayoutManager 对象缓存。只要保留 Text Kit 个对象,您就可以随时重用它们。当使用 table 视图呈现任意长度的文本时,这是非常有效的,因为每次需要显示一个单元格时,文本只布局一次并绘制。我还建议使用 Text Kit 而不使用 UITextView 作为苹果官方文档的建议。因为如果他想重用 UITextView 附带的 Text Kit 个对象,就必须缓存每个 UITextView。像我一样将 Text Kit 对象附加到模型对象,并且仅在从 API 中拉出新的 html 字符串时更新 NSTextStorage 并强制 NSLayoutManager 进行布局。如果table视图的行数固定,也可以使用固定的占位模型对象列表,避免重复分配和配置。并且因为 drawRect: 导致 Core Animation 创建必须避免的无用的支持位图,所以不要使用 UIViewdrawRect:。使用 CALayer 绘图技术或将文本绘制到位图上下文中。我使用后一种方法是因为可以在后台线程中使用 GCD 完成,因此主线程可以自由响应用户的操作。我的应用程序的结果非常令人满意,速度很快,排版很好,table 视图的滚动非常流畅(60 fps),因为所有绘图过程都是在 GCD 的后台线程中完成的。每个应用程序都需要使用 table 视图绘制一些文本,应使用 Text Kit.

您可以替换此方法来计算属性字符串的高度:

- (CGFloat)textViewHeightForAttributedText:(NSAttributedString*)text andFont:(UIFont *)font andWidth:(CGFloat)width {
CGFloat result = font.pointSize + 4;
if (text)
    result = (ceilf(CGRectGetHeight([text boundingRectWithSize:CGSizeMake(width, CGFLOAT_MAX) options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading context:nil])) + 1);

return result;

}

可能您更改的字体与html 页面上内容的字体不匹配。因此,使用此方法创建具有适当字体的属性字符串:

// HTML -> NSAttributedString

-(NSAttributedString*) convertHTMLtoAttributedString: (NSString *) html {
NSError *error;
NSDictionary *options = @{NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType};
NSAttributedString *attrString = [[NSAttributedString alloc] initWithData:[html dataUsingEncoding:NSUTF8StringEncoding] options:options documentAttributes:nil error:&error];
if(!attrString) {
    NSLog(@"creating attributed string from HTML failed: %@", error.debugDescription);
}
return attrString;

}

// 强制字体通过 & css

- (NSAttributedString *)attributedStringFromHTML:(NSString *)html withFont:(UIFont *)font    {
return [self convertHTMLtoAttributedString:[NSString stringWithFormat:@"<span style=\"font-family: %@; font-size: %f\";>%@</span>", font.fontName, font.pointSize, html]];

}

并在您的 tableView:heightForRowAtIndexPath 中:将其替换为:

case kContent:
        return [self textViewHeightForAttributedText:[self attributedStringFromHTML:myHTMLString withFont:contentFont] andFont:contentFont andWidth:self.tappableCell.width]; 
        break;

我假设您正在使用 UILabel 来显示字符串?

如果你是,我在自动布局的多行标签方面遇到过无数问题。我在这里提供了答案

Table View Cell AutoLayout in iOS8

其中还引用了我的另一个答案,其中详细说明了我是如何解决所有问题的。 iOS 8 中再次出现类似问题,需要在不同区域进行类似修复。

一切都归结为每次边界更改时设置 UILabelpreferredMaxLayoutWidth 的想法。将单元格宽度设置为 运行:

之前表格视图的宽度也有帮助
CGSize size = [cell.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize];

你应该可以像这样转换成 NSString 来计算高度。

-(CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
    UIFont * font = [UIFont systemFontOfSize:15.0f];
    NSString *text = [getYourAttributedTextArray objectAtIndex:indexPath.row] string];
    CGFloat height = [text boundingRectWithSize:CGSizeMake(self.tableView.frame.size.width, maxHeight) options:(NSStringDrawingUsesLineFragmentOrigin|NSStringDrawingUsesFontLeading) attributes:@{NSFontAttributeName: font} context:nil].size.height;

    return height + additionalHeightBuffer;
}

我 运行 在另一个项目中遇到了一个非常相似的问题,其中使用 NSAttributedString 的字段没有以正确的高度呈现。不幸的是,它有两个错误使我们完全放弃在我们的项目中使用它。

第一个是您在此处注意到的错误,某些 HTML 会导致错误的大小计算。这通常来自 p 标签之间的 space。注入 CSS 解决了这个问题,但我们无法控制传入的格式。这在 iOS7 和 iOS8 之间表现不同,一个是错误的,另一个是正确的。

第二个(也是更严重的)错误是 NSAttributedString 荒谬 在 iOS 8 中很慢。我在这里概述了它:NSAttributedString performance is worse under iOS 8

使用 https://github.com/Cocoanetics/DTCoreText 的建议对项目来说非常有效,而不是通过大量的 hack 来让一切都按我们想要的方式执行。

您需要更新内在内容大小。

我假设您在此代码中将属性文本设置为标签 [self configureContentCellForIndexPath:cell atIndexPath:indexPath];

所以,它应该是这样的

cell.youLabel.attributedText = NSAttributedString(...) 
cell.youLabel.invalidateIntrinsicContentSize()
cell.youLabel.layoutIfNeeded()

您的高度计算代码 (CGFloat)textViewHeightForAttributedText:(NSAttributedString*)text andFont:(UIFont *)font andWidth:(CGFloat)width 应替换为使用原型单元格计算单元格高度。

[cell.descriptionLabel setPreferredMaxLayoutWidth:375.0];