如何在表视图中的UITableViewCells中使用“自动布局”,让每个单元格的内容和子视图确定行高度(自身/自动),同时保持平滑的滚动性能?


当前回答

我刚刚遇到一个非常重要的问题,作为回答发布。

@smileyborg的回答基本正确。但是,如果自定义单元格类的layoutSubviews方法中有任何代码,例如设置preferredMaxLayoutWidth,则不会使用以下代码运行:

[cell.contentView setNeedsLayout];
[cell.contentView layoutIfNeeded];

这让我困惑了一阵子。然后我意识到这是因为它们只触发contentView上的layoutSubview,而不是单元格本身。

我的工作代码如下:

TCAnswerDetailAppSummaryCell *cell = [self.tableView dequeueReusableCellWithIdentifier:@"TCAnswerDetailAppSummaryCell"];
[cell configureWithThirdPartyObject:self.app];
[cell layoutIfNeeded];
CGFloat height = [cell.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize].height;
return height;

注意,如果您正在创建一个新的单元格,我很确定您不需要调用setNeedsLayout,因为它应该已经设置好了。在保存对单元格的引用的情况下,您可能应该调用它。不管怎样,它都不会造成任何影响。

另一个提示是,如果您使用的是单元格子类,则需要设置preferredMaxLayoutWidth等属性。正如@smileyborg提到的,“您的表视图单元格的宽度尚未固定到表视图的宽度”。这是正确的,如果您在子类中而不是在视图控制器中进行工作,则会遇到麻烦。但是,此时可以使用表格宽度简单地设置单元格框架:

例如,在计算高度时:

self.summaryCell = [self.tableView dequeueReusableCellWithIdentifier:@"TCAnswerDetailDefaultSummaryCell"];
CGRect oldFrame = self.summaryCell.frame;
self.summaryCell.frame = CGRectMake(oldFrame.origin.x, oldFrame.origin.y, self.tableView.frame.size.width, oldFrame.size.height);

(我碰巧缓存了这个特定的单元以供重用,但这无关紧要)。

其他回答

另一个“解决方案”:跳过所有这些挫折,改用UIScrollView来获得与UITableView看起来和感觉相同的结果。

对我来说,这是一个痛苦的“解决方案”,因为我花了20多个令人沮丧的小时试图构建类似smileyborg建议的东西,但在几个月和三个版本的应用商店发布中失败了。

我的看法是,如果你真的需要iOS 7支持(对我们来说,这是必不可少的),那么这项技术太脆弱了,你会竭尽全力。UITableView通常是完全过度的,除非您使用一些高级行编辑功能和/或确实需要支持1000+“行”(在我们的应用程序中,它实际上永远不会超过20行)。

额外的好处是,与UITableView附带的所有委托垃圾相比,代码变得异常简单。它只是viewOnLoad中的一个代码循环,看起来很优雅,易于管理。

以下是一些关于如何做到这一点的提示:

使用Storyboard或nib文件,创建ViewController和关联的根视图。将UIScrollView拖到根视图上。向顶层视图添加顶部、底部、左侧和右侧约束,以便UIScrollView填充整个根视图。在UIScrollView中添加一个UIView,并将其称为“容器”。向UIScrollView(其父视图)添加顶部、底部、左侧和右侧约束。关键技巧:还要添加一个“等宽”约束来链接UIScrollView和UIView。注意:您将收到一个错误“滚动视图的可滚动内容高度不明确”,并且您的容器UIView的高度应该为0像素。当应用程序运行时,这两个错误似乎都不重要。为每个“单元”创建笔尖文件和控制器。使用UIView而不是UITableViewCell。在您的根ViewController中,您实际上将所有“行”添加到容器UIView中,并以编程方式添加约束,将它们的左边缘和右边缘链接到容器视图,将其顶部边缘链接到(第一个项目的)容器视图顶部或上一个单元格。然后将最终单元格链接到容器底部。

对我们来说,每一行都在一个笔尖文件中。所以代码看起来像这样:

class YourRootViewController {

    @IBOutlet var container: UIView! //container mentioned in step 4

    override func viewDidLoad() {
        
        super.viewDidLoad()

        var lastView: UIView?
        for data in yourDataSource {

            var cell = YourCellController(nibName: "YourCellNibName", bundle: nil)
            UITools.addViewToTop(container, child: cell.view, sibling: lastView)
            lastView = cell.view
            //Insert code here to populate your cell
        }

        if(lastView != nil) {
            container.addConstraint(NSLayoutConstraint(
                item: lastView!,
                attribute: NSLayoutAttribute.Bottom,
                relatedBy: NSLayoutRelation.Equal,
                toItem: container,
                attribute: NSLayoutAttribute.Bottom,
                multiplier: 1,
                constant: 0))
        }

        ///Add a refresh control, if you want - it seems to work fine in our app:
        var refreshControl = UIRefreshControl()
        container.addSubview(refreshControl!)
    }
}

下面是UITools.addViewToTop的代码:

class UITools {
    ///Add child to container, full width of the container and directly under sibling (or container if sibling nil):
    class func addViewToTop(container: UIView, child: UIView, sibling: UIView? = nil)
    {
        child.setTranslatesAutoresizingMaskIntoConstraints(false)
        container.addSubview(child)
        
        //Set left and right constraints so fills full horz width:
        
        container.addConstraint(NSLayoutConstraint(
            item: child,
            attribute: NSLayoutAttribute.Leading,
            relatedBy: NSLayoutRelation.Equal,
            toItem: container,
            attribute: NSLayoutAttribute.Left,
            multiplier: 1,
            constant: 0))
        
        container.addConstraint(NSLayoutConstraint(
            item: child,
            attribute: NSLayoutAttribute.Trailing,
            relatedBy: NSLayoutRelation.Equal,
            toItem: container,
            attribute: NSLayoutAttribute.Right,
            multiplier: 1,
            constant: 0))
        
        //Set vertical position from last item (or for first, from the superview):
        container.addConstraint(NSLayoutConstraint(
            item: child,
            attribute: NSLayoutAttribute.Top,
            relatedBy: NSLayoutRelation.Equal,
            toItem: sibling == nil ? container : sibling,
            attribute: sibling == nil ? NSLayoutAttribute.Top : NSLayoutAttribute.Bottom,
            multiplier: 1,
            constant: 0))
    }
}

到目前为止,我用这种方法发现的唯一“错误”是UITableView具有一个很好的特性,即在滚动时在视图顶部“浮动”节标题。除非您添加更多的编程,否则上面的解决方案无法做到这一点,但对于我们的特定情况,这一功能并不是100%必需的,当它消失时,没有人注意到。

如果您希望在单元格之间使用分隔符,只需在自定义“单元格”底部添加一个1像素高的UIView,它看起来像分隔符。

确保启用“反弹”和“垂直反弹”,刷新控件才能工作,这样看起来更像是一个表视图。

TableView在您的内容下显示一些空行和分隔符,如果它没有像这个解决方案那样填满整个屏幕。但就我个人而言,我更希望那些空行不在那里——因为单元格高度可变,所以我总是觉得空行在那里很“麻烦”。

希望其他一些程序员在浪费20多个小时试图在自己的应用程序中使用表视图解决问题之前阅读我的帖子

以防人们仍然对此感到困扰。我写了一篇关于在UITableViews中使用Autolayout的快速博客文章,利用Autolayout For Dynamic Cell Heights以及一个开源组件来帮助实现这一点,使其更加抽象和易于实现。https://github.com/Raizlabs/RZCellSizeManager

如果你有一根长长的绳子。例如没有换行符的。然后你可能会遇到一些问题。

公认的答案和其他几个答案都提到了“所谓的”修复。你只需要添加

cell.myCellLabel.preferredMaxLayoutWidth = tableView.bounds.width

我发现Suragh的回答最完整、最简洁,因此不会令人困惑。

虽然没有解释为什么需要这些改变。让我们这样做吧。

将以下代码放到项目中。

import UIKit

class ViewController: UIViewController {

    lazy var label : UILabel = {
        let lbl = UILabel()
        lbl.translatesAutoresizingMaskIntoConstraints = false
        lbl.backgroundColor = .red
        lbl.textColor = .black
        return lbl
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        // step0: (0.0, 0.0)
        print("empty Text intrinsicContentSize: \(label.intrinsicContentSize)")
        // ----------
        // step1: (29.0, 20.5)
        label.text = "hiiiii"
        print("hiiiii intrinsicContentSize: \(label.intrinsicContentSize)")
        // ----------
        // step2: (328.0, 20.5)
        label.text = "translatesAutoresizingMaskIntoConstraints"
        print("1 translate intrinsicContentSize: \(label.intrinsicContentSize)")
        // ----------
        // step3: (992.0, 20.5)
        label.text = "translatesAutoresizingMaskIntoConstraints translatesAutoresizingMaskIntoConstraints translatesAutoresizingMaskIntoConstraints"
        print("3 translate intrinsicContentSize: \(label.intrinsicContentSize)")
        // ----------
        // step4: (328.0, 20.5)
        label.text = "translatesAutoresizingMaskIntoConstraints\ntranslatesAutoresizingMaskIntoConstraints\ntranslatesAutoresizingMaskIntoConstraints"
        print("3 translate w/ line breaks (but the line breaks get ignored, because numberOfLines is defaulted to `1` and it will force it all to fit into one line! intrinsicContentSize: \(label.intrinsicContentSize)")
        // ----------
        // step5: (328.0, 61.0)
        label.numberOfLines = 0
        print("3 translate w/ line breaks and '0' numberOfLines intrinsicContentSize: \(label.intrinsicContentSize)")
        // ----------
        // step6: (98.5, 243.5)
        label.preferredMaxLayoutWidth = 100
        print("3 translate w/ line breaks | '0' numberOfLines | preferredMaxLayoutWidth: 100 intrinsicContentSize: \(label.intrinsicContentSize)")

        setupLayout()
    }
    func setupLayout(){
        view.addSubview(label)
        label.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        label.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
    }
}

注意,我没有添加任何大小限制。我只添加了centerX和centerY约束。但标签的大小仍然正确为什么?

因为contentSize。

为了更好地处理这个问题,首先保留步骤0,然后注释掉步骤1-6。让setupLayout()保持不变。观察行为。

然后取消注释步骤1,并观察。

然后取消注释步骤2并观察。

执行此操作,直到取消注释所有6个步骤并观察它们的行为。

从这一切可以得出什么结论?什么因素可以改变contentSize?

文本长度:如果文本较长,则intrinsicContentSize的宽度将增加换行符:如果添加,则intrinsicContentSize的宽度将是所有行的最大宽度。如果一行有25个字符,另一行有2个字符,而另一行则有21个字符,则将根据25个字符计算宽度允许的行数:必须将numberOfLines设置为0,否则将不会有多行。numberOfLines将调整intrinsicContentSize的高度进行调整:假设基于文本,intrinsicContentSize的宽度为200,高度为100,但您希望将宽度限制为标签的容器,您打算做什么?解决方案是将其设置为所需的宽度。通过将preferredMaxLayoutWidth设置为130,则新的intrinsicContentSize的宽度大约为130。高度显然会超过100,因为你需要更多的线条。也就是说,如果你的约束设置正确,那么你根本不需要使用这个!有关更多信息,请参阅此答案及其评论。如果没有限制宽度/高度的约束,则只需要使用preferredMaxLayoutWidth,例如“除非文本超过preferredMaxLayoutWidth否则不要换行”。但如果你将前导/尾随和numberOfLines设置为0,那么你就可以100%确定了!长话短说这里推荐使用它的大多数答案都是错误的!你不需要它。需要它意味着你的约束没有正确设置,或者你只是没有约束字体大小:还请注意,如果增加fontSize,则intrinsicContentSize的高度将增加。我的代码中没有显示这一点。你可以自己试试。

回到tableViewCell示例:

您需要做的是:

将numberOfLines设置为0将标签正确约束到边距/边无需设置preferredMaxLayoutWidth。

我刚刚遇到一个非常重要的问题,作为回答发布。

@smileyborg的回答基本正确。但是,如果自定义单元格类的layoutSubviews方法中有任何代码,例如设置preferredMaxLayoutWidth,则不会使用以下代码运行:

[cell.contentView setNeedsLayout];
[cell.contentView layoutIfNeeded];

这让我困惑了一阵子。然后我意识到这是因为它们只触发contentView上的layoutSubview,而不是单元格本身。

我的工作代码如下:

TCAnswerDetailAppSummaryCell *cell = [self.tableView dequeueReusableCellWithIdentifier:@"TCAnswerDetailAppSummaryCell"];
[cell configureWithThirdPartyObject:self.app];
[cell layoutIfNeeded];
CGFloat height = [cell.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize].height;
return height;

注意,如果您正在创建一个新的单元格,我很确定您不需要调用setNeedsLayout,因为它应该已经设置好了。在保存对单元格的引用的情况下,您可能应该调用它。不管怎样,它都不会造成任何影响。

另一个提示是,如果您使用的是单元格子类,则需要设置preferredMaxLayoutWidth等属性。正如@smileyborg提到的,“您的表视图单元格的宽度尚未固定到表视图的宽度”。这是正确的,如果您在子类中而不是在视图控制器中进行工作,则会遇到麻烦。但是,此时可以使用表格宽度简单地设置单元格框架:

例如,在计算高度时:

self.summaryCell = [self.tableView dequeueReusableCellWithIdentifier:@"TCAnswerDetailDefaultSummaryCell"];
CGRect oldFrame = self.summaryCell.frame;
self.summaryCell.frame = CGRectMake(oldFrame.origin.x, oldFrame.origin.y, self.tableView.frame.size.width, oldFrame.size.height);

(我碰巧缓存了这个特定的单元以供重用,但这无关紧要)。

(对于底部读取的Xcode 8.x/Xcode 9.x)

注意Xcode 7.x中的以下问题,这可能是混淆的根源:

Interface Builder无法正确处理自动调整单元格大小的设置。即使你的约束绝对有效,IB仍然会抱怨,并给你令人困惑的建议和错误。原因是IB不愿意根据您的约束条件改变行的高度(以便单元格适合您的内容)。相反,它会保持行的高度不变,并开始建议您更改约束,您应该忽略这些约束。

例如,假设您已经设置好了一切,没有警告,没有错误,一切正常。

现在,如果您更改字体大小(在本例中,我将描述标签的字体大小从17.0更改为18.0)。

由于字体大小增加,标签现在希望占据3行(之前它占据2行)。

如果Interface Builder按预期工作,它将调整单元格的高度以适应新的标签高度。然而,实际发生的情况是IB显示红色的自动布局错误图标,并建议您修改拥抱/压缩优先级。

您应该忽略这些警告。相反,您可以*手动更改行的高度(选择“单元格”>“大小检查器”>“行高度”)。

我一次单击一次(使用上/下步进器)改变这个高度,直到红色箭头错误消失!(实际上,您会收到黄色警告,此时只需继续并执行“更新帧”,一切都应该正常)。

*请注意,您实际上不必在Interface Builder中解决这些红色错误或黄色警告-在运行时,一切都会正常工作(即使IB显示错误/警告)。只需确保在运行时控制台日志中没有出现任何自动布局错误。事实上,尝试总是更新IB中的行高度是非常令人讨厌的,有时几乎不可能(因为分数值)。为了防止烦人的IB警告/错误,您可以选择所涉及的视图,并在Size Inspector中为属性Ambiguity选择Verify Position Only


Xcode 8.x/Xcode 9.x看起来(有时)做的事情与Xcode 7.x不同,但仍然不正确。例如,即使抗压优先级/拥抱优先级设置为必需(1000),Interface Builder也可能拉伸或剪裁标签以适合单元格(而不是调整单元格高度以适合标签周围)。在这种情况下,它甚至可能不会显示任何自动布局警告或错误。或者,有时它完全像上面描述的Xcode7.x那样。