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


当前回答

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

其他回答

在我的例子中,我必须创建一个自定义单元格,其中包含来自服务器的图像,可以是任何宽度和高度。以及两个具有动态大小(宽度和高度)的UILabels

我在回答自动布局和编程实现了同样的效果:

基本上在@smileyBorg以上的回答有所帮助,但systemLayoutSizeFittingSize从未对我奏效,在我的方法中:

1.不使用自动行高计算属性。2.不使用估计高度3.不需要不必要的更新约束。4.不使用自动首选最大布局宽度。5.不使用systemLayoutSizeFittingSize(应该使用但不适用于我,我不知道它在内部做什么),而是使用我的方法-(float)getViewHeight,我知道它在在内部做些什么。

当我使用几种不同的方式显示单元格时,UITableView单元格中的高度是否可能不同?

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

@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);

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

TL;DR:不喜欢读书吗?直接跳转到GitHub上的示例项目:

iOS 8示例项目-需要iOS 8iOS 7示例项目-适用于iOS 7+

概念性描述

无论您正在为哪个iOS版本开发,下面的前两个步骤都适用。

1.设置和添加约束

在UITableViewCell子类中,添加约束,使单元格的子视图的边缘固定到单元格的contentView的边缘(最重要的是顶部和底部边缘)。注意:不要将子视图固定到单元格本身;仅限于单元格的contentView!通过确保每个子视图的垂直维度中的内容压缩阻力和内容拥抱约束不会被添加的更高优先级约束覆盖,让这些子视图的固有内容大小驱动表视图单元格内容视图的高度。(嗯?点击这里。)

记住,这样做的目的是使单元格的子视图垂直连接到单元格的内容视图,以便它们可以“施加压力”,并使内容视图扩展以适应它们。使用带有几个子视图的示例单元格,下面是一些(不是所有!)约束所需的外观的直观图示:

您可以想象,当更多的文本添加到上面示例单元格中的多行正文标签时,它需要垂直增长以适应文本,这将有效地迫使单元格高度增长。(当然,您需要正确设置约束条件,才能正确工作!)

在使用Auto Layout获得动态单元格高度的过程中,正确设置约束无疑是最困难也是最重要的部分。如果你在这里犯了一个错误,它可能会阻止其他一切工作——所以慢慢来吧!我建议您在代码中设置约束,因为您确切地知道哪些约束被添加到哪里,并且在出现问题时更容易调试。在代码中添加约束可能与使用布局锚或GitHub上提供的一个很棒的开源API的Interface Builder一样简单,也比它强大得多。

如果要在代码中添加约束,应该在UITableViewCell子类的updateConstraints方法中执行一次。请注意,updateConstraints可能被多次调用,因此为了避免多次添加相同的约束,请确保在检查诸如didSetupConstraints之类的布尔属性(在运行一次约束添加代码后将其设置为YES)时,将约束添加代码包装在updateConstraints中。另一方面,如果您有更新现有约束的代码(例如调整某些约束的常量属性),请将其放在updateConstraints中,但不要检查didSetupConstraints,以便每次调用方法时都可以运行。

2.确定唯一的表视图单元重用标识符

对于单元中的每个唯一约束集,使用唯一的单元重用标识符。换句话说,如果您的单元格有多个唯一的布局,那么每个唯一的布局都应该接收自己的重用标识符。(当单元变量具有不同数量的子视图,或者子视图以不同的方式排列时,需要使用新的重用标识符。)

例如,如果您在每个单元格中显示一封电子邮件,则可能有4种独特的布局:仅包含主题的邮件、包含主题和正文的邮件、带有主题和照片附件的邮件,以及包含主题、正文和图片附件的邮件。每个布局都有实现它所需的完全不同的约束,因此,一旦初始化了单元并为这些单元类型之一添加了约束,单元就应该获得特定于该单元类型的唯一重用标识符。这意味着当您将一个单元出列以供重用时,约束已经被添加,并准备好用于该单元类型。

请注意,由于固有内容大小的差异,具有相同约束(类型)的单元格可能仍然具有不同的高度!不要因为内容大小不同而将根本不同的布局(不同的约束)与不同的计算视图框架(根据相同的约束解决)混淆。

不要将具有完全不同约束集的单元格添加到同一个重用池(即使用相同的重用标识符),然后在每次出队后尝试删除旧约束并从头开始设置新约束。内部自动布局引擎的设计不是为了处理约束的大规模变化,您会看到大量的性能问题。

对于iOS 8-自动调整单元格大小

3.启用行高度估计

要启用自调整表视图单元格大小,必须设置表视图的rowHeight属性设置为UITableViewAutomaticDimension。您还必须为estimatedRowHeight属性赋值。一旦设置这些财产后,系统使用自动布局计算行的实际高度Apple:使用自调整表视图单元格

在iOS 8中,苹果已经将之前必须由您在iOS 8之前完成的大部分工作内部化。为了允许自动调整单元格大小机制工作,必须首先将表视图上的rowHeight属性设置为常量UITableView.automaticDimension。然后,只需通过将表视图的estimatedRowHeight属性设为非零值来启用行高度估计,例如:

self.tableView.rowHeight = UITableView.automaticDimension;
self.tableView.estimatedRowHeight = 44.0; // set to whatever your "average" cell height is

这将为表格视图提供尚未显示在屏幕上的单元格行高度的临时估计值/占位符。然后,当这些单元格即将在屏幕上滚动时,将计算实际的行高度。为了确定每行的实际高度,表视图会根据内容视图的已知固定宽度(该宽度基于表视图的宽度,减去任何其他内容(如分区索引或附件视图))以及添加到单元格内容视图和子视图的自动布局约束,自动询问每个单元格的contentView需要的高度。一旦确定了此实际单元格高度,将使用新的实际高度更新行的旧估计高度(并根据需要对表视图的contentSize/contentOffset进行任何调整)。

一般来说,您提供的估计值不必非常准确——它只用于在表格视图中正确调整滚动指示器的大小,而表格视图在您滚动屏幕上的单元格时,可以很好地调整滚动指示器以显示不正确的估计值。您应该将表视图(在viewDidLoad或类似视图中)的estimatedRowHeight属性设置为一个常量值,即“平均”行高度。只有当您的行高度具有极大的可变性(例如,相差一个数量级),并且您在滚动时注意到滚动指示器“跳跃”时,才需要麻烦地实现tableView:estimatedHeightForRowAtIndexPath:以进行所需的最小计算,从而为每行返回更准确的估计值。

对于iOS 7支持(自己实现自动调整单元格大小)

3.通过布局并获取单元格高度

首先,实例化一个表视图单元的屏幕外实例,每个重用标识符对应一个实例,该实例严格用于高度计算。(屏幕外意味着单元格引用存储在视图控制器上的属性/ivar中,并且从未从tableView:cellForRowAtIndexPath:返回,以使表视图在屏幕上实际呈现。)接下来,必须使用如果要在表视图中显示,它将保存的确切内容(例如文本、图像等)配置单元格。

然后,强制单元格立即布局其子视图,然后使用UITableViewCell的contentView上的systemLayoutSizeFittingSize:方法来确定单元格所需的高度。使用UILayoutFittingCompressedSize获取适合单元格所有内容所需的最小大小。然后可以从tableView:heightForRowAtIndexPath:delegate方法返回高度。

4.使用估计的行高度

如果您的表视图中有十几行以上的行,那么您会发现,在首次加载表视图时,执行自动布局约束解决会很快使主线程陷入停滞,因为在首次加载时,会对每一行调用tableView:heightForRowAtIndexPath:(以便计算滚动指示器的大小)。

从iOS 7开始,您可以(而且绝对应该)在表视图上使用estimatedRowHeight属性。这将为表格视图提供尚未显示在屏幕上的单元格行高度的临时估计值/占位符。然后,当这些单元格即将在屏幕上滚动时,将计算实际的行高度(通过调用tableView:heightForRowAtIndexPath:),并用实际高度更新估计的高度。

一般来说,您提供的估计值不必非常准确——它只用于在表格视图中正确调整滚动指示器的大小,而表格视图在您滚动屏幕上的单元格时,可以很好地调整滚动指示器以显示不正确的估计值。您应该将表视图(在viewDidLoad或类似视图中)的estimatedRowHeight属性设置为一个常量值,即“平均”行高度。只有当您的行高度具有极大的可变性(例如,相差一个数量级),并且您在滚动时注意到滚动指示器“跳跃”时,才需要麻烦地实现tableView:estimatedHeightForRowAtIndexPath:以进行所需的最小计算,从而为每行返回更准确的估计值。

5.(如果需要)添加行高缓存

如果您已经完成了以上所有操作,但仍然发现在tableView:heightForRowAtIndexPath:中执行约束求解时性能慢得令人无法接受,那么很遗憾,您需要为单元格高度实现一些缓存。(这是苹果工程师建议的方法。)总的想法是让自动布局引擎第一次解决约束,然后缓存该单元格的计算高度,并将缓存的值用于该单元格高度的所有未来请求。当然,诀窍是确保在发生任何可能导致单元格高度发生变化的情况时,清除单元格的缓存高度——主要是当该单元格的内容发生变化或发生其他重要事件时(如用户调整动态类型文本大小滑块)。

iOS 7通用示例代码(有很多有趣的评论)

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    // Determine which reuse identifier should be used for the cell at this 
    // index path, depending on the particular layout required (you may have
    // just one, or may have many).
    NSString *reuseIdentifier = ...;

    // Dequeue a cell for the reuse identifier.
    // Note that this method will init and return a new cell if there isn't
    // one available in the reuse pool, so either way after this line of 
    // code you will have a cell with the correct constraints ready to go.
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:reuseIdentifier];
         
    // Configure the cell with content for the given indexPath, for example:
    // cell.textLabel.text = someTextForThisCell;
    // ...
    
    // Make sure the constraints have been set up for this cell, since it 
    // may have just been created from scratch. Use the following lines, 
    // assuming you are setting up constraints from within the cell's 
    // updateConstraints method:
    [cell setNeedsUpdateConstraints];
    [cell updateConstraintsIfNeeded];

    // If you are using multi-line UILabels, don't forget that the 
    // preferredMaxLayoutWidth needs to be set correctly. Do it at this 
    // point if you are NOT doing it within the UITableViewCell subclass 
    // -[layoutSubviews] method. For example: 
    // cell.multiLineLabel.preferredMaxLayoutWidth = CGRectGetWidth(tableView.bounds);
    
    return cell;
}

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
    // Determine which reuse identifier should be used for the cell at this 
    // index path.
    NSString *reuseIdentifier = ...;

    // Use a dictionary of offscreen cells to get a cell for the reuse 
    // identifier, creating a cell and storing it in the dictionary if one 
    // hasn't already been added for the reuse identifier. WARNING: Don't 
    // call the table view's dequeueReusableCellWithIdentifier: method here 
    // because this will result in a memory leak as the cell is created but 
    // never returned from the tableView:cellForRowAtIndexPath: method!
    UITableViewCell *cell = [self.offscreenCells objectForKey:reuseIdentifier];
    if (!cell) {
        cell = [[YourTableViewCellClass alloc] init];
        [self.offscreenCells setObject:cell forKey:reuseIdentifier];
    }
    
    // Configure the cell with content for the given indexPath, for example:
    // cell.textLabel.text = someTextForThisCell;
    // ...
    
    // Make sure the constraints have been set up for this cell, since it 
    // may have just been created from scratch. Use the following lines, 
    // assuming you are setting up constraints from within the cell's 
    // updateConstraints method:
    [cell setNeedsUpdateConstraints];
    [cell updateConstraintsIfNeeded];

    // Set the width of the cell to match the width of the table view. This
    // is important so that we'll get the correct cell height for different
    // table view widths if the cell's height depends on its width (due to 
    // multi-line UILabels word wrapping, etc). We don't need to do this 
    // above in -[tableView:cellForRowAtIndexPath] because it happens 
    // automatically when the cell is used in the table view. Also note, 
    // the final width of the cell may not be the width of the table view in
    // some cases, for example when a section index is displayed along 
    // the right side of the table view. You must account for the reduced 
    // cell width.
    cell.bounds = CGRectMake(0.0, 0.0, CGRectGetWidth(tableView.bounds), CGRectGetHeight(cell.bounds));

    // Do the layout pass on the cell, which will calculate the frames for 
    // all the views based on the constraints. (Note that you must set the 
    // preferredMaxLayoutWidth on multiline UILabels inside the 
    // -[layoutSubviews] method of the UITableViewCell subclass, or do it 
    // manually at this point before the below 2 lines!)
    [cell setNeedsLayout];
    [cell layoutIfNeeded];

    // Get the actual height required for the cell's contentView
    CGFloat height = [cell.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize].height;

    // Add an extra point to the height to account for the cell separator, 
    // which is added between the bottom of the cell's contentView and the 
    // bottom of the table view cell.
    height += 1.0;

    return height;
}

// NOTE: Set the table view's estimatedRowHeight property instead of 
// implementing the below method, UNLESS you have extreme variability in 
// your row heights and you notice the scroll indicator "jumping" 
// as you scroll.
- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath
{
    // Do the minimal calculations required to be able to return an 
    // estimated row height that's within an order of magnitude of the 
    // actual height. For example:
    if ([self isTallCellAtIndexPath:indexPath]) {
        return 350.0;
    } else {
        return 40.0;
    }
}

示例项目

iOS 8示例项目-需要iOS 8iOS 7示例项目-适用于iOS 7+

由于表视图单元格包含UILabels中的动态内容,这些项目是具有可变行高度的表视图的完整工作示例。

Xamarin(C#/.NET)

如果您正在使用Xamarin,请查看@KentBoogaart整理的这个示例项目。

另一个“解决方案”:跳过所有这些挫折,改用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多个小时试图在自己的应用程序中使用表视图解决问题之前阅读我的帖子

tableView.estimatedRowHeight = 343.0
tableView.rowHeight = UITableViewAutomaticDimension

推荐文章