UITableView choppy scrolling WITHOUT images or content fetching

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:

  • NSCache is not evicting data
  • Is it possible to import third-party Objective-C code into a Swift playground?
  • How to get Reachability Notifications in iOS in Background when dropping Wi-Fi network?
  • Present multiple modal view controllers?
  • AFNetworking and background transfers
  • cloudkit error no authToken received for asset
  • - (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 …

    2 Solutions Collect From Internet About “UITableView choppy scrolling WITHOUT images or content fetching”

    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.

    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(&onceToken, ^{
        sizingCell = [self.tableView dequeueReusableCellWithIdentifier:MyJobCellIdentifier];
    });
    
    // This configures the sizing cell labels with text values
    [self configureCell:sizingCell atIndexPath:indexPath];
    
    // This line calls the calculation. It fires the Auto Layout constraints on the cell,
    // If label 2 and / or label 3 are empty, they will be collapsed to 0 height.
    return [self calculateHeightForConfiguredSizingCell:sizingCell];
    

    }

    - (CGFloat)calculateHeightForConfiguredSizingCell:(MYJobCell *)sizingCell {
    
    //Force the cell to update its constraints
    [sizingCell setNeedsLayout];
    [sizingCell layoutIfNeeded];
    
    // Get the size of the 'squashed' cell and return it to caller
    CGSize size = [sizingCell.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize];
    return size.height;
    

    }

    It’s the best I can do at showing you a working method. You will have to make adjustments to logically cater for all of the different types of custom cells you have.
    But other than that I think this should help. Let me know how you get on.