原文:Self-sizing Table View Cells
作者:Bradley Johnson
译者:kmyhy注:本文被 Bradley 升级为适用于 Xcode 7.3/iOS 9/Swift 2.2。原文作者为 Joshua Greene.
如果你之前使用过自定义单元格,你肯定也在代码中花费大量的精力实现过自适应单元格。你可能习惯于手动计算单元格中的每一样东西的高度,比如 Label、ImageView、TextField 等等。
坦率地说,这种方法非常复杂而且问题多多。
在本文中,你将学习如何创建自适应单元格并根据它的内容动态改变它的大小。你可能会想,“那太费事儿了…!”
你错了。:] 在 iOS 8 中,苹果使这个任务变得非常简单。
注:本文需要 Xcode 7.3 以上,以支持最新的 Swift 语法。本文假设你熟悉自动布局、UITableView 和 Swift 开发。
如果你是新手,你可以浏览本站的其他文章或视频。
开始
回忆起 iOS 6 的时候,苹果推出了一项神奇的技术:自动布局。开发者欢呼;在街头庆祝;为它的诞生大唱赞歌…
好吧,你可以说它是一个进步,但它仍然带来了一系列问题。
虽然它被开发者寄予了许多希望,但自动布局真的很难用。在 iOS 开发中,手动写出的自动布局代码,仍然是非常的晦涩难懂。而在 Interface Builder 中,创建布局约束一开始也是非常的低效。
回到今天,随着 Interface Builder 的升级和 iOS 8 的推出,用自动布局创建自适应表格单元格不在是件难事了!
撇开这些细枝末节不论,你所需要做的就是:
- 创建单元格时,启用自动布局
- 设置 table view 的 rowHeight 为 UITableViewAutomaticDimension
- 设置 estimatedRowHeight 或者实现 height estimation 委托方法
等等,你现在不想听长篇大论,你只想看代码?那就让我们直接从项目开始吧。
示例 App 一览
假设你的大客户跟你说“我想在 app 上列出最伟大的已故画家以及他们最伟大的作品!”
“我们已经在做这个 app 了,但我们被一个问题难住了:如何在 table view 上显示内容?”这个客户说。“你能帮个忙吗?”
你突然觉得自己有一种想冲到最近的电话亭并披上斗篷的冲动。
当然,你不需要使用任何伎俩就有机会在客户面前充当英雄——你的编程技能足矣!
首先,下载“这个客户的代码”——Artistry-Starter——也就是本文的开始项目。解压缩 zip 包,用 Xcode 打开项目。
打开 Main.storyboard (在 Views 文件组下) ,你将看到有 3 个 scenes:
从左到右分别是:
- 一个顶级的导航控制器
- ArtistListViewController,用于显示画家列表
- ArtistDetailViewController ,用于显示画家的作品以及生平事迹。
运行程序。你会在 ArtistListViewController 中看到画家列表。选择第一个画家(Pablo Picasso),app 将跳转到ArtistDetailViewController,在那里列出其作品:
这个 app 不仅没有显示出画家和作品的图片,而且显示的文字也是不完整的!每段描述和图片的大小都是不同的,因此你不能只是增加 cell 的高度。cell 高度应当是动态的,根据 cell 的内容来算出。
就在 ArtistListViewController 中来实现动态单元格高度吧。
自适应表格单元格
要实现动态计算单元格高度,你需要创建自定义 cell 并正确设置它的自动布局约束。
在项目导航窗口中,选择 Views 目录,按下 Command + N 键,新建一个文件。新建 Cocoa Touch Class,名为 ArtistTableViewCell,并继承 UITableViewCell。
打开 ArtistTableViewCell.swift 文件,删除自动插入的两个方法,添加属性声明:
@IBOutlet var bioLabel: UILabel!
然后,打开 Main.storyboard,选中 ArtistListViewController 的 table view 中的单元格对象。在 Identity 面板中,将 Class 设置为 ArtistTableViewCell:
拖一个 UILabel 到 cell 中,设置 text 属性为 Bio。Lines 属性(Label 能够显示的最大行数)设置为 0。
对于动态高度的单元格来说,将 lines 设置为 0 是个重点。lines 为 0 的标签表明它的高度会随着文字的长度自动增长。如果将 lines 设置为其它数字,则会导致当文字长度超过指定行数时,文字将被截断。
将标签连接到 ArtistTableViewCell 的 bioLabel 出口。比较快的方法是在 Document Outline 出口中右键点击 cell,然后从弹出的 Outlet 列表中,点击 bioLabel 右边的空心圆圈拖到刚才添加的标签对象上:
要让 UITableViewCell 的自动布局能够生效,诀窍就是它的每个 subview 的 4 条边都加一个 pin 约束——也就是说,每个 subview 都必须有 leading、top、trailing 和 bottom 约束。这样,就会用这些 subview 的 intrinsic 高度来计算 cell 的高度。让我们来试一下。
注意:如果你不熟悉自动布局,或者想了解如何创建自动布局约束,请看这里。
选中 bioLabel,点击故事板右下角的 Pin 按钮。在弹出菜单中,在顶部点击指向 4 个方向的 4 条虚线,左边和右边的值修改为 8,在点击 Add Constraints:
这样,无论单元格有多大,bioLabel 总是:
- 距离 cell 顶部、底部 0 个 point
- 距离 cell 左边、右边 8 个 point
评论:这是否满足前面的自动布局条件?
1. 每个 subview 的 4 边都设置了 pin 约束?yes。
2. 从 contentView 上边到下边是否有约束?yes。
bioLabel 的上、下边距为 0。
因此自动布局完全可以计算出单元格的高度!
好了,ArtistTableViewCell 创建好了!编译运行 app,你会看到:
什么变化都没有。搞毛啊?别担心,你还需要些一小点代码才能让 cell 变成动态的。
设置 Table View
首先,需要设置 table view,让它使用你的自定义 cell。
打开 ArtistListViewController.swift ,将 tableView(_:cellForRowAtIndexPath:) 方法修改为:
func tableView(tableView: UITableView,
cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("Cell",
forIndexPath: indexPath) as! ArtistTableViewCell
let artist = artists[indexPath.row]
cell.bioLabel.text = artist.bio
cell.bioLabel.textColor = UIColor(red: 114 / 255,
green: 114 / 255,
blue: 114 / 255,
alpha: 1.0)
return cell
}
上述代码非常简单:取出一个缓存的 cell,设置它的文字和颜色,然后返回这个 cell。
再次运行 app,看起来还是没什么变化。你现在用的是 bioLabel,但每个 cell 还是只显示一行文本。尽管你已经将 lines 属性设为 0,约束也设对了,bioLabel 现在已经占据了整个 cell,但你还需要告诉 table view,让自动布局引擎计算每个 cell 的高度。
回到 ArtistListViewController.swift 在 viewDidLoad() 最后加入:
tableView.rowHeight = UITableViewAutomaticDimension
tableView.estimatedRowHeight = 140
当 rowHeight 被设置为 UITableViewAutomaticDimension 时,table view 会根据自动布局约束和 cell 的内容来计算单元格高度。
编译运行,你现在可以看到每个大画家的生平简介了 :]
加入图片
虽然能够看到每个画家的简洁,但最好还是再显示一些内容。
每个画家都有一张图片和姓名需要显示。加上这些内容会让整个 app 看起来更好。
加一个 image view 到 ArtistTableViewCell,然后加一个 label 用于显示画家的名字。打开 ArtistTableViewCell.swift,加入如下属性:
@IBOutlet var nameLabel: UILabel!
@IBOutlet var artistImageView: UIImageView!
image view 的变量名是 artistImageView 而不是 imageView,是因为在 UITableViewCell 中已经有一个 imageView 属性了。
打开 Main.storyboard,选中 cell,在 Size 面板中,将 Row Height 设为 140;以便你有更多的空间可用:
选中 bioLabel 的左边距约束,你可以在 Document Outline 窗口的 Content View 的 Constraints 下面找到它:
用 delete 键删除这个约束。不要理会正在提示的自动布局警告。用鼠标按住 bioLabel 的左边缘向右拖,让 bioLabel 的宽度大概只占 cell 宽度的一半。你会在空出的左边放置 image view 和名字标签:
拖一个新的 label 到 cell 的下方,水平居中对齐刚刚空出来的地方。设置 text 属性为 Name:
拖一个 image view,放到 nameLabel 的上方:
然后,为 image view 和 nameLabel 创建连接,就像你在 bioLabel 上所做的一样:
接下来创建约束。首先从 nameLabel 开始,依次往上添加:
- 从 nameLabel 的底部 pin 到 contentView 的底部 0 个 point。
- 从 nameLabel 的顶部 pin 到 image view 的底部 8 个 point。
- 从 image view 的顶部 pin 到 contentView 的顶部 0 个 point。
- 从 image view 的左边 pin 到 contentView 的左边 0 个 point。
- 从 image view 的右边 pin 到 bioLabel 的左边 16 个 point。
选中 image view,用右键拖到 contentView。然后选择 Equal Widths:
在 Document Outline 窗口中,找到新建的这条宽度约束,将它的 multiplier 修改为 0.5:
这会让 image view 的宽度等于单元格宽度的二分之一。
继续添加约束:
- 按住 shift 键,点击 image view 和 nameLabel,然后选择 Pin 菜单下的 Equal Width
- 按住 shift 键,点击 image view 和 nameLabel,然后选择 Align 菜单下的 Horizontal Centers(水平居中对齐)
添加完这些约束,自动布局引擎可能会报几个错误,告诉你某些 frame 是错误的。要消灭这些警告,在 Document Outline 窗口中选择 contentView,然后点击 Resolve Auto Layout Issues 菜单中的 All Views > Update Frames under :
故事板中的操作就完成了。打开 ArtistListViewController.swift,在 tableView(_:cellForRowAtIndexPath:)
方法中,找到设置 bioLabel.text 一句,在后面添加:
cell.artistImageView.image = artist.image
cell.nameLabel.text = artist.name
在设置 textColor 一句后添加:
cell.nameLabel.backgroundColor = UIColor(red: 255 / 255, green: 152 / 255, blue: 1 / 255, alpha: 1.0)
cell.nameLabel.textColor = UIColor.whiteColor()
cell.nameLabel.textAlignment = .Center
cell.selectionStyle = .None
运行 app。这个画面是不是更好看一些?但当你拉到 Georgia O’Keeffe 处,你会发现有点不对劲:
nameLabel 被拉高了(顶部距离 image view 底部 8 point,底部距离 contentView 底部 0 point)。
你可以修改某些约束来解决这个问题。在 Main.storyboard 中,选中 nameLabel 再添加一个约束,从它的底部 pin 到单元格的底部。在 Document Outline 窗口中,选中这条约束,修改它的 Relation 为大于等于:
然后选中 nameLabel 原来的那条底边约束,设置它的优先级为250:
这样,自动布局引擎会在必要的时候放弃老的约束,因为它的优先级比底部边距 >= 0 的那条约束低。运行 app,一切变得更加合理。
显示作品
如果你还记得开始的内容,当点击某个画家时,会跳到另一个 view controller,并显示该画家的作品。在这个 table view 中的 cell 需要使用动态高度,因为在对应的数据中,每个作品都会有不同的大小。
第一步,同之前一样,也是创建一个 UITableViewCell 子类。
在项目导航窗口中选择 views 文件夹,按下 command + N 键,新建一个文件。创建一个名为 WorkTableViewCell 的 Cocoa Touch 类,并继承 UITableViewCell。
打开 WorkTableViewCell.swift,如同之前一样,删除自动生成的两个方法,添加如下属性声明:
@IBOutlet weak var workImageView: UIImageView!
@IBOutlet weak var workTitleLabel: UILabel!
@IBOutlet weak var moreInfoTextView: UITextView!
打开 Main.storyboard ,在 Artist Detail View Controller 中选中位于 table view 中的 cell。将 cell 的 Custom Class 设置为 WorkTableViewCell,然后将 row height 修改为 200 ,以便有更多的空间操作。
拖一个 image view、一个 label、一个 text view,分别如下图放置(text view 放到最下边):
将 text view 的 text 修改为 “Select For More Info >” ,将 label 的 text 设置为 “Name”。将 image view 的mode 属性修改为 Aspect Fit。选择 text view,在属性面板中,修改 alignment 属性为居中,并禁止滚动:
和将 label 的 lines 属性设为 0 一样,禁止 text view 滚动也是一个重点。一旦禁止滚动后,text view 就会根据其内容来增长其 size,因为用户不能通过滚动的方式来查看完整的内容了。
在 Scrolling Enabled 稍往下拉一点,将 User Interaction Enabled 选项清空,这将允许触摸事件穿过 text view 向上传递,并触发单元格的选中事件。
将这 3 个对象与其对应的出口进行连接,就像我们在上一个单元格中所做的一样。
接下来添加约束。首先从 text view 开始依次往上添加:
- 从 text view 底部 pin 到 contentView 底部 0 个 point。
- 从 text view 左边、右边分别 pin 到 contentView 左边和右边 8 个 point。
- 从 text view 顶部 pin 到 label 底部 8 个 point。
- 从 label 顶部 pin 到 image view 底部 8 个 point。
- 从 Align 菜单中选择 Horizontally in Container(居中于容器),使 label 居中。
- 选中 nameLabel 和 image view(用 shift + 鼠标左键),选则 pin 菜单中的 Equal Widths。
- 从 image view 的顶部 pin 到 contentView 顶部 0 个 point。
- 从 image view 的左边、右边 pin 分别 pin 到 contentView 的左边、右边 8 个 point。
如果出现自动布局警告,像之前一样,刷新一下 frame。现在故事板中的工作就做完了。如同前面一样,接下来需要编写一点代码。
打开 ArtistDetailViewController.swift 修改 tableView(_:cellForRowAtIndexPath:) 方法为:
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath) as! WorkTableViewCell
let work = selectedArtist.works[indexPath.row]
cell.workTitleLabel.text = work.title
cell.workImageView.image = work.image
cell.workTitleLabel.backgroundColor = UIColor(red: 204 / 255, green: 204 / 255, blue: 204 / 255, alpha: 1.0)
cell.workTitleLabel.textAlignment = .Center
cell.moreInfoTextView.textColor = UIColor(red: 114 / 255, green: 114 / 255, blue: 114 / 255, alpha: 1.0)
cell.selectionStyle = .None
return cell
}
这些代码看起来非常熟悉。从 cell 缓存中取出一个 cell 转换成自定义 cell,从模型数据中检索要显示的对象,设置 cell 属性然后返回 cell。
在同一类的 viewDidLoad() 方法中,在最后添加如下代码:
tableView.rowHeight = UITableViewAutomaticDimension
tableView.estimatedRowHeight = 300
这和之前 View controller 中的代码是一样的。运行 app,选择毕加索,现在你可以看到这位大画家的作品了:
干得不错!但接下来我们要添加一个滑出式单元格,以显示每个作品的详细介绍。你的用户一定会喜欢这个!
展开式单元格
因为 cell 高度由自动布局约束及每个 UI 元素的内容来计算,展开式单元格非常简单,其实就是在用户点击单元格时往 text view 中插入更多文字而已。
打开 ArtistDetailViewController.swift 定义一个 extension:
extension ArtistDetailViewController: UITableViewDelegate {
func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
// 1
guard let cell = tableView.cellForRowAtIndexPath(indexPath) as? WorkTableViewCell else { return }
var work = selectedArtist.works[indexPath.row]
// 2
work.isExpanded = !work.isExpanded
selectedArtist.works[indexPath.row] = work
// 3
cell.moreInfoTextView.text = work.isExpanded ? work.info : moreInfoText
cell.moreInfoTextView.textAlignment = work.isExpanded ? .Left : .Center
// 4
UIView.animateWithDuration(0.3) {
cell.contentView.layoutIfNeeded()
}
// 5
tableView.beginUpdates()
tableView.endUpdates()
// 6
tableView.scrollToRowAtIndexPath(indexPath, atScrollPosition: UITableViewScrollPosition.Top, animated: true)
}
}
上述代码解释如下:
- 通过用户所选的 index path 从 tableView 中找到指定的 cell,然后根据 index path 检索出对应的作品 work。
- 修改 work 的 isExpanded 属性,然后将它重新放回数组(这是必须的,因为结构体是以拷贝的方式传递的)。
- 然后,根据 work 的 isExpanded 属性 修改 cell 的 text view 的显示内容:如果为 true,将 text view 的 text 设为 work 的 info 属性并将文本对齐方式设置为左对齐。如果为 false,则设置为 “Select to See More >”并置文本对齐方式为居中对齐。
- 修改完 text view 的内容之后,需要刷新 cell 的约束。在动画块中调用 layoutIfNeeded() 将重新计算布局约束。
- 除了刷新布局约束,table view 还需要重新计算 cell 高度。通过调用 beginUpdates() 和 endUpdates(),将以动画方式强制让 table view 刷新 cell 高度。
- 最后,让 table view 以动画方式将用户所点击的 cell 滑动到 table view 的顶端。
然后是 tableView(_:cellForRowAtIndexPath:)
方法,在方法最后 return 之前加入:
cell.moreInfoTextView.text = work.isExpanded ? work.info : moreInfoText
cell.moreInfoTextView.textAlignment = work.isExpanded ? NSTextAlignment.Left : NSTextAlignment.Center
上述代码让重用单元格正确显示展开或缩起状态。
编译运行 app。当点击某个作品,你会看到它将展开成完整信息显示。但图片的显示不正确。
这个很好搞定!打开 Main.storyboard,选择你的 WorkTableViewCell 中的 image view,打开 Size 面板。修改 content Hugging Priority 和 Content Compression Resistance Priority 为如下值:
将 Vertical Content Hugging Priority 设置为 252,是为了让 image view 固定其内容,并在动画过程中不进行拉伸。设置
Vertical Compression Resistance Priority 为 749,是允许图片在周围元素变大时能够被压缩。这只是为了让 cell 执行展开动画时更加平滑。图片并不会被压缩,因为当 cell 内部的东东长高时 cell 的高度也会随之长高。
运行 app,选择某位画家,然后点击某个作品。你会看到 cell 的展开变得非常平滑,并显示出每个作品的详细介绍:
好极了!
动态字体
将成果拿给用户看吧,他们一定爱死了!但他们最后又提了一个需求。他们想让 app 支持 Larger Text Accessibility 特性。
在 iOS 7 中引入了动态字体,这让这个工作变得轻松。动态字体允许开发者为不同的文本块(比如标题和中文)指定不同的文本风格,同时可以让文本根据用户在设置中指定的大小进行显示。
在 ArtistListViewController.swift 的 tableView(_:cellForRowAtIndexPath:) 方法返回之前加入:
cell.nameLabel.font = UIFont.preferredFontForTextStyle(UIFontTextStyleHeadline)
cell.bioLabel.font = UIFont.preferredFontForTextStyle(UIFontTextStyleBody)
这里使用动态字体来设置 UI 元素上的文本。preferredFontForTextStyle(style:) 只有一个参数,就是你想在这个文本元素用什么风格来进行显示。你可以用 10 种不同的常量,请参考苹果关于 preferredFontForTextStyle(style:) 的文档。
然后你需要在用户修改了字体大小偏好的时候刷新 table view。在 ArtistListViewController 的 viewDidLoad() 后添加方法:
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)
NSNotificationCenter.defaultCenter().addObserverForName(UIContentSizeCategoryDidChangeNotification, object: nil, queue: NSOperationQueue.mainQueue()) { [weak self] _ in self?.tableView.reloadData()
}
}
这里注册了 onContentSizeCategoryChange: 通知的观察者,当用户修改了字体大小偏好之后会发送这个通知。
这个观察者使用了一个闭包来通知 table view 进行刷新。这会针对屏幕上所有显示的 cell 调用 tableView(_:cellForRowAtIndexPath:)
方法。在这个方法中又会调用 preferredFontForTextStyle(style:) 方法。现在字体会在收到通知时发生改变。
注意:从 iOS 9 开始,从通知中心移除观察者不再是必须的了。但如果你的 app 部署目标是 iOS 8,你仍然需要移除观察者!
在 ArtistDetailViewController 中增加 动态字体 支持于此类型。打开 ArtistDetailViewController.swift 然后在 tableView(_:cellForRowAtIndexPath:)
方法最后添加:
cell.workTitleLabel.font = UIFont.preferredFontForTextStyle(UIFontTextStyleHeadline)
cell.moreInfoTextView.font = UIFont.preferredFontForTextStyle(UIFontTextStyleFootnote)
然后在 viewDidAppear(_:) 中和前面一样加入同样的代码。
在 iOS 9.3 模拟器中进行测试是无效的,你必须在设备上进行测试。在设备上运行 app,切换到 Home 屏。打开设置 app,依次点击 通用 > 辅助功能 > 更大字体,向右边拖动滑动条将字体变大:
然后回到 app,你的文本将变得更大了。幸好你使用了自适应单元格,table view 仍然显示正常:
结束语
祝贺你,这个自适应 table view cell 的教程到此结束了!:]
你可以从 这里 下载完整项目。
Table view 大概是 iOS 中最基本的结构化数据视图。随着 app 越来越复杂,你可能用过各种各样的自定义单元格布局。幸运的是,子宫布局和 iOS 8 使这一切变得简单。
如果你有任何问题和建议,请留言。