Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

AutoLayout row height miscalculating for NSAttributedString

My app pulls HTML from an API, converts it into a NSAttributedString (in order to allow for tappable links) and writes it to a row in an AutoLayout table. Trouble is, any time I invoke this type of cell, the height is miscalculated and the content is cut off. I have tried different implementations of row height calculations, none of which work correctly.

How can I accurately, and dynamically, calculate the height of one of these rows, while still maintaining the ability to tap HTML links?

Example of undesired behavior

My code is below.

- (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;
}
like image 776
Matt Avatar asked Jan 06 '15 16:01

Matt


1 Answers

In the app I'm working on, the app pulls terrible HTML strings from a lousy API written by other people and converts HTML strings to NSAttributedString objects. I have no choice but to use this lousy API. Very sad. Anyone who has to parse terrible HTML string knows my pain. I use Text Kit. Here is how:

  1. parse html string to get DOM object. I use libxml with a light wrapper, hpple. This combination is super fast and easy to use. Strongly recommended.
  2. traverse the DOM object recursively to construct NSAttributedString object, use custom attribute to mark links, use NSTextAttachment to mark images. I call it rich text.
  3. create or reuse primary Text Kit objects. i.e. NSLayoutManager, NSTextStorage, NSTextContainer. Hook them up after allocation.
  4. layout process
    1. Pass the rich text constructed in step 2 to the NSTextStorage object in step 3. with [NSTextStorage setAttributedString:]
    2. use method [NSLayoutManager ensureLayoutForTextContainer:] to force layout to happen
  5. calculate the frame needed to draw the rich text with method [NSLayoutManager usedRectForTextContainer:]. Add padding or margin if needed.
  6. rendering process
    1. return the height calculated in step 5 in [tableView: heightForRowAtIndexPath:]
    2. draw the rich text in step 2 with [NSLayoutManager drawGlyphsForGlyphRange:atPoint:]. I use off-screen drawing technique here so the result is an UIImage object.
    3. use an UIImageView to render the final result image. Or pass the result image object to the contents property of layer property of contentView property of UITableViewCell object in [tableView:cellForRowAtIndexPath:].
  7. event handling
    1. capture touch event. I use a tap gesture recognizer attached with the table view.
    2. get the location of touch event. Use this location to check if user tapped a link or an image with [NSLayoutManager glyphIndexForPoint:inTextContainer:fractionOfDistanceThroughGlyph] and [NSAttributedString attribute:atIndex:effectiveRange:].

Event handling code snippet:

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

This approach is much faster and more customizable than [NSAttributedString initWithData:options:documentAttributes:error:] when rendering same html string. Even without profiling I can tell the Text Kit approach is faster. It's very fast and satisfying even though I have to parse html and construct attributed string myself. The NSDocumentTypeDocumentAttribute approach is too slow thus is not acceptable. With Text Kit, I can also create complex layout like text block with variable indentation, border, any-depth nested text block, etc. But it does need to write more code to construct NSAttributedString and to control layout process. I don't know how to calculate the bounding rect of an attributed string created with NSDocumentTypeDocumentAttribute. I believe attributed strings created with NSDocumentTypeDocumentAttribute are handled by Web Kit instead of Text Kit. Thus is not meant for variable height table view cells.

EDIT: If you must use NSDocumentTypeDocumentAttribute, I think you have to figure out how the layout process happens. Maybe you can set some breakpoints to see what object is responsible for layout process. Then maybe you can query that object or use another approach to simulate the layout process to get the layout information. Some people use an ad-hoc cell or a UITextView object to calculate height which I think is not a good solution. Because in this way, the app has to layout the same chunk of text at least twice. Whether you know or not, somewhere in your app, some object has to layout the text just so you can get information of layout like bounding rect. Since you mentioned NSAttributedString class, the best solution is Text Kit after iOS 7. Or Core Text if your app is targeted on earlier iOS version.

I strongly recommend Text Kit because in this way, for every html string pulled from API, the layout process only happens once and layout information like bounding rect and positions of every glyph are cached by NSLayoutManager object. As long as the Text Kit objects are kept, you can always reuse them. This is extremely efficient when using table view to render arbitrary length text because text are laid out only once and drawn every time a cell is needed to display. I also recommend use Text Kit without UITextView as the official apple docs suggested. Because one must cache every UITextView if he wants to reuse the Text Kit objects attached with that UITextView. Attach Text Kit objects to model objects like I do and only update NSTextStorage and force NSLayoutManager to layout when a new html string is pulled from API. If the number of rows of table view is fixed, one can also use a fixed list of placeholder model objects to avoid repeat allocation and configuration. And because drawRect: causes Core Animation to create useless backing bitmap which must be avoided, do not use UIView and drawRect:. Either use CALayer drawing technique or draw text into a bitmap context. I use the latter approach because that can be done in a background thread with GCD, thus the main thread is free to respond to user's operation. The result in my app is really satisfying, it's fast, the typesetting is nice, the scrolling of table view is very smooth (60 fps) since all the drawing process are done in background threads with GCD. Every app needs to draw some text with table view should use Text Kit.

like image 82
wcd Avatar answered Sep 29 '22 11:09

wcd