顾名思义,一个展开式 UITableView 是这样一种表视图,它“允许”其单元格(cell)展开或者收起,显示或者隐藏,而在一般的表视图中,它们的单元格只能是显示的状态。当我们需要收集一些简单的数据或者根据用户的意愿显示/隐藏某些内容时,创建展开式 UITableView 是一种不错的选择。这样,我们就没有必要仅仅为了让用户输入一些数据就创建新的 View Controller,无论如何我们都只需要呆在同一个 View Controller 裡面,即当前的 View Controller 中。例如,通过展开式的 cell,我们显示或隐藏一个用于给用户输入信息的表单,在显示或隐藏这个表单时,根本不需要离开当前的 View Controller。
是否採用或者不採用展开式的 UITableView,完全取决于 App 的性质。但是,只要是通过子类化UITableViewCell
和自定义xib
文件的方式来定制 cell 的情况,App 的外观就不会是什麽问题。因此,归根结底,这只是一个需求问题。
在本教学中,我将演示一种创建展开式 UITableView 的简单有效的方法。注意,这并不是唯一方法。最好的方法要视 App 的需要而定,我的目的只是展示一种一般化的解决方案,它在大部份情形下都是适用的。因此,请进入下一部份,看看本教程最终将实现什麽样的效果。
示例 App
我们将创建一个只有一个 View Controller (其包含有一个 TableView)的 App,在这个 App 中我们将演示如何创建一个展开式表视图。我们将模拟一个允许用户输入的表单,为了演示,这个 TableView 将由 3 个 section 构成:
Personal
Preferences
Work Experience
每个 section 都会包含展开式 cell,这些 cell 会隐藏/显示该 section 的其它 cell。尤其是位于 section 顶部的 cell (该 cell 能够展开或收起):
对于 “Personal” section:
Full name: 这个 cell 用于显示用户的全名,当它处于展开状态时,它下面会多出两个子 cell,分别用于输入名和姓。
Date of birth: 用于显示用户的生日。当它被展开后,会显示一个日期选择器(UIDatePicker),允许用户选择某个日期并提供一个按钮将用户选择的日期返回给它上面的 cell。
Marital status: 显示用户的婚姻状态:已婚或单身。当它被展开后,会显示一个开关控件,允许用户设置他们的婚姻状态。
对于“Preferences”section:
Favorite sport: 我们模拟了一个运动种类列表,用于提供给用户,让他们从中选择他们所喜爱的运动。当它被展开时,会列出 4 个运动种类,当用户选择某个子项,这个 cell 又自动会被收起。
Favorite color: 和上面非常相似,只不过这裡显示了一个颜色列表供用户选择。
对于“Work Experience”section:
Level: 当这个 cell 被点击并展开后,将显示另一个包含有一个滑动条的 cell,允许用户设置他们的工作经验等级。这个级别用一个 0…10 之间的数字表示,我们只取这个数值的整数部份。
通过下面的动画会看得更清楚一些:
注意上面的例子,当我们展开 TableView 时会显示不同类型的 cell。这些 cell 都被包含在开始项目中了,你可以下载这些代码,项目已经完成了一些前期的准备工作。所有的 cell 都在单独的xib
文件进行了必要的设计,同时它们的 Custom Class 也被指定为自定义的 UITableViewCell
子类(即 CustomCell
):
在项目文件夹中,你将发现这些 cell 所使用的 xib
文件,包括:
它们作用分别如其名称所示,你也可以下载开始项目深入探究一番。
除了 cell,你还会发现一些已经写好的代码。虽然这些代码对于实现整个示例 App 的功能来说是必不可少的,但却不属于本教程的核心内容,因此我会跳过这些代码,仅仅是以现成的代码提供在开始项目中。缺失的其馀代码是本教程中我们最关心的内容,在接下来的教程中会以 step-by-step 的方式添加到项目中。
到此,你已经知道我们最后的目标是什麽了,接下来就让我们开始学习如何创建展开式的 UITableView。
描述单元格
我将在本教程中演示的、所有与展开式 UITableView 相关的实现和技术,都基于这样一种简单的思路:向 App 描述每个 cell 的细节。通过这种方式我们让 App 知道每一个 cell 到底是展开的还是收起的,是可见的还是隐藏的,每个 cell 的文字标籤显示什麽内容,等等。实际上,整体思路都基于将属性集进行编组,这些属性要麽描述了每个 cell 的属性,要麽包含了 cell 的某些数值,然后将这些属性告诉给 App,这样 App 才能正确地显示它们。
在本示例程序中,我创建和使用了一个属性集合,如下面所列。注意在真正的 App 中,你可能需要增加新的属性,或者对某些属性进行修改。不过,此时你只需要了解大致的情况就可以了。当然,只要你愿意你可以任意修改这些属性。我们所使用的属性列表(plist)是这样的:
isExpandable: 一个布尔值,标明 cell 是否能够展开或收起。在本教程中,这是我们非常关心的重要属性。
isExpanded: 一个布尔值,标明一个展开式 cell 是处于展开状态还是收起状态。顶层的 cell 默认是收起状态,因此这个值一开始都应该设成 NO。
isVisible: 顾名思义,标明这个 cell 是否应该显示到表格中。稍后这个属性会扮演一个重要的角色,因为我们会根据这个属性让表格中的某些 cell 得到显示。
value: 这个属性用于保存 UI 控件的值(例如,开关控件中的婚姻状态)。不是所有 cell 都会有这样的控件,因此大部份 cell 的这个属性值将保留为空。
primaryTitle: cell 主标题的显示文本,当这个属性不为空时,这个属性的值会显示到 cell 上。
secondaryTitle: cell 子标题的显示文本,或者 cell 第二个标籤的显示文本。
cellIdentifier: 自定义 cell 的
ID
,用于唯一识别当前 cell 的描述。这个 ID 不仅被 App 用于从缓存队列中弹出合适的 cell,而且还要根据这个 ID 对要显示的 cell 进行相应的处理并指定 cell 的高度。additionalRows: 用于表示当一个展开式单元格被展开时,它下面包含了几个附属的 cell。
每个 cell 都会用上面的属性集进行描述。从 App 的角度,我们使用一个属性列表(plist)文件来保存它们会更加轻鬆。在这个plist
文件中,我们会为每个 cell 使用一个上述属性集来进行描述,并适当地填充属性集中的属性值,这样,我们将最终获得所有 cell 的一个完整的描述,这个描述对于我们或 App 来说都很容易理解。同时我们并没有为之编写一行代码。很不错吧?
现在,我们在项目中新建一个 plist 文件,然后用适当的数据来填充它。当然你也可以从这裡下载现成的.plist 文件
。下载后记得将它添加到我们的开始项目中。手动设置所有 cell 的属性会佔用大量空间,这是完全没有必要的,同时拷贝-粘贴或者输入所有属性值也是一件很繁琐的事情。
然后,让我们来讨论一下这个 plist 文件:
首先,你下载的这个文件的文件名叫做CellDescriptor.plist
。它的根节点(root)是一个数组
,其中的每个元素表示表格中的一个 section。也就是说这个plist
文件的 root 数组中有三个元素,就跟我们想在表格中显示的 section 的数目一样。
每个 section 本身也是一个数组,数组中包含了该 section 中所包含的所有 cell 的描述。实际上,这些编组的属性集在这裡用字典来进行表示,每个字典代表了一个单独的 cell。这是一个 plist 文件的例子:
现在,是时候来完整地回顾一下我们将要显示到表格中的 cell 的属性集和属性值了。很显然,拥有了这些 cell 描述之后,我们需要编写用于生成、管理 cell 的代码大大减少了。我们也不需要告诉 App 这些 cell 的各种状态(例如,哪个单元格是可展开的,App 应当让某个 cell 展开或收起,判断某个 cell 是可见的还是隐藏的等等)。所有的这些信息都包含在你下载的 plist 文件裡面。
加载单元格描述
终于可以编写代码了,虽然我们使用的单元格描述技术为我们节省了许多时间,但在这个项目中我们仍然免不了要编写代码。现在,我们已经有了用于描述 cell的 plist 文件了,接下来的事情自然是用代码将文件内容加载到一个数组对象中。这个数组对象将在后面充当表格的数据源。
打开开始项目中的 ViewController.swift
文件,在类的顶部声明如下属性:
var cellDescriptors: NSMutableArray!
这个数组将用于包含所有来自于 plist 文件中的用于描述每个 cell的字典。
然后,新增一个方法,用于将文件内容加载到数组对象。我们将这个方法命名为 loadCellDescriptors():
func loadCellDescriptors() {
if let path = NSBundle.mainBundle().pathForResource("CellDescriptor", ofType: "plist") {
cellDescriptors = NSMutableArray(contentsOfFile: path)
}
}
这个方法非常简单:首先我们我们判断指定的 plist 文件路径在 bundle 中是否存在,如果存在我们从文件中加载一个数组并初始化 cellDescriptors 变量。
接下来就是调用这个方法,我们将在 TableView 已经配置好,并且视图即将显示之前(即在 TableView 已经创建并且还没有显示任何内容之前)调用这个方法:
override func viewWillAppear(animated: Bool) {
super.viewWillAppear(animated)
configureTableView()
loadCellDescriptors()
}
如果在上述方法最后一行后添加 print(cellDescriptors)
一句,则运行 App 后你将看见 plist 文件的内容输出到了控制台中。这就说明文件已经成功加载到内存了。
通常,本节的内容应该到此结束,但这次有一点例外。我们还要补充一些对于下一节来说至关重要的内容。也许你想到了(尤其是当你检查了CellDescriptor.plist
文件之后),当 App 启动后,并不是所有的 cell 都应该被显示。事实上,我们根本无法得知它们是否会在同时显示,因为它们是根据用户的要求来进行展开和收起的。
从编程的角度,这意味著 每个 cell 的行索引不应该是常量 (这就是为什麽我们在处理每个 cell 时,将 indexPath.row
用代码来生成)。同时,我们也不能用 cell 的行索引来遍历数据源数组并显示每个 cell。我们只能将可见的 cell 的行索引来提供给 App。如果将 cell 描述中标记为不可见的 cell 显示出来,这就大错特错了,那会导致 App 表现异常。
基于这样的原因,我们需要实现一个新方法,叫做 getIndicesOfVisibleRows()
。这个方法的作用是显而易见的:它只返回那些标记为可见的 cell 的行索引。在实现这个方法之前,请在类的顶部增加如下属性:
var visibleRowsPerSection = [[Int]]()
这是一个二维数组,保存了所有 section 的可见的 cell 的行索引(一维用于表示 section,一维用于表示 cell)。
现在来实现这个方法。你也许想到了,我们会遍历所有 cell 描述并将 isVisible 属性为 true
的 cell 的行索引添加到二维数组中。当然,我们不得不用到嵌套循环,但这也不是什麽大问题。下面是这个方法的实现:
func getIndicesOfVisibleRows() {
visibleRowsPerSection.removeAll()
for currentSectionCells in cellDescriptors {
var visibleRows = [Int]()
for row in 0...((currentSectionCells as! [[String: AnyObject]]).count - 1) {
if currentSectionCells[row]["isVisible"] as! Bool == true {
visibleRows.append(row)
}
}
visibleRowsPerSection.append(visibleRows)
}
}
注意,方法的一开始就将 visibleRowsPerSection
数组的内容清空了,否则连续多次调用这个方法之后数据就不正常了。接下来的实现就一目了然了,就不用我再多说了。
这个函数的第一次调用应该在从 plist 文件加载完 cell 描述之后(我们还会在后面多次调用这个函数)。因此,回到本节实现的第一个方法,将它修改为:
func loadCellDescriptors() {
if let path = NSBundle.mainBundle().pathForResource("CellDescriptor", ofType: "plist") {
cellDescriptors = NSMutableArray(contentsOfFile: path)
getIndicesOfVisibleRows()
tblExpandable.reloadData()
}
}
虽然 TableView 还不能正常工作,但我们已经在 App 一启动的时候就调用了它的刷新动作,这样就能保证在接下来的的步骤中显示正确的 cell。
显示单元格
每当 App 一启动,cell 描述就会被加载,接下来我们应当处理和显示表格中的 cell。一开始,我们需要创建一个新的方法,用于在 cellDescriptors
数组中查找并返回指定 cell 的单元格描述。正如下面的代码所示,这个方法能够正常工作的前提,是你已经拥有一个填充好数据的 visibleRowsPerSection
数组。
func getCellDescriptorForIndexPath(indexPath: NSIndexPath) -> [String: AnyObject] {
let indexOfVisibleRow = visibleRowsPerSection[indexPath.section][indexPath.row]
let cellDescriptor = cellDescriptors[indexPath.section][indexOfVisibleRow] as! [String: AnyObject]
return cellDescriptor
}
这个方法的参数是某个 cell 的 IndexPath 值(NSIndexPath
),这个 cell 就是 TableView 当前正在处理的那个 cell。这个方法返回了一个字典对象,包含了该 cell 的全部属性值。在方法体中,首先需要根据给定的 IndexPath 去可见行数组中进行匹配,这个任务非常简单,我们只需要提供这个 cell 的 section 索引和行索引就可以了。现在你可能还有点摸不著头脑,因为我们还没有介绍 TableView 的委託方法,因此我必须先告诉你每个 section 的行数应当等于每 section 中可见 cell 的个数。也就是说,在上面的代码中,我们必须保证每个 indexPath.row
都能在 visibleRowsPerSection
中找到对应的可见的 cell 的索引。
拥有了每个 cell 的行索引之后,我们就来处理和“读取”从 cellDescriptors
数组获取的 cell 描述字典。注意,我们在指定这个数组的第二个下标索引时,使用的是 indexOfVisibleRow
而不是 indexPath.row
。如果你使用了后者,得到的数据是不正确的。
实现了这个工具方法之后,我们后面就比较轻鬆了。接下来我们开始修改 ViewController
类中的 TableView 方法。首先,指定 TableView 的 section 数:
func numberOfSectionsInTableView(tableView: UITableView) -> Int {
if cellDescriptors != nil {
return cellDescriptors.count
}
else {
return 0
}
}
在这个方法中,我们必须考虑到 cellDescriptor
数组为 nil
的情况。只有当它不为空且填充了 cell 描述时我们才返回它的长度。
然后,让我们指定每 section 的行数。就像我刚才所说的,这个数字应该等于可见 cell 的数目,我们只需要一行代码就可以搞定这个:
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return visibleRowsPerSection[section].count
}
接下来,是每一个 section 的标题:
func tableView(tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
switch section {
case 0:
return "Personal"
case 1:
return "Preferences"
default:
return "Work Experience"
}
}
然后,指定每行的行高:
func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
let currentCellDescriptor = getCellDescriptorForIndexPath(indexPath)
switch currentCellDescriptor["cellIdentifier"] as! String {
case "idCellNormal":
return 60.0
case "idCellDatePicker":
return 270.0
default:
return 44.0
}
}
这裡需要说明一下:我们第一次使用了 getCellDescriptorForIndexPath:
方法,这个方法是我们在前面实现了的。我们需要获得每个 cell 的描述,因为我们接著还需要读取 cellIdentifier 属性,用这个值去决定行的高度。关于每个 cell 的高度,我们可以打开相应的 cell 的 xib
文件获知(或者你也可以不用管,直接使用这裡提供的数值好了)。
最后,才是真正去显示 cell。首先,从单元格重用队列中出列一个 cell:
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let currentCellDescriptor = getCellDescriptorForIndexPath(indexPath)
let cell = tableView.dequeueReusableCellWithIdentifier(currentCellDescriptor["cellIdentifier"] as! String, forIndexPath: indexPath) as! CustomCell
return cell
}
再一次,我们根据当前 IndexPath 来获取正确的单元格描述,并通过 cellIdentifier 属性从单元格重用队列中出列一个 cell,然后分别针对每种 cell 进行单独的处理:
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let currentCellDescriptor = getCellDescriptorForIndexPath(indexPath)
let cell = tableView.dequeueReusableCellWithIdentifier(currentCellDescriptor["cellIdentifier"] as! String, forIndexPath: indexPath) as! CustomCell
if currentCellDescriptor["cellIdentifier"] as! String == "idCellNormal" {
if let primaryTitle = currentCellDescriptor["primaryTitle"] {
cell.textLabel?.text = primaryTitle as? String
}
if let secondaryTitle = currentCellDescriptor["secondaryTitle"] {
cell.detailTextLabel?.text = secondaryTitle as? String
}
}
else if currentCellDescriptor["cellIdentifier"] as! String == "idCellTextfield" {
cell.textField.placeholder = currentCellDescriptor["primaryTitle"] as? String
}
else if currentCellDescriptor["cellIdentifier"] as! String == "idCellSwitch" {
cell.lblSwitchLabel.text = currentCellDescriptor["primaryTitle"] as? String
let value = currentCellDescriptor["value"] as? String
cell.swMaritalStatus.on = (value == "true") ? true : false
}
else if currentCellDescriptor["cellIdentifier"] as! String == "idCellValuePicker" {
cell.textLabel?.text = currentCellDescriptor["primaryTitle"] as? String
}
else if currentCellDescriptor["cellIdentifier"] as! String == "idCellSlider" {
let value = currentCellDescriptor["value"] as! String
cell.slExperienceLevel.value = (value as NSString).floatValue
}
return cell
}
对于一般的 cell,我们只是将 primaryTitle
和 secondaryTitle
的文本值赋给 textLabel
和 detailTextLabel
标籤。在本示例程序中,ID 为 idCellNormal
的 cell 实际上是位于 section 顶层的 cell,正是这个 cell 能够进行展开和收起的动作。
对于带有一个 TextField 的 cell,我们只是用单元格描述的 primaryTitle
属性去设置它的 placeholder
值。
对于带有一个开关控件的 cell,我们需要做两个动作:首先设置开关控件的显示文本(在 CellDescriptor.plist
文件中这是一个常量,当然你可以修改它),然后根据描述中的 value 属性是否为 true 来设置开关的 on 属性。注意,之后我们还会改变这个值。
还有一种 ID 为 idCellValuePicker 的 cell。这种 cell 表示它会提供一个选择列表,当我们选中列表中的某个选项,父 cell 将会自动收起,同时父 cell 的 textLabel 将做相应改变。
最后,是带有一个滑动条的 cell。我们仅仅是从 currentCellDescriptor
字典中取出当前的 value 值转换为一个 Float 数字,然后赋给滑动条,让它总是(在可见的时候)显示正确的值。稍后我们也会改变这个值以及与之对应的单元格描述。
对于 ID 不在上述 if 语句检查条件中的 cell,本示例 App 不会进行任何处理。当然,如果你不想採取这种方式,只需要修改上述代码并添加缺少的语句即可。
现在你可以先运行一下程序,看看运行的结果。当然不会看到更多的 cell,因为你只能看到顶层的 cell。别忘记我们还没有实现展开/收起功能,因此你点击 cell 也不会发生什麽。但你不用沮丧,因为你看到的这个结果已经表明我们刚才所做的一切已经生效了。
展开/收起
这部份内容可能是你最感兴趣的内容了,因为本教程的目标即将在这裡达成。首先我们将让我们的顶层 cell 在被点击之后展开/收起,同时子 cell 会适时地显示/隐藏。
首先需要知道被点到的 cell 位于哪一行(注意,并不是 indexPath.row
,而是可见单元格的行索引),因此,我们需要在下面的 TableView 委託方法中将行索引保存到某个局部变量:
func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
let indexOfTappedRow = visibleRowsPerSection[indexPath.section][indexPath.row]
}
儘管让我们的 cell 展开/收起用不著多少代码,我仍然打算以 step-by-step 的方式进行讲解。一则这会使我的思路更加清晰,二则也方便你了解每个动作的真正含义。现在,我们拥有了被点击的 cell 的真正的行索引,我们可以用它来检索 cellDescriptors 数组,看那个 cell 是否是一个“可展开的”的 cell。如果它是“可展开”的,同时还没有展开,则我们将认为它应该被展开(用一个标志变量来表示),否则我们认为它应该被收起:
func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
let indexOfTappedRow = visibleRowsPerSection[indexPath.section][indexPath.row]
if cellDescriptors[indexPath.section][indexOfTappedRow]["isExpandable"] as! Bool == true {
var shouldExpandAndShowSubRows = false
if cellDescriptors[indexPath.section][indexOfTappedRow]["isExpanded"] as! Bool == false {
// In this case the cell should expand.
shouldExpandAndShowSubRows = true
}
}
}
当我们通过一系列条件计算出 cell 是否该被展开或收起之后,我们需要将这个值存到单元格描述集合里,也就是说,我们要修改 cellDescriptors
数组。我们要修改的是选中的 cell 的 isExpanded
属性,这样它才会在再次被点击时表现正确(cell 的 isExpanded
为 true
,则再次点击时它会收起,否则再次点击后它会展开)。
func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
let indexOfTappedRow = visibleRowsPerSection[indexPath.section][indexPath.row]
if cellDescriptors[indexPath.section][indexOfTappedRow]["isExpandable"] as! Bool == true {
var shouldExpandAndShowSubRows = false
if cellDescriptors[indexPath.section][indexOfTappedRow]["isExpanded"] as! Bool == false {
shouldExpandAndShowSubRows = true
}
cellDescriptors[indexPath.section][indexOfTappedRow].setValue(shouldExpandAndShowSubRows, forKey: "isExpanded")
}
}
这裡我们不应该忘记一个重要的细节:回想一下,在单元格描述中,有一个表明 cell 是否应当显示的属性 isVisible
。这个属性也应当做相应的改变,这样那些新增的行才会在 cell 被展开时从隐藏变为显示,或者在 cell 被收起时由显示变成隐藏。事实上,只有改变这个值才能真正实现展开(或相反)的效果。因此,我们需要修改上述代码,在顶层 cell 被点击后修改其附属 cell 的 isVisible
属性。
func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
let indexOfTappedRow = visibleRowsPerSection[indexPath.section][indexPath.row]
if cellDescriptors[indexPath.section][indexOfTappedRow]["isExpandable"] as! Bool == true {
var shouldExpandAndShowSubRows = false
if cellDescriptors[indexPath.section][indexOfTappedRow]["isExpanded"] as! Bool == false {
shouldExpandAndShowSubRows = true
}
cellDescriptors[indexPath.section][indexOfTappedRow].setValue(shouldExpandAndShowSubRows, forKey: "isExpanded")
for i in (indexOfTappedRow + 1)...(indexOfTappedRow + (cellDescriptors[indexPath.section][indexOfTappedRow]["additionalRows"] as! Int)) {
cellDescriptors[indexPath.section][i].setValue(shouldExpandAndShowSubRows, forKey: "isVisible")
}
}
}
我们已经离我们的目标不远了,但我们还需要注意一件很重要的事情:在上述代码中,我们刚刚修改了某些 cell 的 isVisible
属性,这导致整个可视 cell 的行数也改变了。因此,在我们刷新表格之前,我们还要让 App 重新计算可视 cell 的行索引:
func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
let indexOfTappedRow = visibleRowsPerSection[indexPath.section][indexPath.row]
if cellDescriptors[indexPath.section][indexOfTappedRow]["isExpandable"] as! Bool == true {
var shouldExpandAndShowSubRows = false
if cellDescriptors[indexPath.section][indexOfTappedRow]["isExpanded"] as! Bool == false {
shouldExpandAndShowSubRows = true
}
cellDescriptors[indexPath.section][indexOfTappedRow].setValue(shouldExpandAndShowSubRows, forKey: "isExpanded")
for i in (indexOfTappedRow + 1)...(indexOfTappedRow + (cellDescriptors[indexPath.section][indexOfTappedRow]["additionalRows"] as! Int)) {
cellDescriptors[indexPath.section][i].setValue(shouldExpandAndShowSubRows, forKey: "isVisible")
}
}
getIndicesOfVisibleRows()
tblExpandable.reloadSections(NSIndexSet(index: indexPath.section), withRowAnimation: UITableViewRowAnimation.Fade)
}
你也看到了,我以动画的方式重新加载了被点击的 cell 的 section。当然,如果你不喜欢这种方式的话,你可以修改它。
运行 App 进行测试。连续点击顶层 cell,cell 随之展开和收起,虽然现在与子 cell 进行交互还不会发生任何事情,但这个结果看起来非常不错!
获取输入内容
从这裡开始,我们要将精力放在数据处理以及用户和子 cell 控件进行的交互上。对于 ID 为 idCellValuePicker 的 cell,我们将代码逻辑实现在当 ID 为 idCellValuePicker 的 cell 被点击的时候。对于本示例程序,在表格的 Preferences section中,有一些 cell 会罗列用户喜爱的运动和颜色。虽然我已经说过,但这裡我仍然要再说一次,就当是加强一下我们的记忆:当这类 cell 被点击时,我们想让对应的顶层 cell 收起(或者隐藏),所选中的值会显示到顶层 cell。
我之所以一开始就来处理这类 cell,是因为这是我们最后一次还需要和 TableView 的委託方法打交道。在这裡,我们会添加一个 else 分支来处理“不可展开的”cell,然后再对被点到的 cell 的 ID 值进行判断。如果 ID 值等于 idCellValuePicker,则进行相应的处理。
func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
let indexOfTappedRow = visibleRowsPerSection[indexPath.section][indexPath.row]
if cellDescriptors[indexPath.section][indexOfTappedRow]["isExpandable"] as! Bool == true {
...
}
else {
if cellDescriptors[indexPath.section][indexOfTappedRow]["cellIdentifier"] as! String == "idCellValuePicker" {
}
}
getIndicesOfVisibleRows()
tblExpandable.reloadSections(NSIndexSet(index: indexPath.section), withRowAnimation: UITableViewRowAnimation.Fade)
}
在内层的 if 语句中,我们将分四个单独的步骤进行处理:
找出顶层 cell 的行索引,也就是被点击的 cell 的“父 cell”的行索引。实际上,我们只需要从这个 cell 的单元格描述向前搜索,所找到的第一个顶层 cell 就是我们要找的 cell(即第一个可展开的 cell)。
将选中的 cell 的显示文本赋给顶层 cell 的
textLabel
的 text 属性。将顶层 cell 的
expanded
标记为false
。将顶层 cell 的所有的子 cell 标记为隐藏。
实现为代码则是:
func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
let indexOfTappedRow = visibleRowsPerSection[indexPath.section][indexPath.row]
if cellDescriptors[indexPath.section][indexOfTappedRow]["isExpandable"] as! Bool == true {
...
}
else {
if cellDescriptors[indexPath.section][indexOfTappedRow]["cellIdentifier"] as! String == "idCellValuePicker" {
var indexOfParentCell: Int!
for var i=indexOfTappedRow - 1; i>=0; --i {
if cellDescriptors[indexPath.section][i]["isExpandable"] as! Bool == true {
indexOfParentCell = i
break
}
}
cellDescriptors[indexPath.section][indexOfParentCell].setValue((tblExpandable.cellForRowAtIndexPath(indexPath) as! CustomCell).textLabel?.text, forKey: "primaryTitle")
cellDescriptors[indexPath.section][indexOfParentCell].setValue(false, forKey: "isExpanded")
for i in (indexOfParentCell + 1)...(indexOfParentCell + (cellDescriptors[indexPath.section][indexOfParentCell]["additionalRows"] as! Int)) {
cellDescriptors[indexPath.section][i].setValue(false, forKey: "isVisible")
}
}
}
getIndicesOfVisibleRows()
tblExpandable.reloadSections(NSIndexSet(index: indexPath.section), withRowAnimation: UITableViewRowAnimation.Fade)
}
当我们修改了某些 cell 的 isVisible
属性之后,可见 cell 的数目就被改变。因此最后两句是必须的。
现在运行程序,当你选择了一个喜爱的运动或颜色后 App 会进行适当的响应:
响应其它动作
在 CustomCell.swift
文件中,找到 CustomCellDelegate
协议,这裡我们已经对所有 required 方法进行了定义。在 ViewController
类中实现这些方法,我们将使 App 能够对其它动作进行响应。
打开 ViewController.swift
文件,声明遵循于该协议。在类声明的顶部加入该协议:
class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource, CustomCellDelegate
接著,在 tableView:cellForRowAtIndexPath:
方法中,将每个 CustomCell 的委託指定为 ViewController
类。在方法体中,在方法即将返回之前,加入这句代码:
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
...
cell.delegate = self
return cell
}
好了,现在我们可以来实现委託方法了。第一个方法是当用户在 DatePicker 中选定一个日期后,我们将所选定的日期显示在与之对应的顶层 cell:
func dateWasSelected(selectedDateString: String) {
let dateCellSection = 0
let dateCellRow = 3
cellDescriptors[dateCellSection][dateCellRow].setValue(selectedDateString, forKey: "primaryTitle")
tblExpandable.reloadData()
}
在指定好适当的 section 索引和行索引后,我们将选定日期以字符串格式赋给对应 cell 的单元格描述。注意这个字符串是委託方法通过参数来传递给我们的。
然后是带有开关控件的 cell。当开关控件的值改变时,我们需要做两件事情:首先,将合适的值( Single 或 Married)传递给对应的顶层 cell,同时用开关控件的值更新 cellDescriptors
数组,这样当表格刷新后开关控件就会显示正确的状态。在下面的代码中,注意我们首先基于开关控件的状态来决定适当的值,然后将它们赋给对应的属性:
func maritalStatusSwitchChangedState(isOn: Bool) {
let maritalSwitchCellSection = 0
let maritalSwitchCellRow = 6
let valueToStore = (isOn) ? "true" : "false"
let valueToDisplay = (isOn) ? "Married" : "Single"
cellDescriptors[maritalSwitchCellSection][maritalSwitchCellRow].setValue(valueToStore, forKey: "value")
cellDescriptors[maritalSwitchCellSection][maritalSwitchCellRow - 1].setValue(valueToDisplay, forKey: "primaryTitle")
tblExpandable.reloadData()
}
接下来是带有 TextField 的 cell。这裡,当用户的姓或者名输入有内容时,我们会动态组装用户的全名。因此,我们需要指明包含有 TextField 的 cell 的行索引,并根据索引的不同将字符串添加到全名中去(名在前,姓在后)。最后,我们需要更新顶层 cell 的文字,刷新表格,以使它反映出用户输入内容的改变:
func textfieldTextWasChanged(newText: String, parentCell: CustomCell) {
let parentCellIndexPath = tblExpandable.indexPathForCell(parentCell)
let currentFullname = cellDescriptors[0][0]["primaryTitle"] as! String
let fullnameParts = currentFullname.componentsSeparatedByString(" ")
var newFullname = ""
if parentCellIndexPath?.row == 1 {
if fullnameParts.count == 2 {
newFullname = "\(newText) \(fullnameParts[1])"
}
else {
newFullname = newText
}
}
else {
newFullname = "\(fullnameParts[0]) \(newText)"
}
cellDescriptors[0][0].setValue(newFullname, forKey: "primaryTitle")
tblExpandable.reloadData()
}
最后,是带有滑动条的那个 cell,即“Work Experience” section 需要我们处理。当用户拖动滑块,我们需要同时完成两件事:用新的滑动条的数值修改顶层 cell 的文本内容(即“经验级别”),以及将滑动条的值保存到对应的 cell 描述中,使其在刷新表格后能够更新界面。
func sliderDidChangeValue(newSliderValue: String) {
cellDescriptors[2][0].setValue(newSliderValue, forKey: "primaryTitle")
cellDescriptors[2][1].setValue(newSliderValue, forKey: "value")
tblExpandable.reloadSections(NSIndexSet(index: 2), withRowAnimation: UITableViewRowAnimation.None)
}
最后一块拼图已经完成,接下来就是运行 App 进行测试。
总结
正如我一开始所说,有时创建一个展开式 TableView 真的很有用,因为它让你直接在表格中处理以前必须创建新 View Controller 才能解决的问题。在教程的前半部份,我演示了一种创建展开式 TableView 的方法,它的主要特点是在一个属性列表文件(plist)中以属性集的方式来描述每个 cell。我还演示了在单元格显示、展开和选定时,如何用代码来处理单元格描述列表;此外,我还教你如何用用户输入的数据来修改这些 cell。虽然示例App 中模拟的表单在真正的 App 中也是可以用的,但在要把它当做一个完整的组件仍然需要我们考虑更多的事情(例如,将 cell 描述列表回写到文件中)。当然,这已经超出了本文的范围,我们只是想实现一个展开式的 TableView,让它的 cell 可以根据需要显示或隐藏而已,也就是我们最终的实现的那个 App。我希望你能从本教程中发现任何对你有用的东西。当然,你可以设法改进教程中的代码,或者根据需要进行调整。又到了不得不说再见的时候了,祝你开心,永远勇于尝试新的事物!
为便于参考,你可以从 GitHub 下载完整的 Xcode 项目.