一尘不染

在 UITableView 中使用自动布局进行动态单元格布局和可变行高

javascript

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


阅读 126

收藏
2022-02-22

共1个答案

一尘不染

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

概念描述

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

1. 设置和添加约束

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

请记住,这个想法是让单元格的子视图垂直连接到单元格的内容视图,以便它们可以“施加压力”并使内容视图扩展以适应它们。使用带有几个子视图的示例单元格,这里是您的一些 (不是全部!)约束需要看起来的可视化说明:

表格视图单元格上的约束示例说明。

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

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

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

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

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

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

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

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

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

3.启用行高估计

要启用自调整表格视图单元格,您必须将表格视图的 rowHeight 属性设置为 UITableViewAutomaticDimension。您还必须为estimatedRowHeight 属性分配一个值。一旦设置了这两个属性,系统就会使用 Auto Layout 来计算行的实际高度

Apple:使用自调整大小的表格视图单元格

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

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

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

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

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

3. 进行布局传递并获取单元格高度

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

然后,强制单元格立即布局其子视图,然后使用’ssystemLayoutSizeFittingSize:上的方法找出单元格所需的高度是多少。用于获取适合单元格所有内容所需的最小尺寸。然后可以从委托方法返回高度。UITableViewCell``contentView``UILayoutFittingCompressedSize``tableView:heightForRowAtIndexPath:

4.使用估计的行高

如果您的表格视图中有超过几十行,您会发现在第一次加载表格视图时执行自动布局约束求解会很快使主线程陷入困境,正如在第一次加载tableView:heightForRowAtIndexPath:时对每一行调用的那样(为了计算滚动指示器的大小)。

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

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

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

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

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;
    }
}
2022-02-22