/ Swift 3

Self sizing cells with rotation support in Swift 3

It's hard to make self sizing cells, but don't worry this time I'll teach you how to do it both with table and collection views supporting orientation changes and dynamic type as well.

NOTICE: For this tutorial I am using Xcode8 beta4 and Swift 3. But this technique should work with other Xcode and language versions too. (You just have to port it)

UITableView

So let's start with a standard single-view template for iOS. By the way the new template selector is really beautiful, right? (I still hope the guys at Apple will include a simple list view as well... ;))

Name the project something line SelfSizing, and go straight to the Main.storyboard file. Select your ViewController, delete it and create a new UITableViewController scene. Set it as initial view controller and create a TableViewController.swift file with the corresponding class.

import UIKit


class TableViewController: UITableViewController {

    var dataSource: [String] = [
        "Donec id elit non mi porta gravida at eget metus.",
        "Integer posuere erat a ante venenatis dapibus posuere velit aliquet. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.",
        "Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit. Vestibulum id ligula porta felis euismod semper. Nullam id dolor id nibh ultricies vehicula ut id elit. Nullam quis risus eget urna mollis ornare vel eu leo.",
        "Maecenas faucibus mollis interdum.",
        "Donec ullamcorper nulla non metus auctor fringilla. Aenean lacinia bibendum nulla sed consectetur. Cras mattis consectetur purus sit amet fermentum.",
        "Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Maecenas faucibus mollis interdum.",
    ]
}

extension TableViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.
    }
    
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }
}

extension TableViewController
{
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return self.dataSource.count
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! TableViewCell
        
        cell.dynamicLabel?.text = self.dataSource[indexPath.row]
        cell.dynamicLabel.font  = UIFont.preferredFont(forTextStyle: UIFontTextStyleBody)
        
        return cell
    }
}

The setup is really self-descriptive. You've got a string array as data source, and the required implementation of the UITableViewDataSource protocol. The only thing that is missing is the TableViewCell class. Firstly, create the class itself, then with interface builder select the table view controller scene and drag a label to the prototype cell.

import UIKit


class TableViewCell: UITableViewCell {
    
    @IBOutlet weak var dynamicLabel: UILabel!
}

Set the class of the prototype cell to TableViewCell. The reusable identifier should be simply "Cell". Connect the dynamicLabel outlet to the view. Give the label top, bottom, leading, trailing constraints to the superview with the value of 8.

Select the label, set the font to body style and the lines property to 0.

Now you are almost ready. You just need to set the estimated row height on the table view. Go back to your TableViewController class and change the viewDidLoad method like this:

override func viewDidLoad() {
    super.viewDidLoad()

    // Do any additional setup after loading the view, typically from a nib.        
    self.tableView.estimatedRowHeight = 44
    self.tableView.rowHeight = UITableViewAutomaticDimension
}

The estimatedRowHeight property will tell the system that the tableview should try to figure out the height of each cell dynamically. You should also change the rowHeight property to automatic dimension, if you don't do then the system will use a static cell height - that one from interface builder that you can set on the cell. Now build & run.

You have a wonderful table view with self sizing cells. You can even rotate your device, it's going to work in both orientations.

One more thing

If you change the text size in the accessibility, the table view will adapt to the new font size.

The font is bigger now:

You can also subscribe to the UIContentSizeCategoryDidChangeNotification to detect size changes and reload the UI.

NotificationCenter.default.addObserver(self.tableView,
    selector: #selector(UITableView.reloadData),
        name: .UIContentSizeCategoryDidChange,
      object: nil)

UICollectionView

So we've finished the easy part. Now let's try to achieve the same functionality with a collection view. UICollectionView is a generic class, that is designed to create custom layouts, because of this generic behaviour you will not be able to create self sizing cells from interface builder. You have to do it from code.

Before we start, we can still play with IB a little bit. Create a new collection view controller scene, and drag a push segue from the previous table view cell to this new controller. Finally embed the whole thing in a navigation controller.

Now create two classes:

  • CollectionViewController
  • CollectionViewCell

The cell is going to be the exact same as we used for the table view, but it's a subclass of UICollectionViewCell, and we are going to construct the layout directly from code.


class CollectionViewCell: UICollectionViewCell {
    
    weak var dynamicLabel: UILabel!
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)

        self.translatesAutoresizingMaskIntoConstraints = false
        
        let label = UILabel(frame: self.bounds)
        label.translatesAutoresizingMaskIntoConstraints = false
        label.font = UIFont.preferredFont(forTextStyle: UIFontTextStyleBody)
        label.backgroundColor = UIColor.darkGray
        label.numberOfLines = 0
        label.preferredMaxLayoutWidth = frame.size.width
        
        self.contentView.addSubview(label)
        self.dynamicLabel = label

        NSLayoutConstraint.activate([
            self.contentView.topAnchor.constraint(equalTo: self.dynamicLabel.topAnchor),
            self.contentView.bottomAnchor.constraint(equalTo: self.dynamicLabel.bottomAnchor),
            self.contentView.leadingAnchor.constraint(equalTo: self.dynamicLabel.leadingAnchor),
            self.contentView.trailingAnchor.constraint(equalTo: self.dynamicLabel.trailingAnchor),
        ])
    }
    
    override func prepareForReuse() {
        super.prepareForReuse()
        
        self.dynamicLabel.font = UIFont.preferredFont(forTextStyle: UIFontTextStyleBody)
    }

    func setPreferred(width: CGFloat) {
        self.dynamicLabel.preferredMaxLayoutWidth = width
    }
}

We have a subclass for our cell, now create the view controller class. I'll only copy here the important part.

Inside the viewDidLoad method you have to set the estimatedItemSize property on the collection view. There if you give wrong size, the autorotation won't work as expected.

extension CollectionViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.collectionView?.register(CollectionViewCell.self, forCellWithReuseIdentifier: "Cell")
        
        if let flowLayout = self.collectionView?.collectionViewLayout as? UICollectionViewFlowLayout {
            flowLayout.itemSize = CGSize(width: 64, height: 64)
            flowLayout.minimumInteritemSpacing = 10
            flowLayout.minimumLineSpacing = 20
            flowLayout.sectionInset = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
            flowLayout.estimatedItemSize = CGSize(width: self.preferredWith(forSize: self.view.bounds.size), height: 64)
        }

        self.collectionView?.reloadData()

        NotificationCenter.default.addObserver(self.collectionView!,
                                               selector: #selector(UICollectionView.reloadData),
                                               name: .UIContentSizeCategoryDidChange,
                                               object: nil)
    }

Inside the rotation methods, you have to invalidate the collection view layout, and recalculate the visible cell sizes when the transition will happen.


    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        super.traitCollectionDidChange(previousTraitCollection)
        
        guard
            let previousTraitCollection = previousTraitCollection,
            self.traitCollection.verticalSizeClass != previousTraitCollection.verticalSizeClass ||
            self.traitCollection.horizontalSizeClass != previousTraitCollection.horizontalSizeClass
        else
        {
            return
        }
        
        self.collectionView?.collectionViewLayout.invalidateLayout()
        self.collectionView?.reloadData()
    }
    
    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)
        
        self.collectionView?.collectionViewLayout.invalidateLayout()
        self.estimateVisibleCellSizes(to: size)
        
        coordinator.animate(alongsideTransition: { context in
            
        }, completion: { context in
            self.collectionView?.collectionViewLayout.invalidateLayout()
        })
    }

}

There are two helper methods to calculate the preferred width for the estimated item size and to recalculate the visible cell sizes.

extension CollectionViewController
{
    func preferredWith(forSize size: CGSize) -> CGFloat {
        return (size.width - 30) // 2.0 //if you want two columns...
    }

    func estimateVisibleCellSizes(to size: CGSize) {
        guard let collectionView = self.collectionView else {
            return
        }
        
        if let flowLayout = self.collectionView?.collectionViewLayout as? UICollectionViewFlowLayout {
            flowLayout.estimatedItemSize = CGSize(width: self.preferredWith(forSize: size), height: 64)
        }
        
        collectionView.visibleCells.forEach({ cell in
            if let cell = cell as? CollectionViewCell {
                cell.setPreferred(width: self.preferredWith(forSize: size))
            }
        })
        
    }
}

Ready to build.

You can even have multiple columns if you do the appropriate calculations.

There is only one thing that I could not solve, but that's just a log message. If you rotate back the device some of the cells are not going to be visible and the layout engine will complain about that those cells can not be snapshotted.

Snapshotting a view that has not been rendered results in an empty snapshot. Ensure your view has been rendered at least once before snapshotting or snapshot after screen updates.

If you can solve this, please don't hesitate to contact me, or fork the repo on github. I really hope that I made self sizing cells easy for you, for me it was a real adventure to figure out the rotation support. ;)

External sources