UITableView choppy scrolling WITHOUT images or content fetching

Question!

So I've got this UITableView which gets its data from memory (already preloaded, there are no requests going on while it is scrolling, everything is loaded before the view is being layouted). Each cell has its height dynamically calculated based on the amount of text in a UITextView and Autolayout. The cells are loaded from a Nib and reusing cells is working properly (at least I hope so). I use UITableViewAutomaticDimension when calculating row height, so I do not force cells to layout twice like you had to do that prior to iOS 8.

Here is the relevant methods where I populate the cells and calculate the heights:

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
    NSString *cellType = [self reuseIdentifierForIndexPath:indexPath];

    if ([cellType isEqualToString:kLoadingCell])
        return kLoadingCellHeight;
    else if ([cellType isEqualToString:kOfflineCell])
        return kOfflineCellHeight;
    else if ([cellType isEqualToString:kFootprintListHeaderCell])
        return kHeaderCellHeight;
    else if ([cellType isEqualToString:kFootprintCellUnsynced])
        return kUnsyncedCellHeight;
    else if ([cellType isEqualToString:kShowFullTripCell])
        return kShowFullTripCellHeight;
    else if ([cellType isEqualToString:kFootprintOnMapCell])
        return kFootprintOnMapCellHeight;
    else
    {
        return UITableViewAutomaticDimension;
    }
}

- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath
{
    NSString *cellType = [self reuseIdentifierForIndexPath:indexPath];

    if ([cellType isEqualToString:kLoadingCell])
        return kLoadingCellHeight;
    else if ([cellType isEqualToString:kOfflineCell])
        return kOfflineCellHeight;
    else if ([cellType isEqualToString:kFootprintListHeaderCell])
        return kHeaderCellHeight;
    else if ([cellType isEqualToString:kFootprintCellUnsynced])
        return kUnsyncedCellHeight;
    else if ([cellType isEqualToString:kShowFullTripCell])
        return kShowFullTripCellHeight;
    else if ([cellType isEqualToString:kFootprintOnMapCell])
        return kFootprintOnMapCellHeight;
    else
    {
        return UITableViewAutomaticDimension;
    }
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    NSString *cellType = [self reuseIdentifierForIndexPath:indexPath];

    if ([cellType isEqualToString:kLoadingCell])
    {
        UITableViewCell *loadingCell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:nil];
        loadingCell.tag = kLoadingCellTag;
        loadingCell.selectionStyle = UITableViewCellSelectionStyleNone;
        loadingCell.backgroundColor = loadingCell.contentView.backgroundColor = [UIColor clearColor];

        UIActivityIndicatorView *activityIndicatorView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhite];
        activityIndicatorView.center = CGPointMake(tableView.frame.size.width / 2, 20);
        [loadingCell.contentView addSubview:activityIndicatorView];

        [activityIndicatorView startAnimating];

        return loadingCell;
    }
    else if ([cellType isEqualToString:kOfflineCell])
    {
        FPOfflineCell *offlineCell = [tableView dequeueReusableCellWithIdentifier:kOfflineCell];
        return offlineCell;
    }
    else if ([cellType isEqualToString:kFootprintListHeaderCell])
    {
        FPFootprintListHeaderCell *headerCell = [tableView dequeueReusableCellWithIdentifier:kFootprintListHeaderCell];
        [headerCell.syncButton addTarget:self action:@selector(syncButtonPressed:) forControlEvents:UIControlEventTouchUpInside];
        return headerCell;
    }
    else if ([cellType isEqualToString:kFootprintCellUnsynced])
    {
        FPFootprintCellUnsynced *unsyncedCell = [tableView dequeueReusableCellWithIdentifier:kFootprintCellUnsynced];
        unsyncedCell.footprint = self.map.footprintsNonSynced[[self unsyncedFootprintIndexForIndexPath:indexPath]];
        return unsyncedCell;
    }
    else if ([cellType isEqualToString:kShowFullTripCell])
    {
        FPShowFullTripCell *showFullTripCell = [tableView dequeueReusableCellWithIdentifier:kShowFullTripCell];
        return showFullTripCell;
    }
    else if ([cellType isEqualToString:kFootprintOnMapCell])
    {
        FPFootprintOnMapCell *footprintOnMapCell = [tableView dequeueReusableCellWithIdentifier:kFootprintOnMapCell];
        footprintOnMapCell.footprint = self.map.footprints[0];
        return footprintOnMapCell;
    }
    else
    {
        FPFootprint *footprint = self.map.footprints[[self footprintIndexForIndexPath:indexPath]];
        FootprintCell *cell = [tableView dequeueReusableCellWithIdentifier:kFootprintCell];
        cell.titleLabel.text = footprint.name;
        cell.dateLabel.text = footprint.displayDate;
        cell.textView.text = nil;
        if (footprint.text && footprint.text.length > 0) {
            if ([self.readmoreCache[@(footprint.hash)] boolValue]) {
                cell.textView.text = footprint.text;
            } else {
                cell.textView.text = [footprint.text stringByAppendingReadMoreAndLimitingToCharacterCount:300 screenWidth:tableView.frame.size.width];
            }
        } else {
            cell.hasText = NO;
        }
        cell.textView.markdownLinkTextViewDelegate = self;
        [cell.textView setNeedsDisplay];
        cell.isPrivate = footprint.isPrivate;
        [cell.likesAndCommentsView setLikesCount:footprint.likes andCommentsCount:footprint.comments];
        [cell.likesAndCommentsView setLiked:footprint.liked];
        [cell.likesAndCommentsView.likeButton addTarget:self action:@selector(likeButtonPressed:) forControlEvents:UIControlEventTouchUpInside];
        [cell.likesAndCommentsView.likesTextButton addTarget:self action:@selector(likesTextButtonPressed:) forControlEvents:UIControlEventTouchUpInside];
        [cell.likesAndCommentsView.commentButton addTarget:self action:@selector(commentButtonPressed:) forControlEvents:UIControlEventTouchUpInside];
        [cell.likesAndCommentsView.commentsTextButton addTarget:self action:@selector(commentsTextButtonPressed:) forControlEvents:UIControlEventTouchUpInside];
        [cell.detailButton addTarget:self action:@selector(detailButtonPressed:) forControlEvents:UIControlEventTouchUpInside];
        [cell.translateButton addTarget:self action:@selector(translateButtonPressed:) forControlEvents:UIControlEventTouchUpInside];
        if (footprint.canBeTranslated) {
            cell.translationStatus = footprint.translationState;
            if (footprint.translationState == FPFootprintTranslationStateTranslated) {
                cell.translatedTextView.text = footprint.translatedText;
            }
        } else {
            cell.translationStatus = FPFootprintTranslationStateNotAvailible;
        }
        cell.numberOfImages = 2;

        return cell;
    }
}

And this is my cell:

import UIKit

@objc class FootprintCell: UITableViewCell {

    var translationStatus: FPFootprintTranslationState = .NotTranslated {
        didSet {
            translateButton.hidden = true
            translateLoader.stopAnimating()
            translatedTextView.hidden = true
            translatedTextView.text = nil

            translatedTextView.addConstraint(translatedTextViewHeightConstraint)
            translationButtonHeightConstraint.constant = 0
            loaderHeightConstraint.constant = 0

            switch translationStatus {
            case .NotAvailible:
                break
            case .NotTranslated:
                translateButton.hidden = false
                translationButtonHeightConstraint.constant = translationButtonHeightConstraintConstant
            case .Translating:
                translateLoader.startAnimating()
                loaderHeightConstraint.constant = loaderHeightConstraintConstant
                translatedTextView.text = nil
            case .Translated:
                translatedTextView.hidden = false
                translatedTextView.removeConstraint(translatedTextViewHeightConstraint)
            }
        }
    }

    var isPrivate: Bool = false {
        didSet {
            privacyBar.hidden = !isPrivate
            privacyIcon.image = UIImage(named: isPrivate ? "ic_lock" : "ic_globe")
        }
    }

    var hasText: Bool = true {
        didSet {
            if hasText {
                textView.removeConstraint(textViewHeightConstraint)
            } else {
                textView.addConstraint(textViewHeightConstraint)
            }
        }
    }

    var numberOfImages: Int = 0 {
        didSet {
            if numberOfImages == 0 {
                imagesContainer.subviews.map { $0.removeFromSuperview() }
            } else if numberOfImages == 2 {
                twoImagesContainer = NSBundle.mainBundle().loadNibNamed("FootprintCellTwoImagesContainer", owner: nil, options: nil)[0] as? FootprintCellTwoImagesContainer
                twoImagesContainer?.setTranslatesAutoresizingMaskIntoConstraints(false)
                imagesContainer.addSubview(twoImagesContainer!)
                let views = ["foo" : twoImagesContainer!] as [NSString : AnyObject]
                imagesContainer.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:|[foo]|", options: .allZeros, metrics: nil, views: views))
                imagesContainer.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:|[foo]|", options: .allZeros, metrics: nil, views: views))
            }
        }
    }

    @IBOutlet private(set) weak var titleLabel: UILabel!
    @IBOutlet private(set) weak var dateLabel: UILabel!
    @IBOutlet private(set) weak var textView: FPForwardingTextView!
    @IBOutlet private(set) weak var likesAndCommentsView: FPLikesAndCommentsView!
    @IBOutlet private weak var privacyBar: UIView!
    @IBOutlet private weak var privacyIcon: UIImageView!
    @IBOutlet private(set) weak var detailButton: UIButton!
    @IBOutlet private(set) weak var translateButton: UIButton!
    @IBOutlet private weak var translateLoader: UIActivityIndicatorView!
    @IBOutlet private(set) weak var translatedTextView: FPForwardingTextView!
    @IBOutlet private(set) weak var imagesContainer: UIView!

    private(set) var twoImagesContainer: FootprintCellTwoImagesContainer?

    @IBOutlet private weak var translationButtonHeightConstraint: NSLayoutConstraint!
    @IBOutlet private weak var loaderHeightConstraint: NSLayoutConstraint!
    @IBOutlet private var translatedTextViewHeightConstraint: NSLayoutConstraint!
    @IBOutlet private var textViewHeightConstraint: NSLayoutConstraint!

    private var translationButtonHeightConstraintConstant: CGFloat!
    private var loaderHeightConstraintConstant: CGFloat!

    override func awakeFromNib() {
        super.awakeFromNib()

        textView.contentInset = UIEdgeInsets(top: -10, left: -5, bottom: 0, right: 0)
        textView.linkColor = UIColor(fromHexString: "0088CC")

        translatedTextView.contentInset = UIEdgeInsets(top: -10, left: -5, bottom: 0, right: 0)
        translatedTextView.linkColor = UIColor(fromHexString: "0088CC")

        privacyBar.backgroundColor = UIColor(patternImage: UIImage(named: "ic_privacy_bar"))

        translatedTextView.text = nil
        translatedTextView.hidden = true
        translateButton.hidden = true
        translationButtonHeightConstraintConstant = translationButtonHeightConstraint.constant
        loaderHeightConstraintConstant = loaderHeightConstraint.constant
        hasText = true
    }

    func layoutMargins() -> UIEdgeInsets {
        return UIEdgeInsetsZero
    }

    override func prepareForReuse() {
        super.prepareForReuse()
        numberOfImages = 0
        translationStatus = .NotAvailible
        hasText = true
    }

}

FootprintCellTwoImagesContainer and FPLikesAndCommentsView are loaded from Nibs and currently do not contain any images or load anything, just some Autolayout.

So the main problem is even when the whole tableView is loaded and every cell is displayed at least once (so there should be enough cells to reuse), after SLOWLY scrolling over a cell border up or down, I get a small jump (like 5 pixels up and down). This happens on every device, even on a 6 Plus.

Any ideas where the problem could be? I hope it is not something with my constraints in the xibs, at least Interface Builder does not throw warnings there ...

By : Ch1llb4y


Answers

OK before the code, the principle. I have custom cells with 4 labels in a column. The top label (label1) always has text and the bottom label (label4) also always has text. Labels 2 and 3 may contain text, that's one may , or both may. To achieve the resizing we use part auto layout and part delegate methods (not far from what you have)

In Interface builder, we set the constraints for the prototype cell

Label1: Leading, Trailing, top, height, width

Label2: Leading, Trailing, top, bottom, height, width

Label3: Leading, Trailing, top, bottom, height, width

Label4: Leading, Trailing, top, bottom, height, width

For Labels 1 and 4 (top and bottom) we set the Content Compression Resistance Priority Vertical to 'required' (1000) Also for labels 2 and 3 we set the Content Compression Resistance Priority Vertical to 'Low' (250)

This basically means if the height should decrease, collapse labels 2 and 3 first and above collapsing labels 1 and 4. (You may know all this already) You should have no warnings and you constraints all added correctly. (do not use constrain to margins unless you know what it does)

Now the code.

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
// Calls the sizing method to return a calculated height.
return [self heightForBasicCellAtIndexPath:indexPath];

}

- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath {
//taken from interface builder. If all 4 labels have strings and not collapsed, this is the height the cell will be.
return 123.0f;

}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
// Yours has lots of case logic - 
// Mine is similar but I configure the properties of the custom cell elsewhere mainly so it can be used for sizing.
[self configureCell:cell atIndexPath:indexPath];
return cell;

}

- (void)configureCell:(MyJobCell *)cell atIndexPath:(NSIndexPath *)indexPath {

// my data source
MyCase *aCase = [_fetchedResultsController objectAtIndexPath:indexPath];

// setting the labels to match the case from data
cell.label1.text = aCase.name;
cell.label2.text = aCase.address;
cell.label3.text = aCase.postcode;
cell.label4.text = aCase.caseDescription;

}

- (CGFloat)heightForBasicCellAtIndexPath:(NSIndexPath *)indexPath {
// In here I create a cell and configure it with a cell identifier
static MyJobCell *sizingCell = nil;
static dispatch_once_t onceToken;
dispatch_once(


I'm not so sure UITableViewAutomaticDimension is for table cells. From the documentation...

You return this value from UITableViewDelegate methods that request dimension metrics when you want UITableView to choose a default value. For example, if you return this constant in the tableView:heightForHeaderInSection: or tableView:heightForFooterInSection:, UITableView uses a height that fits the value returned from tableView:titleForHeaderInSection: or tableView:titleForFooterInSection: (if the title is not nil).

No mention of tableview cells.

So I did a search and found this... more discussion on UITableViewAutomaticDimension...

Where it says..

it will not work. UITableViewAutomaticDimension is not intended to be used to set the row height. Use rowHeight and specify your value or implement:

So I think you may have that wrong.



This video can help you solving your question :)
By: admin