25

TL;DR

My programmatically created table view cells are not resizing according to the intrinsic content height of their custom views, even though I am using UITableViewAutomaticDimension and setting both the top and bottom constraints.

The problem probably lies in my implementation of the UITableViewCell subclass. See the code below under Doesn't work programmatically > Code > MyCustomCell.swift.

Goal

I'm trying to make a suggestion bar for a custom Mongolian keyboard. Mongolian is written vertically. In Android it looks like this:

enter image description here

Progress

I've learned that I should use a UITableView with variable cell heights, which is available starting with iOS 8. This requires using auto layout and telling the table view to use automatic dimensions for the cell heights.

Some things I've had to learn along the way are represented in my recent SO questions and answers:

So I have come to the point where I have the vertical labels that support intrinsic content size. These labels go in my custom table view cells. And as described in the next section, they work when I do it in the storyboard, but not when I create everything programmatically.

Works in IB

In order to isolate the problem I created two basic projects: one for where I use the storyboard and one where I do everything programmatically. The storyboard project works. As can be seen in the following image, each table view cell resizes to match the height of custom vertical label.

enter image description here

In IB

I set constraints to pin the top and bottom as well as centering the label.

enter image description here

Code

ViewController.swift

import UIKit
class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {

    let myStrings: [String] = ["a", "bbbbbbb", "cccc", "dddddddddd", "ee"]
    let cellReuseIdentifier = "cell"

    @IBOutlet var tableView: UITableView!

    override func viewDidLoad() {
        super.viewDidLoad()

        tableView.delegate = self
        tableView.dataSource = self

        tableView.estimatedRowHeight = 44.0
        tableView.rowHeight = UITableViewAutomaticDimension
    }

    // number of rows in table view
    func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return self.myStrings.count
    }

    // create a cell for each table view row
    func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {

        let cell:MyCustomCell = self.tableView.dequeueReusableCellWithIdentifier(cellReuseIdentifier) as! MyCustomCell
        cell.myCellLabel.text = self.myStrings[indexPath.row]
        return cell
    }

    // method to run when table view cell is tapped
    func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
        print("You tapped cell number \(indexPath.row).")
    }
}

MyCustomCell.swift

import UIKit
class MyCustomCell: UITableViewCell {
    @IBOutlet weak var myCellLabel: UIMongolSingleLineLabel!
}

Doesn't work programmatically

Since I want the suggestion bar to be a part of the final keyboard, I need to be able to create it programmatically. However, when I try to recreate the above example project programmatically, it isn't working. I get the following result.

enter image description here

The cell heights are not resizing and the custom vertical labels are overlapping each other.

I also get the following error:

Warning once only: Detected a case where constraints ambiguously suggest a height of zero for a tableview cell's content view. We're considering the collapse unintentional and using standard height instead.

This error has been brought up before multiple times on Stack Overflow:

However, the problem for most of those people is that they were not setting both a top and bottom pin constraint. I am, or at least I think I am, as is shown in my code below.

Code

ViewController.swift

import UIKit
class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {

    let myStrings: [String] = ["a", "bbbbbbb", "cccc", "dddddddddd", "ee"]
    let cellReuseIdentifier = "cell"
    var tableView = UITableView()

    override func viewDidLoad() {
        super.viewDidLoad()

        // Suggestion bar
        tableView.frame = CGRect(x: 0, y: 20, width: view.bounds.width, height: view.bounds.height)
        tableView.registerClass(MyCustomCell.self, forCellReuseIdentifier: cellReuseIdentifier)
        tableView.delegate = self
        tableView.dataSource = self
        tableView.estimatedRowHeight = 44.0
        tableView.rowHeight = UITableViewAutomaticDimension
        view.addSubview(tableView)
    }

    // number of rows in table view
    func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return self.myStrings.count
    }

    // create a cell for each table view row
    func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {

        let cell:MyCustomCell = self.tableView.dequeueReusableCellWithIdentifier(cellReuseIdentifier) as! MyCustomCell
        cell.myCellLabel.text = self.myStrings[indexPath.row]
        return cell
    }

    // method to run when table view cell is tapped
    func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
        print("You tapped cell number \(indexPath.row).")
    }
}

MyCustomCell.swift

I think the problem is probably in here since this is the main difference from the IB project.

import UIKit
class MyCustomCell: UITableViewCell {

    var myCellLabel = UIMongolSingleLineLabel()

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

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

    func setup() {
        self.myCellLabel.translatesAutoresizingMaskIntoConstraints = false
        self.myCellLabel.centerText = false
        self.myCellLabel.backgroundColor = UIColor.yellowColor()
        self.addSubview(myCellLabel)

        // Constraints
        // pin top
        NSLayoutConstraint(item: myCellLabel, attribute: NSLayoutAttribute.Top, relatedBy: NSLayoutRelation.Equal, toItem: self.contentView, attribute: NSLayoutAttribute.TopMargin, multiplier: 1.0, constant: 0).active = true
        // pin bottom
        NSLayoutConstraint(item: myCellLabel, attribute: NSLayoutAttribute.Bottom, relatedBy: NSLayoutRelation.Equal, toItem: self.contentView, attribute: NSLayoutAttribute.BottomMargin, multiplier: 1.0, constant: 0).active = true
        // center horizontal
        NSLayoutConstraint(item: myCellLabel, attribute: NSLayoutAttribute.CenterX, relatedBy: NSLayoutRelation.Equal, toItem: self.contentView, attribute: NSLayoutAttribute.CenterX, multiplier: 1, constant: 0).active = true

    }

    override internal class func requiresConstraintBasedLayout() -> Bool {
        return true
    }
}

Supplemental Code

I'll also include the code for the custom vertical label that I used in both projects above, but since the IB project works, I don't think the main problem is here.

import UIKit
@IBDesignable
class UIMongolSingleLineLabel: UIView {

    private let textLayer = LabelTextLayer()
    var useMirroredFont = false

    // MARK: Primary input value

    @IBInspectable var text: String = "A" {
        didSet {
            textLayer.displayString = text
            updateTextLayerFrame()
        }
    }

    @IBInspectable var fontSize: CGFloat = 17 {
        didSet {
            updateTextLayerFrame()
        }
    }

    @IBInspectable var centerText: Bool = true {
        didSet {
            updateTextLayerFrame()
        }
    }

    // MARK: - Initialization

    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setup()
    }

    func setup() {


        // Text layer
        textLayer.backgroundColor = UIColor.yellowColor().CGColor
        textLayer.useMirroredFont = useMirroredFont
        textLayer.contentsScale = UIScreen.mainScreen().scale
        layer.addSublayer(textLayer)

    }

    override func intrinsicContentSize() -> CGSize {
        return textLayer.frame.size
    }

    func updateTextLayerFrame() {

        let myAttribute = [ NSFontAttributeName: UIFont.systemFontOfSize(fontSize) ]
        let attrString = NSMutableAttributedString(string: textLayer.displayString, attributes: myAttribute )
        let size = dimensionsForAttributedString(attrString)

        // This is the frame for the soon-to-be rotated layer
        var x: CGFloat = 0
        var y: CGFloat = 0
        if layer.bounds.width > size.height {
            x = (layer.bounds.width - size.height) / 2
        }
        if centerText {
            y = (layer.bounds.height - size.width) / 2
        }
        textLayer.frame = CGRect(x: x, y: y, width: size.height, height: size.width)
        textLayer.string = attrString
        invalidateIntrinsicContentSize()
    }

    func dimensionsForAttributedString(attrString: NSAttributedString) -> CGSize {

        var ascent: CGFloat = 0
        var descent: CGFloat = 0
        var width: CGFloat = 0
        let line: CTLineRef = CTLineCreateWithAttributedString(attrString)
        width = CGFloat(CTLineGetTypographicBounds(line, &ascent, &descent, nil))

        // make width an even integer for better graphics rendering
        width = ceil(width)
        if Int(width)%2 == 1 {
            width += 1.0
        }

        return CGSize(width: width, height: ceil(ascent+descent))
    }
}

// MARK: - Key Text Layer Class

class LabelTextLayer: CATextLayer {

    // set this to false if not using a mirrored font
    var useMirroredFont = true
    var displayString = ""

    override func drawInContext(ctx: CGContext) {
        // A frame is passed in, in which the frame size is already rotated at the center but the content is not.

        CGContextSaveGState(ctx)

        if useMirroredFont {
            CGContextRotateCTM(ctx, CGFloat(M_PI_2))
            CGContextScaleCTM(ctx, 1.0, -1.0)
        } else {
            CGContextRotateCTM(ctx, CGFloat(M_PI_2))
            CGContextTranslateCTM(ctx, 0, -self.bounds.width)
        }

        super.drawInContext(ctx)
        CGContextRestoreGState(ctx)
    }
}

Update

The entire code for the project is all here, so if anyone is interested enough to try it out, just make a new project and cut and paste the code above into the following three files:

  • ViewController.swift
  • MyCustomCell.swift
  • UIMongolSingleLineLabel.swift
Community
  • 1
  • 1
Suragch
  • 484,302
  • 314
  • 1,365
  • 1,393
  • can you please try to pin left and right sides too? to my understanding, it is surprising to see that your IB project works well; label has a intrinsic content size but you don't set a value for horizontal spacing, how much space would you like to have between label and cell? it seems ambiguous to me. – Mert Buran Apr 13 '16 at 11:45
  • @MertBuran, I set a center horizontal constraint so I think that is what makes the horizontal position unambiguous. – Suragch Apr 13 '16 at 13:05
  • @MertBuran, I will try it with pinning also and see what the result is. (I won't be able to get to it until tomorrow, though.) – Suragch Apr 13 '16 at 13:05
  • I thought of it again and I sort of changed my mind, as it is an UITableView cells don't change their width, thus horizontal pinning shouldn't make any difference. But horizontal pinning still may help UILabel sizing itself just like preferredMaxLayoutWidth does for horizontal/traditional UILabels. Anyway, at least trying is free :) Good luck. – Mert Buran Apr 13 '16 at 13:20
  • Please have a look on my updated answer and also uploaded source code on git hub https://github.com/tarunseera/DynamicHeightCell – Tarun Seera Apr 13 '16 at 14:00

5 Answers5

11

The error is pretty trivial:

Instead of

self.addSubview(myCellLabel)

use

self.contentView.addSubview(myCellLabel)

Also, I would replace

// pin top
NSLayoutConstraint(...).active = true
// pin bottom
NSLayoutConstraint(...).active = true
// center horizontal
NSLayoutConstraint(...).active = true

with

let topConstraint = NSLayoutConstraint(...)
let bottomConstraint = NSLayoutConstraint(...)
let centerConstraint = NSLayoutConstraint(...)

self.contentView.addConstraints([topConstraint, bottomConstraint, centerConstraint])

which is more explicit (you have to specify the constraint owner) and thus safer.

The problem is that when calling active = true on a constraint, the layout system has to decide to which view it should add the constraints. In your case, because the first common ancestor of contentView and myCellLabel is your UITableViewCell, they were added to your UITableViewCell, so they were not actually constraining the contentView (constraints were between siblings not between superview-subview).

Your code actually triggered a console warning:

Warning once only: Detected a case where constraints ambiguously suggest a height of zero for a tableview cell's content view. We're considering the collapse unintentional and using standard height instead.

Which made me to look immediately at the way the constraints are created for your label.

Sulthan
  • 128,090
  • 22
  • 218
  • 270
  • Follow up question: My main error was adding my subview to the cell instead of the cell's `contentView`. But I noticed in testing that both ways of adding constraints work fine. Readability aside, I'm still not really understanding the problem with setting `active = true`. I learned this method from the documentation [here](https://developer.apple.com/library/ios/documentation/UserExperience/Conceptual/AutolayoutPG/ProgrammaticallyCreatingConstraints.html). This is not the main focus of my original question, though, so just tell me to ask a new question if this requires a complex answer. – Suragch Apr 14 '16 at 10:58
  • @Suragch the `active = true` is very subjective and some programmers will have a different opinion. In my opinion, there are two readability problems with your code: 1. you cannot see what you are doing unless you scroll the editor to the very right. 2. Using `active` is also a bit confusing because you are not actually activating or deactivating a constraint. You want to add a constraint but you are using a side effect in `active = true` to add the constraint. This is not a functional problem, this is a readability problem. I prefer to be more expressive because it's easier to spot problems. – Sulthan Apr 14 '16 at 11:10
6

I have tested your code and found the issue was in setting constraints please use below code part for setting constants in your "MyCustomCell.swift" file setup() function

 let topConstraint = NSLayoutConstraint(item: myCellLabel, attribute: .Top, relatedBy: .Equal, toItem: self, attribute: .Top, multiplier: 1, constant: 0)
    let bottomConstraint = NSLayoutConstraint(item: myCellLabel, attribute: .Bottom, relatedBy: .Equal, toItem: self, attribute: .Bottom, multiplier: 1, constant: 0)
    let centerConstraint = NSLayoutConstraint(item: myCellLabel, attribute: .CenterX, relatedBy: .Equal, toItem: self, attribute: .CenterX, multiplier: 1, constant: 0)
    self.addConstraints([centerConstraint, topConstraint, bottomConstraint])

Also set clips to bound property to your cell lable in "viewcontroller.swift"

// create a cell for each table view row
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {

    let cell:MyCustomCell = self.tableView.dequeueReusableCellWithIdentifier(cellReuseIdentifier) as! MyCustomCell
    cell.myCellLabel.text = self.myStrings[indexPath.row]
    cell.myCellLabel.clipsToBounds=true
    return cell
}

For your ease I have uploaded my sample code on GitHub Dynamic Height Sample

Output is looking like this now

enter image description here

Tarun Seera
  • 4,212
  • 4
  • 27
  • 41
  • Let me try with the 3 files you provided – Tarun Seera Apr 13 '16 at 13:29
  • Resolved your issue please check my updated answer also uploaded code on git hub https://github.com/tarunseera/DynamicHeightCell – Tarun Seera Apr 13 '16 at 13:59
  • +1 for finding a solution that works and for the effort you made. The reason I accepted Sulthan's solution, though, is because I needed to add the label subview to the `contentView` rather than to the cell itself. It apparently works to constrain everything to `self` (the cell), but from what I have read in the documentation, it is better to have the constraints to the `contentView`. – Suragch Apr 14 '16 at 11:08
2

The problem seems to come from the vertical constraints in the cell By putting them relative to self instead of self.contentView in MyCustomCell you can fix your problem

    NSLayoutConstraint(item: myCellLabel, attribute: NSLayoutAttribute.Top, relatedBy: NSLayoutRelation.Equal, toItem: self, attribute: NSLayoutAttribute.TopMargin, multiplier: 1.0, constant: 0).active = true
    // pin bottom
    NSLayoutConstraint(item: myCellLabel, attribute: NSLayoutAttribute.Bottom, relatedBy: NSLayoutRelation.Equal, toItem: self, attribute: NSLayoutAttribute.BottomMargin, multiplier: 1.0, constant: 0).active = true
    // center horizontal
    NSLayoutConstraint(item: myCellLabel, attribute: NSLayoutAttribute.CenterX, relatedBy: NSLayoutRelation.Equal, toItem: self, attribute: NSLayoutAttribute.CenterX, multiplier: 1, constant: 0).active = true

the full class would be:

import UIKit
class MyCustomCell: UITableViewCell {

    var myCellLabel = UIMongolSingleLineLabel()

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

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

    func setup() {
        self.myCellLabel.translatesAutoresizingMaskIntoConstraints = false
        self.myCellLabel.centerText = false
        self.myCellLabel.backgroundColor = UIColor.yellowColor()
        self.addSubview(myCellLabel)

        // Constraints
        // pin top
        NSLayoutConstraint(item: myCellLabel, attribute: NSLayoutAttribute.Top, relatedBy: NSLayoutRelation.Equal, toItem: self, attribute: NSLayoutAttribute.TopMargin, multiplier: 1.0, constant: 0).active = true
    // pin bottom
        NSLayoutConstraint(item: myCellLabel, attribute: NSLayoutAttribute.Bottom, relatedBy: NSLayoutRelation.Equal, toItem: self, attribute: NSLayoutAttribute.BottomMargin, multiplier: 1.0, constant: 0).active = true
        // center horizontal
        NSLayoutConstraint(item: myCellLabel, attribute: NSLayoutAttribute.CenterX, relatedBy: NSLayoutRelation.Equal, toItem: self, attribute: NSLayoutAttribute.CenterX, multiplier: 1, constant: 0).active = true

    }

    override internal class func requiresConstraintBasedLayout() -> Bool {
        return true
    }
}
Pierre Oleo
  • 1,209
  • 1
  • 8
  • 6
  • +1 for a solution that works, but as Sulthan's answer pointed out, what I really needed to do was add my subview to the `contentView` rather than `self`. This way I can still add the constraints to `contentView`, which I believe is the more standard way of doing it. – Suragch Apr 14 '16 at 11:15
1

The thing you are missing is this function:

override func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
     return heightValue
}

Im not so sure what you should do exactly, but by the fact that you know your labels you should be able to return an exact height value for each cell in this method

glace
  • 2,162
  • 1
  • 17
  • 23
  • I am considering going this route if I can't get `UITableViewAutomaticDimension` to work. The whole idea of the automatic dimensions, though, is that iOS should calculate the height for me. I shouldn't need to calculate it myself. – Suragch Apr 11 '16 at 11:04
  • Whats your final approach now? – glace Apr 14 '16 at 05:25
  • I was in middle of giving up on automatic dimensions and going with your suggestion (ie, manually calculating the height of every cell), when Sulthan found my problem. See the accepted answer. – Suragch Apr 14 '16 at 11:19
  • Although your solution is not correct for my problem, I think this is a valid alternative for people who need to support pre iOS 8 (which introduced `UITableViewAutomaticDimension`). Those people should note, though, that `heightForRowAtIndexPath` is called before the cell is created in `cellForRowAtIndexPath`, so they will need to calculate the the heights beforehand independently. (+1 to your answer to balance out the -1 and so that you won't delete it.) – Suragch Apr 14 '16 at 11:31
0

I think you are missing to set constraints for tableView with superview. And try to increase estimated row height also.

Ketan Parmar
  • 27,092
  • 9
  • 50
  • 75
  • There should be mistake in constrains of label. Check your all constraints of label and other UI if any in cell. – Ketan Parmar Apr 12 '16 at 10:15
  • The label is the only UI item in the cell. The constraints are written there in the code under MyCustomCell.swift. I can't find any mistakes in them. Let me know if you can. – Suragch Apr 12 '16 at 10:59
  • Try [cell.contentview layoutSubviews] and reload table data in viewDidAppear. second test case, comment your label's constraints and then try above thing. – Ketan Parmar Apr 12 '16 at 12:45