UITableView 教程:动态 Table View Cell 高

旧翻译文章迁移自:简书 create by 2016.03.11 00:33

原文地址: RAYWENDERLICH

说明:英文水平有限,主要是为了巩固学到的知识,也能帮别人快速上手,节约时间,有任何破绽,尤其是技术上的,请您一定要告诉我。


学习怎样在iOS8上使用Swift创建动态Cell高
旁白:其实这张图几乎说明了所有问题,设置好constraints,其它都不是问题。补充一下:这篇教程实际上就是 auto layout教程。)

你应该在使用 table view cells 的时候经常会写很多代码,来手动计算label,image view,text field 和 cell 相关的每一个控件的高。
坦白讲,这种方法很容易出错且容易让人迷糊看不懂。
在这篇教程里,你将学会怎样创建自定义 cell 并且根据内容动态调整 cell 的高度,你可能会想,”这得需要多少工作量…!”
Nope! (旁白:you are right ~
你很幸运,Apple 可以让你在 iOS8 里很容易的做到这些。你将从写适配代码中解脱出来。但是还是要实现 table view 的数据源和代理方法。

让我们开始

iOS6出来几天之后,Apple 介绍了一个超赞的技术:auto layout。程序猿们开始庆祝,街头聚会,写赞歌…(旁白:要不要这么夸张啊

好吧,也许有些问题,但它毕竟是一次大飞跃(旁白:是这个意思吧,哈哈

在给了很多开发者希望的同时,auto layout 还是很难用的。尤其是手写 auto layout 代码。Interface Builder 在设置 constraints 时也并不理想。

很快到现在,伴随着对 Interface Builder 的所有改进和 iOS8 的到来,我们现在可以很容易的动态设置 table view cells 的高度了。

你需要不得不做的事情有:
1,在创建 table view cells 时使用 auto layout。
2,设置 table view 的rowHeight等于UITableViewAutomaticDimension
3,设置estimatedRowHeight的值或者实现预估高度的代理方法。

这是你需要知道的几个要点,现在开始下载代码,搞起项目了。
旁白:确实很重要,虽然说了不少废话,但是并不是浪费时间的。

教程 App 概览

设想一下你的老大来到你面前,对你说:“我们的用户在为看Deviant Artists的方法而大声抗议”。
我会问:”什么是Deviant Artists“。
你的头解释说:“那是一个艺术家们用来分享自己作品的社交平台。你可以通过 Deviant Art websiteMedia RSS endpoint 来了解艺术家的公告和动态。”
老大:“我们开始做这个 App 吧,但是要怎么样把内容显示到表格上呢?你能做到吧?”
你突然受到了感召,走进最近的电话亭,换上了披风成了super Dev..

但你不需要弄骗人的把戏做你老大的英雄,用你的编程技术就可以做到了。
旁白:你怎么不去做导演啊…
首先,现在客户端代码(项目的起始程序)这里
旁白:语法有一些过时,打开项目会自动让你转换到最新的语法,转换完之后会报一个错,将FeedViewController.swift里的**deselectAllRows 方法替换成如下代码:

1
2
3
4
5
6
7
func deselectAllRows() {
if let selectedRows = tableView.indexPathsForSelectedRows{
for indexPath in selectedRows {
tableView.deselectRowAtIndexPath(indexPath, animated: false)
}
}
}


这个项目使用的CocoaPods,因此打开DeviantArtBrowser.xcworkspace(不是xcodeproj这个文件),pods 已经包含到 zip 包里了,所以不用在重新pod install
注意:如果你不清楚什么是 CocoaPods ,可以看下这个教程

打开Main.storyboard(在DeviantArtBrowserproject 下的DeviantArtBrowser文件夹Views分组里),你将会看到下面四个场景:

从左到右,它们是:

  • 顶级的导航控制器。
    *FeedViewController,标题是Deviant Browser
  • 还有两个都是DetailViewController的场景,标题分别是Deviant ArticleDeviant Media,一个只用来显示文本,另一个文本和图片一起显示。
    编译并运行,你将看到一些控制台上的输出日志和一个短暂出现的活动指示器,但是在 app 里并没有什么内容显示出来。
    日志输出像下面一样:
1
2
3
4

2014-11-08 14:30:02.746 DeviantArtBrowser[70847:829282] GET 'http://backend.deviantart.com/rss.xml?q=boost%3Apopular'

2014-11-08 14:30:03.297 DeviantArtBrowser[70847:829282] 200 'http://backend.deviantart.com/rss.xml?q=boost%3Apopular' [0.5506 s]

app 发起一个网络请求并获得返回,但是并没有做任何事。
旁白:如果请求失败,很可能是iOS9系统下不可用http协议,打开info.plist的源码,粘贴如下内容。

1
2
3
4
5
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>

现在,打开FeedViewController.swift(在Controllers文件夹的下面)。
看下parseForQuery这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func parseForQuery(query: String?) {
showProgressHUD()

parser.parseRSSFeed(deviantArtBaseUrlString,
parameters: parametersForQuery(query),
success: {(let channel: RSSChannel!) -> Void in

self.convertItemPropertiesToPlainText(channel.items as! [RSSItem])
self.items = (channel.items as! [RSSItem])

self.hideProgressHUD()
self.reloadTableViewContent()

}, failure: {(let error:NSError!) -> Void in

self.hideProgressHUD()
println("Error: \(error)")
})
}

parser是一个RSSParser的实例,属于 MediaRSSParser 的一部分。
这是一个得到Deviant ArtRSS feed 的网络请求,它会在成功的 block 里返回一个RSSChannel实例。然后解析数据将 HTML 转成普通文本,channel.items和控制器里的items属性都是数组。

channel.items数组包含RSSItem对象,每一个对象元素都是一个 RSS feed。(现在你该知道要将什么显示到表格里了吧,正是items数组!)

最后,项目里会有一些//TODO:Write this…的注释,是为了告诉我们需要实现些什么。

开始创建自定义 Cell

查看源代码之后,你现在知道这个 app 有了不错的数据,但是什么都没有显示出来,要显示它们,你需要创建一个自定义的 table view cell。

  • 添加一个新类到DeviantArtBrowser项目里。
  • 名字是BasicCell并且继承自UITableViewCell
  • 确信Also create xib file没有被勾选。
  • 语言选择Swift
    打开BasicCell.swift并添加如下属性:
1
2
@IBOutlet var titleLabel: UILabel!
@IBOutlet var subtitleLabel: UILabel!

接下来,打开Main.storyboard,拖拽一个UITableViewCell到FeedViewController的 table view上。
设置
BasicCellCustom Class

设置
BasicCellIdentifier(Reuse Identifier)

设置 cell 的
Row Height83

拖拽一个 UILabel 到 cell 上,设置 text为
Title

设置 label 的
Lines为 0,就是没有上限。(行数可以设置很多)

接下来像下面截图一样设置 label 的尺寸和位置。

连接 cell 的 label 到
BasicCelltitleLabeloutlet 上。

接下来,拖拽第二个 UILabel 到 cell 上,像第一个 label 一样,并且设置 text 为
Subtitle

像第一个 label 一样,按照下面的截图来设置 Subtitle 的尺寸和位置。

设置 subtitle label 的
Color Light Gray Color ;字体大小为 15.0 ;并且Lines**为 0 ;

将 cell 的 subtitle label 连接到 BasicCell 上的 subtitleLabel outlet上。

接下来,你将给BasicCell添加 auto layoutconstraints,来布局 cell 。
注意:如果你对 auto layout 还不太熟悉,不清楚怎么设置 auto layout constraints ,可以看下 这个教程

选择 title label 且设置它的toptrailingleading距离父视图(也就是content view)20个点。确信你没有勾选Constrain to margins。(旁白:这个属性就是系统会为你默认两边留白)
图片显示的很清楚
确信 cell 的 title label 一直是:

  • 向上距离20个点。
  • 相对于 content view 的整体宽度,左右两边空出20个点。

现在,选择 subtitle label 设置它的leadingtrailing,和bottom距离父视图20个点。再次确认,没有勾选Constrain to margins

像 title label 一样,确定在 subtitle label 上的constraints,是按照底部距离 content view 20个点,左右距离 content view 也是20个点。
旁白:还是看图更直观一些)

技巧: 使用 auto layout 布局UITableViewCell时,要确定这些约束都布局到了每一个 subview 的四边,也就是说,每一个 subview 都应该有 leading,top,trailing 和 bottom 约束。
除此之外,contentView的顶部到底部都要有清晰的约束条件, 你要能确定,这些子视图的约束可以正确的指定出contentView的高度。
另一部分技巧是,interface Builder 经常在你缺失一些约束的情况下,没有警告提示。在运行项目时,auto layout 没有返回正确的高度,比如会返回 0 的高度,遇到这些问题需要你重新调整约束条件 直到满足条件为止。

现在, 选择subtitlelabel, 按住Control并拖拽到 title label。选择Vertical Spacing连接subtitle label的顶部和title label的底部。

在 title label 上,设置HorizontalVerticalContent Hugging PriorityContent Compression Resistance Priority751

在 subtitle label 上,设置HorizontalVerticalContent Hugging PriorityContent Compression Resistance Priority750

旁白:解释一下,这两个优先级的意思,Content Hugging Priority就是级别越高,越不会被拉开,抻开。Content Compression Resistance Priority就是级别越高,越不会被压缩,挤掉。这个还是要看具体的例子来理解的,有一点绕。)
这就是告诉 auto layout 怎样去适配 labels 的文本-区分 title label 和 subtitle label 之间约束的优先级。在这个例子里,这些约束基本满足了条件。

检查: 上面的约束条件满足情况了吗?
1,每一个子视图的所有侧边都有约束吗?Yes。
2,contentView从上到下都有约束吗?Yes(旁白:只有这样 contentView 才能确定出自己的高度,像 UIScrollView 一样,才能知道自己的 contentSize,否则运行起来都不能滑动。)
titleLabel距顶部有20个点,它和subtitleLabel之间的距离是4个点,并且subtitleLabel和底部有19.5个点。
所以,现在 auto layout 已经可以动态设置 cell 的高度了。

接下来,你要创建一个BasiceCell跳转到Deviant Article场景的链接(segue)。

选择你的BasiceCell,按住 control 拖拽到Deviant Article场景,从Selection Segue选项中选择Push

Interface Builder 将自动更改Accessory属性为 Disclosure Indicator,这是为了指示你从 cell 导航到详情里去。然而,这并不符合程序的设计,选择BasiceCell ,更改AccessoryNone

现在,用户在任何时候点击BasicCell都会跳转到DetailViewController里了。

哇塞,你的BasicCell已经设置完了!如果你编译运行 app 的话,还是毛都看不到,为啥呢…(旁白:自言自语..)

还记得哪些TODO的注释吗?,对了,这就是问题所在了。你需要到哪些 TODOs 里写一些代码。

配置 Table View

首先,你需要配置下这个 table view。
打开FeedViewController.swift,用下面的代码替换configureTableView()这个方法。

1
2
3
4
func configureTableView() {
tableView.rowHeight = UITableViewAutomaticDimension
tableView.estimatedRowHeight = 160.0
}

确信 table view 是用 auto layout 来动态设置高的时候,将rowHeight设置为UITableViewAutomaticDimension
确定这些之后,你还要提供一个estimatedRowHeight的值。小意思,160.0是一个随意的值也可以使用。在你的项目里,你可能也想根据数据类型设置一个更好的值。

实现 UITableView 的 Data Source

接下来,你需要实现UITableViewDataSource的协议方法。
首先,在FeedViewController里加上这个常量:

1
let basicCellIdentifier = "BasicCell"

这可以让你用这个标识在 storyboard 里取得BasicCell
接下来,用tableView(_:numberOfRowsInSection:)返回从 Deviant Art 获取到数据个数:

1
2
3
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return items.count
}

然后用,下面的代码替换tableView(_:cellForRowAtIndexPath:)这个方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
return basicCellAtIndexPath(indexPath)
}

func basicCellAtIndexPath(indexPath:NSIndexPath) -> BasicCell {
let cell = tableView.dequeueReusableCellWithIdentifier(basicCellIdentifier) as! BasicCell
setTitleForCell(cell, indexPath: indexPath)
setSubtitleForCell(cell, indexPath: indexPath)
return cell
}

func setTitleForCell(cell:BasicCell, indexPath:NSIndexPath) {
let item = items[indexPath.row] as RSSItem
cell.titleLabel.text = item.title ?? "[No Title]"
}

func setSubtitleForCell(cell:BasicCell, indexPath:NSIndexPath) {
let item = items[indexPath.row] as RSSItem
var subtitle: NSString? = item.mediaText ?? item.mediaDescription

if let subtitle = subtitle {

// Some subtitles are really long, so only display the first 200 characters
if subtitle.length > 200 {
cell.subtitleLabel.text = "\(subtitle.substringToIndex(200))..."

} else {
cell.subtitleLabel.text = subtitle as String
}

} else {
cell.subtitleLabel.text = ""
}
}

这边发生什么:

  • 在 tableView(:cellForRowAtIndexPath:) 里,调用basicCellAtIndexPath(:) 方法获取一个BasicCell
  • 在basicCellAtIndexPath(:)里获取一个BasicCell,使用setTitleForCell(:indexPath:) 方法设置 title label 的text,使用 setSubtitleForCell(_:indexPath:)方法设置 subtitle label 的text,然后return 这个 cell。

现在,你需要实现… 等等,搞定了!就这么简单?
编译运行,你将看到这个表格:

图片在哪呢?

这个app看起来还不错,但是好像感觉缺了点什么?
噢,艺术在哪呢?

Deviant Art 上都是图片,但这个 app 没有显示它们,你需要去修复下这个问题,不然,你的老大会让你失去理智!

有个方法是在你的BasicCell上加一个 image view。

但是在 Deviant Art 上带图片的信息和纯文字的信息都有,所以更好的做法是新建一个自定义 cell。

增加一个继承自BasicCell的新类到项目里,名字叫ImageCell,原因是你的新 cell 也需要titleLabelsubtitleLabel。因此,有必要在基类里已经存在一些方法的时候再做所有的事吗?

打开ImageCell.swift并且增加下面的属性:

1
@IBOutlet var customImageView: UIImageView!

这个属性的名字是customImageView,因为在 UITableViewCell 里已经有了一个叫ImageView的属性了。

打开Main.storyboard,选择 basic cell 使用⌘C,或者从菜单里选择Edit > Copy

选择这个 table view 并且 按下⌘V,或者Edit > Paste,去创建一个 cell 的新 copy 。

注意:如果你操作有误,没有得到想要的结果,记得使用⌘Z或者Edit > Undo来撤销操作。

选择新 cell ,更改它的Custom ClassImageCell。同样的,更改它的Reuse IdentifierImageCell

ImageCell上选择 title label,更改它的位置 x 为128,且宽度为172。subtitle label 也是一样。

Interface Builder 将会有一些警告,因为这些 labels 摆放的位置和约束设置的不一样。

正确的做法是,选择ImageCell上的 title label 删掉leading这条约束,subtitle label 也是一样删掉leading约束。

现在选择ImageCell的 title label,按照下面的截图改变它的Intrinsic Size Placeholder 的值。同样的,改变 subtitle label 的Intrinsic Size
(这是用来告诉 Interface Builder 去更新当前 view 的 frame 的占位符,Interface Builder 将不会显示警告了。)

你需要在 cell 上增加一个 image view。但是现在的高度有一点小,所以选择ImageCell修改它的Row Height141

现在,拖拽一个 image view 到ImageCell上,按照下面的截图设置这个新 view 的位置和尺寸。

接下来,选择这个 image view 做如下布局:

  • 设置leadingtopbottom20
  • 设置widthheight100
  • 确认Constrain to margins没有被勾选
  • 最后点击Add 5 constrain的按钮。

选择image view显示它的所有约束,然后选择它的bottom约束来编辑它。在属性编辑器里,改变RelationGreater Than or Equal它的Priority999

同样的,选择 subtitle label 去显示它的所有约束条件,然后选择它的bottom,在属性编辑器里,改变RelationGreater Than or Equal它的Priority1000

这是告诉 auto layout 在imageViewsubtitleLabel向下的约束都为20个点的时候,打破imageView的,遵循subtitleLabel的。(旁白:因为它的优先级高,这样避免约束冲突。)

然后将 image view 的heightwidth约束的优先级Priority设置成999
这是因为自定义的约束有时候会和系统定义的约束之间会产生冲突,这时就是告诉 auto layout ,“如果一定要这么做,就打破这些自定义的约束吧”
旁白:系统定义的优先级高,1000。)

在大部分情况下,auto layout 都会满足这些约束。在极少数的情况下它会打破这些约束,比如在改变设备方向时,但通常都是1-2的像素偏差,不明显。

提示: 尤其在 table view cell上, auto layout 不总是很明显的提示这些约束。
如果你在控制台上看到这些 auto layout 不得不打破一个约束的警告信息,你就要试着去调整下你的约束条件的priorities了。

最后,选择ImageCell上的 title label 使用 Pin Button 来设置下leading约束为8。subtitle label 也是一样的。

现在你的ImageCell上的约束看起来是这个样子的:

你需要选择ImageCell和 image view 的customImageViewoutlet 进行链接。

你需要一个ImageCell跳转到Deviant Media场景的链接(segue),
这样用户点击ImageCell就可以看到详情了。

像之前设置 basic cell 一样, 选择ImageCell,按住 control 并拖拽到Deviant Media场景,然后在Selection Segue选项中选择Push

确认你的Accessory改回为None

非常好,你的ImageCell已经设置完成了!现在你可以加一些代码让它显示出来了。

显示这些图片!

打开FeedViewController.swift在上面增加一个常量:

1
let imageCellIdentifier = "ImageCell"

接下来替换tableView(_:cellForRowAtIndexPath:)的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
if hasImageAtIndexPath(indexPath) {
return imageCellAtIndexPath(indexPath)

} else {
return basicCellAtIndexPath(indexPath)
}
}

func hasImageAtIndexPath(indexPath:NSIndexPath) -> Bool {
let item = items[indexPath.row]
let mediaThumbnailArray = item.mediaThumbnails as! [RSSMediaThumbnail]

for mediaThumbnail in mediaThumbnailArray {
if mediaThumbnail.url != nil {
return true
}
}

return false
}

func imageCellAtIndexPath(indexPath:NSIndexPath) -> ImageCell {
let cell = self.tableView.dequeueReusableCellWithIdentifier(imageCellIdentifier) as! ImageCell
setImageForCell(cell, indexPath: indexPath)
setTitleForCell(cell, indexPath: indexPath)
setSubtitleForCell(cell, indexPath: indexPath)
return cell
}

func setImageForCell(cell:ImageCell, indexPath:NSIndexPath) {
let item: RSSItem = items[indexPath.row]

// mediaThumbnails are generally ordered by size,
// so get the second mediaThumbnail, which is a
// "medium" sized image

var mediaThumbnail: RSSMediaThumbnail?

if item.mediaThumbnails.count >= 2 {
mediaThumbnail = item.mediaThumbnails[1] as? RSSMediaThumbnail

} else {
mediaThumbnail = (item.mediaThumbnails as NSArray).firstObject as? RSSMediaThumbnail
}

cell.customImageView.image = nil

if let url = mediaThumbnail?.url {
cell.customImageView.setImageWithURL(url)
}
}

像上面创建BasicCell一样,但是有一点不同,有一些新的代码:

  • hasImageAtIndexPath(_:)检查IndexPath下的item的mediaThumbnail的 url 不为空。如果不为空的要使用ImageCell来展示数据。
  • imageCellAtIndexPath(_:)basicCellAtIndexPath(_:)一样,但是它要用 setImageForCell(_:indexPath:)**来设置下图片。
  • setImageForCell(_:indexPath:)尝试获取第二个 media thumbnail。使用 AFNetworking提供的setImageWithURL(_:)方法来获取图片。

编译运行,你将看到漂亮的艺术图片!默认,app 检索 “popular“ 分类的数据,但你也能按照艺术家搜索。

试着输入by:CheshireCatAr t并搜索。这是我的一个朋友,Devin Kraft,他是一个杰出的画家。(查看他的website)。

这是我很喜欢的一位,他在 Deviant Art 上很活跃,发过作品和博客。因此,他的账号是个很好的测试账号,可以测试带图片的 cell 和不带图片的 cell。

这个 app 已经看起来很漂亮了,但是你还可以让你的老大对你的技能信心提升一到两个级别。

优化表格

还记得很早之前设置estimatedRowHeight160.0的时候吗?这个属性是在BasicCell的竖屏方向上工作的。但是这个值非常的不准。

你可以使用UITableViewDelegate提供的,在运行时给 cell 估算高度的方法替换这个值。

改之前,你需要删除configureTableView():里的一行代码:

1
tableView.estimatedRowHeight = 160.0

现在,在// MARK: UITextFieldDelegate组下增加下面的方法。(实际上,你可以在这个类的任何地方增加它们,但这样结构比较清楚易读。)

1
2
3
4
5
6
7
8
9
10
11
12
13
// MARK: UITableViewDelegate

func tableView(tableView: UITableView, estimatedHeightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
if isLandscapeOrientation() {
return hasImageAtIndexPath(indexPath) ? 140.0 : 120.0
} else {
return hasImageAtIndexPath(indexPath) ? 235.0 : 155.0
}
}

func isLandscapeOrientation() -> Bool {
return UIInterfaceOrientationIsLandscape(UIApplication.sharedApplication().statusBarOrientation)
}

这个预估 cell 高的方法很简单。检查当前方向是否是横屏并且如果当前 index path 有图片的话,返回一个预先裁定的值。

提示:不论你是实现的代理方法,还是简单的设置了一个estimatedRowHeight固定的值。
这个 table view 会在代理方法和estimatedRowHeight的值之中选择一个。它会影响到滑动的指示条和滑动的性能。(是否卡顿)
如果你的 cell 预估的高度不太正确,那么在滑动的时候就会卡顿,滑动指示条不太准,内容也会混乱。
如果你的预估是准确的,计算就会慢,table view 滚动也会变慢。
这个成功的关键就在于在准确和不准确之间找一个平衡,减少不必要的计算成本。
旁白:难道自己慢慢调试这个数值吗?)

你还是使用固定的值来估算的高度,但是现在可以根据 cell 的类型和设备的方向来设置更合理的高度了。你可以设置自己感兴趣的值,但是记住诀窍就是能让它计算的更快就行。

编译并运行,你应该能看到 table view 滑动的很流畅,看起来很棒。

这就是方法的最后实现,这个 app 现在完成了!

从这去哪呢

你可以去下载完成项目,在这
旁白:上面的路径下载下来的项目编译会报错,因为它是用Xcode6.3和Swift1.2做的,转换到最新语法之后,在像上面已经提到过的修改一个方法的代码,添加http白名单。你也可以下载这里已经修改过的代码

Table views 可能是iOS里面组织数据视图中最常用的了。你的 apps 会很复杂,你可能要使用各种类型的自定义 cell 来布局。幸运的是 iOS8 和 auto layout 能很容易的做到这些。
旁白:不幸的是,有多少 app 是只支持iOS8以上的呢。)

如果你有一些问题或建议,请在下面留言。