Tiois Tiois - 4 months ago 105
iOS Question

UITableView with autolayout not smooth when scrolling

I'm using XIB files to design cells in my UITableView. I also use the dequeue mecanism, for instance :

let cell = tableView.dequeueReusableCellWithIdentifier("articleCell", forIndexPath: indexPath) as! ArticleTableViewCell
. I precalculate all my row height in the
viewDidLoad
of my ViewController so the method
func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat
returns instantly the right value. All of this works.

In my UITableViewCell, I'm using many dynamic height labels (lines = 0). The layout is like this :

enter image description here

I don't use transparent background, all my subviews are opaque with a specified background color. I checked with the
Color Blended Layers
(everything is green) and
Color Misaligned Images
(nothing is yellow).

Here is my cellForRowAtIndexPath method :

func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let tableViewSection = tableViewSectionAtIndex(indexPath.section)
let tableViewRow = tableViewSection.rows.objectAtIndex(indexPath.row) as! TableViewRow

switch tableViewRow.type! {

case TableViewRowType.Article :
let article = tableViewRow.article!

if article.type == TypeArticle.Article {

let cell = tableView.dequeueReusableCellWithIdentifier("articleCell", forIndexPath: indexPath) as! ArticleTableViewCell
return cell

} else {

let cell = tableView.dequeueReusableCellWithIdentifier("chroniqueCell", forIndexPath: indexPath) as! ChroniqueTableViewCell
return cell

}

default:
return UITableViewCell()

}
}


And then, in the willDisplayCell method :

func tableView(tableView: UITableView, willDisplayCell cell: UITableViewCell, forRowAtIndexPath indexPath: NSIndexPath) {
let tableViewSection = tableViewSectionAtIndex(indexPath.section)
let tableViewRow = tableViewSection.rows.objectAtIndex(indexPath.row) as! TableViewRow
let article = tableViewRow.article!

if cell.isKindOfClass(ArticleTableViewCell) {
let cell = cell as! ArticleTableViewCell
cell.delegate = self
cell.article = article

if let imageView = articleImageCache[article.id] {
cell.articleImage.image = imageView
cell.shareControl.image = imageView
} else {
loadArticleImage(article, articleCell: cell)
}
} else {
let cell = cell as! ChroniqueTableViewCell
cell.delegate = self
cell.article = article

if let chroniqueur = article.getChroniqueur() {
if let imageView = chroniqueurImageCache[chroniqueur.id] {
cell.chroniqueurImage.image = imageView
} else {
loadChroniqueurImage(article, articleCell: cell)
}
}
}
}


All my images are downloaded in a background thread, so there is no image loading when scrolling.

The layout is modified in my
ArticleTableViewCell
when I set the "article" property :
cell.article = article
:

var article: Article? {
didSet {
updateUI()
}
}


And my updateUI function :

func updateUI() -> Void {

if let article = article {
if let surtitre = article.surtitre {
self.surtitre.text = surtitre.uppercaseString
self.surtitre.setLineHeight(3)
} else {
self.surtitre.hidden = true
}

self.titre.text = article.titre
self.titre.setLineHeight(3)

if let amorce = article.amorce {
self.amorce.text = amorce
self.amorce.setLineHeight(3)
} else {
self.amorce.hidden = true
}

if let section = article.sectionSource {
if section.couleurFoncee != "" {
self.bordureSection.backgroundColor = UIColor(hexString: section.couleurFoncee)
self.surtitre.textColor = UIColor(hexString: section.couleurFoncee)
}
}
}
}


The problem is when setting the label text, this causes the lag. The
setLineHeight
method transforms the text of the label into an NSAttributedString to specify the line height, but even when removing this code and simply setting the text label, a small lag occurs when displaying a new cell.

If I remove all the label setup code, the cells displays the default label text and the tableview scroll is perfectly smooth, and the heights are correct too. Whenever I set a label text, the lag occurs.

I'm running the app on my iPhone 6s. On the 6s simulator, there's absolutely no lag at all, perfectly smooth.

Any idead? Maybe it's because I'm using a UIStackView to embed my labels? I did it so because it's easier to hide labels when empty, so the other elements are moved up, to avoid having spacing where the empty label is.

I tried many thing but can't get the tableview to scroll smoothly, any help would be appreciated.

Answer

All my optimizations made it almost 100% fluid on a 6s device but on a 5s, it's really not smooth. I even don't wanna test on a 4s device! I reached the autolayout performance limit when using multiple multiline labels.

After a deep analysis of the Time Profiler, the result is that dynamic labels height (3 in my case) with constraint between them, and those label have attributed text (used to set the line height, but this is not the bottleneck), it seems like the lag is caused by the UIView::layoutSubviews which renders the labels, update the constraints, etc... This is why what when I don't change the label text, everything is smooth. The only solution here is not to use autolayout and layout de subviews programmatically in layoutSubviews method of a custom UITableViewCell subclass.

For those wondering how to do this, I achieved to make a 100% smooth scroll without autolayout and multiple lables with dynamic height (multiline). Here is my UITableView subclass (I use a base class because I have 2 similar cell types) :

//
//  ArticleTableViewCell.swift
//

import UIKit

class ArticleTableViewCell: BaseArticleTableViewCell {

    var articleImage = UIImageView()
    var surtitre = UILabel()
    var titre = UILabel()
    var amorce = UILabel()
    var bordureTop = UIView()
    var bordureLeft = UIView()

    var articleImageWidth = CGFloat(0)
    var articleImageHeight = CGFloat(0)

    override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)

        self.articleImage.clipsToBounds = true
        self.articleImage.contentMode = UIViewContentMode.ScaleAspectFill

        self.bordureTop.backgroundColor = UIColor(colorLiteralRed: 219/255, green: 219/255, blue: 219/255, alpha: 1.0)

        self.bordureLeft.backgroundColor = UIColor.blackColor()

        self.surtitre.numberOfLines = 0
        self.surtitre.font = UIFont(name: "Graphik-Bold", size: 11)
        self.surtitre.textColor = UIColor.blackColor()
        self.surtitre.backgroundColor = self.contentView.backgroundColor

        self.titre.numberOfLines = 0
        self.titre.font = UIFont(name: "PublicoHeadline-Extrabold", size: 22)
        self.titre.textColor = UIColor(colorLiteralRed: 26/255, green: 26/255, blue: 26/255, alpha: 1.0)
        self.titre.backgroundColor = self.contentView.backgroundColor

        self.amorce.numberOfLines = 0
        self.amorce.font = UIFont(name: "Graphik-Regular", size: 12)
        self.amorce.textColor = UIColor.blackColor()
        self.amorce.backgroundColor = self.contentView.backgroundColor

        self.contentView.addSubview(articleImage)
        self.contentView.addSubview(surtitre)
        self.contentView.addSubview(titre)
        self.contentView.addSubview(amorce)
        self.contentView.addSubview(bordureTop)
        self.contentView.addSubview(bordureLeft)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func layoutSubviews() {

        super.layoutSubviews()

        if let article = article {

            var currentY = CGFloat(0)
            let labelX = CGFloat(18)
            let labelWidth = fullWidth - 48

            // Taille de l'image avec un ratio de 372/243
            articleImageWidth = ceil(fullWidth - 3)
            articleImageHeight = ceil((articleImageWidth * 243) / 372)

            self.bordureTop.frame = CGRect(x: 3, y: 0, width: fullWidth - 3, height: 1)

            // Image
            if article.imagePrincipale == nil {
                self.articleImage.frame = CGRect(x: 0, y: 0, width: 0, height: 0)

                self.bordureTop.hidden = false
            } else {
                self.articleImage.frame = CGRect(x: 3, y: 0, width: self.articleImageWidth, height: self.articleImageHeight)
                self.bordureTop.hidden = true
                currentY += self.articleImageHeight
            }

            // Padding top
            currentY += 15

            // Surtitre
            if let surtitre = article.surtitre {
                self.surtitre.frame = CGRect(x: labelX, y: currentY, width: labelWidth, height: 0)
                self.surtitre.preferredMaxLayoutWidth = self.surtitre.frame.width
                self.surtitre.setTextWithLineHeight(surtitre.uppercaseString, lineHeight: 3)
                self.surtitre.sizeToFit()

                currentY += self.surtitre.frame.height
                currentY += 15
            } else {
                self.surtitre.frame = CGRect(x: 0, y: 0, width: 0, height: 0)
            }

            // Titre
            self.titre.frame = CGRect(x: labelX, y: currentY, width: labelWidth, height: 0)
            self.titre.preferredMaxLayoutWidth = self.titre.frame.width
            self.titre.setTextWithLineHeight(article.titre, lineHeight: 3)
            self.titre.sizeToFit()

            currentY += self.titre.frame.height

            // Amorce
            if let amorce = article.amorce {
                currentY += 15

                self.amorce.frame = CGRect(x: labelX, y: currentY, width: labelWidth, height: 0)
                self.amorce.preferredMaxLayoutWidth = self.amorce.frame.width
                self.amorce.setTextWithLineHeight(amorce, lineHeight: 3)
                self.amorce.sizeToFit()

                currentY += self.amorce.frame.height
            } else {
                self.amorce.frame = CGRect(x: 0, y: 0, width: 0, height: 0)
            }

            // Boutons
            currentY += 9

            self.updateButtonsPosition(currentY)
            self.layoutUpdatedAt(currentY)
            currentY += self.favorisButton.frame.height

            // Padding bottom
            currentY += 15

            // Couleurs
            self.bordureLeft.frame = CGRect(x: 0, y: 0, width: 3, height: currentY - 2)
            if let section = article.sectionSource {
                if let couleurFoncee = section.couleurFoncee {
                    self.bordureLeft.backgroundColor = couleurFoncee
                    self.surtitre.textColor = couleurFoncee
                }
            }

            // Mettre à jour le frame du contentView avec la bonne hauteur totale
            var frame = self.contentView.frame
            frame.size.height = currentY
            self.contentView.frame = frame
        }

    }

}

And the base class :

//
//  BaseArticleTableViewCell.swift
//

import UIKit

class BaseArticleTableViewCell: UITableViewCell {

    var backgroundThread: NSURLSessionDataTask?
    var delegate: SectionViewController?

    var favorisButton: FavorisButton!
    var shareButton: ShareButton!

    var updatedAt: UILabel!

    var fullWidth = CGFloat(0)

    var article: Article? {
        didSet {
            // Update du UI quand on set l'article
            updateArticle()
        }
    }

    override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)

        self.selectionStyle = UITableViewCellSelectionStyle.None
        self.contentView.backgroundColor = UIColor(colorLiteralRed: 248/255, green: 248/255, blue: 248/255, alpha: 1.0)

        // Largeur de la cellule, qui est toujours plein écran dans notre cas
        // self.contentView.frame.width ne donne pas la bonne valeur tant que le tableView n'a pas été layouté
        fullWidth = UIScreen.mainScreen().bounds.width

        self.favorisButton = FavorisButton(frame: CGRect(x: fullWidth - 40, y: 0, width: 28, height: 30))
        self.shareButton = ShareButton(frame: CGRect(x: fullWidth - 73, y: 0, width: 28, height: 30))

        self.updatedAt = UILabel(frame: CGRect(x: 18, y: 0, width: 0, height: 0))
        self.updatedAt.font = UIFont(name: "Graphik-Regular", size: 10)
        self.updatedAt.textColor = UIColor(colorLiteralRed: 138/255, green: 138/255, blue: 138/255, alpha: 1.0)
        self.updatedAt.backgroundColor = self.contentView.backgroundColor

        self.addSubview(self.favorisButton)
        self.addSubview(self.shareButton)
        self.addSubview(self.updatedAt)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // Avant qu'une cell soit réutilisée, faire un cleanup
    override func prepareForReuse() {
        super.prepareForReuse()

        // Canceller un background thread si y'en a un actif
        if let backgroundThread = self.backgroundThread {
            backgroundThread.cancel()
            self.backgroundThread = nil
        }

        resetUI()
    }

    // Updater le UI
    func updateArticle() {
        self.favorisButton.article = article
        self.shareButton.article = article

        if let delegate = self.delegate {
            self.shareButton.delegate = delegate
        }
    }

    // Faire un reset du UI avant de réutiliser une instance de Cell
    func resetUI() {

    }

    // Mettre à jour la position des boutons
    func updateButtonsPosition(currentY: CGFloat) {

        // Déjà positionnés en X, width, height, reste le Y
        var shareFrame = self.shareButton.frame
        shareFrame.origin.y = currentY
        self.shareButton.frame = shareFrame

        var favorisFrame = self.favorisButton.frame
        favorisFrame.origin.y = currentY + 1
        self.favorisButton.frame = favorisFrame
    }

    // Mettre à jour la position du updatedAt et son texte
    func layoutUpdatedAt(currentY: CGFloat) {
        var frame = self.updatedAt.frame
        frame.origin.y = currentY + 15
        self.updatedAt.frame = frame

        if let updatedAt = article?.updatedAtListe {
            self.updatedAt.text = updatedAt
        } else {
            self.updatedAt.text = ""
        }

        self.updatedAt.sizeToFit()
    }

}

On the viewDidLoad of my ViewController, i pre-calculate all the row height :

// Créer une cache des row height des articles
func calculRowHeight() {
    self.articleRowHeights = [Int: CGFloat]()

    // Utiliser une seule instance de chaque type de cell
    let articleCell = tableView.dequeueReusableCellWithIdentifier("articleCell") as! BaseArticleTableViewCell
    let chroniqueCell = tableView.dequeueReusableCellWithIdentifier("chroniqueCell") as! BaseArticleTableViewCell

    var cell: BaseArticleTableViewCell!

    for articleObj in section.articles {
        let article = articleObj as! Article

        // Utiliser le bon type de cell
        if article.type == TypeArticle.Article {
            cell = articleCell
        } else {
            cell = chroniqueCell
        }

        // Setter l'article et refaire le layout
        cell.article = article
        cell.layoutSubviews()

        // Prendre la hauteur générée
        self.articleRowHeights[article.id] = cell.contentView.frame.height
    }
}

Set the row height for the requested cell :

func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
    let tableViewSection = tableViewSectionAtIndex(indexPath.section)
    let tableViewRow = tableViewSection.rows.objectAtIndex(indexPath.row) as! TableViewRow

    switch tableViewRow.type! {

    case TableViewRowType.Article :
        let article = tableViewRow.article!
        return self.articleRowHeights[article.id]!

    default:
        return UITableViewAutomaticDimension
    }
}

Return the cell in cellForRowAtIndexPath (I have multiple cell types in my tableView, so there's a couple of checks to do) :

// Cellule pour un section/row spécifique
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
    let tableViewSection = tableViewSectionAtIndex(indexPath.section)
    let tableViewRow = tableViewSection.rows.objectAtIndex(indexPath.row) as! TableViewRow

    switch tableViewRow.type! {

    case TableViewRowType.Article :
        let article = tableViewRow.article!

        if article.type == TypeArticle.Article {
            let cell = tableView.dequeueReusableCellWithIdentifier("articleCell", forIndexPath: indexPath) as! ArticleTableViewCell
            cell.delegate = self
            cell.article = article

            if let imageView = articleImageCache[article.id] {
                cell.articleImage.image = imageView
                cell.shareButton.image = imageView
            } else {
                cell.articleImage.image = placeholder
                loadArticleImage(article, articleCell: cell)
            }

            return cell

        }

        return UITableViewCell()
    default:
        return UITableViewCell()

    }
}