原文地址
本文作者:gabriel theodoropoulos
原文:How To Create an Expandable Table View in iOS
原文链接
几乎所有的app都有一个共同特征,它们向用户提供了多个视图控制器来导航和工作.这些视图控制器可以用在很多方面,例如,简单地显示某种信息在屏幕上,或者从用户的输入收集复杂的数据.为不同功能的app创建新的视图控制器经常是强制性的,并且好几次都是有点让人退缩的任务.然而,如果你只是使用可展开的tableview,有时也可能避免创建视图控制器(以及在storyboard中它们各自的场景).
正如这个词所暗示的,一个可展开的tableView是一个tableView,它可以"允许"它的cell打开和合拢,显示和隐藏其他的cell,在任何情况下都总是可见.当需要收集简单的数据或者显示用户所需要的信息的时候,创建可展开的tableView是一个不错的选择.使用可展开的tableView,在任何情况下,只是向用户请求已经存在的数据或是默认的视图控制器,而没必要创建新的视图控制器.例如,有了可展开的cell,你可以显示和隐藏cell,不必离开这个视图控制器收集数据.
你是否使用可展开的tableView,并不总是取决于你开发的app的性质.然而,通过继承UITableViewCell类以及创建额外的xib文件,cell的界面可以自定义,app的外观和感觉通常不是一个问题.所以最终这只是一个要求.
在这个教程中,我将会向你展示一个简单高效的方式来创建可展开的tableView.注意,你在这里所看到的并不是唯一的方法来实现这个功能.相当多的实现方法是基于app的需要,但是我的目标是是提出一种比较通用的方法,在大多数情况下可以被重复使用.所以,说了这么多,前往下一个部分体会我们将在此次教程中处理的内容吧.
关于演示的app
通过实现一个包含tableView的视图控制器的app,我们将会看到可展开的tableView是如何创建和工作的.我们将会做一个假的表格让用户输入数据,为此,tableView将要包含下面三个组:
- 个人(Personal)
- 偏好(Preferences)
- 工作经验(Work Experience)
每组(section)都将包含可展开的cell,这将触发显示或隐藏每组中附加的cell,具体来说,每组的顶级cell(那些将会打开或是合拢的cell)就是:
对于"Personal"组来说
Full name(全名):它显示了用户的全名,并且当它打开的时候,它底下还包括两个可用于输入姓和名cell.
Date of birth(生日):它显示了用户的出生日期,当它打开的时候,提供了一个日期选择器(date picker view),底部还有一个按钮,当选中一个日期的时候,点击按钮可以把设置的日期显示到顶部cell上.
Marital status(婚姻状况):这个cell显示了用户的婚姻状况(已婚或者单身).当它打开的时候,提供了一个开关控件来设置用户的婚姻状态.
对于"Preferences"组来说:
Favorite sport:我们的假表格要求用户选择最喜欢的运动.当这个cell打开的时候,四个包含运动名的选项就出现了,并且当一个选项被点击后,这个cell就会"自动地"合拢起来.
Favorite color:和上面一样,这个时候就会显示三种不同的颜色来供用户选择.
对于“Work Experience”组来说:
Level:当顶级cell被点击打开的时候,另一个带有滑块控件的cell就出现了,让用户指定一个假设的工作经验.允许的值在0...10这个范围之间,我们将保持唯一的整数值.
下面的动态图可以清楚的表明我们将要做什么:
你可以注意到上面的tableview打开的时候有多种类型的cell.所有这些你都可以在启动项目里找到,可供你下载,还包括一些其他将要实现的东西.设计的所有自定义cell都在单独的xib文件中,同时一个自定义的UITableViewCell子类(命名为CustomCell)已经被分配为他们的自定义类:
在项目中你会发现有如下自定义cell的xib文件:
它们的名字说明了每个cell所代表的含义,你可以在启动项目中更深的区探索它们.
除了这些cell,你也可以找到一些已经被实现的代码.虽然这些代码是重要的并且完成了demo的功能,但是它们并不是此次教程的核心代码,所以就跳过了编写代码并且已经提供了写好的代码.当我们通过下面的部分,缺失的那些我们所感兴趣的代码都会在下面一步一步地增加.
所以,现在你知道我们最终的目标了,因此下面我们将要学习如何创建一个可展开的tableView.
描述这些cell
在此次教程中,我所提出的有关可展开的tableView,其中涉及的所有实现和技术都是基于一个简单的想法:为app描述每一个cell的细节.这样让它知道是可能的,cell是否可以展开,是否可见,以及每个cell的文本标签的值是什么,等等.事实上,整个想法都是基于分组的属性,那既描述了属性也包含了每个cell的某些值,然后把它们提供给app,以便正确地显示它们.
对于这个示例app,我创建并且使用了在下一列表里中显示的属性.注意,一个真实的app可以添加新的属性,或者修改现有的属性.在任何情况下,重要的是你设法在这里学到有用的东西.然后你就可以完成所有你期望的改变.属性列表如下:
isExpandable:它是一个布尔值,表示一个cell是否可以展开.对于我们来说,在这篇教程中,它是最重要的属性之一.
isExpanded:也是一个布尔值,表示一个可以展开的cell是展开状态还是合拢状态.顶级的cell默认是合拢的,所以,所有的cell初始值都会设置成 NO.
isVisible:正如名字所暗示的,表示cell是否可见.稍后,它将发挥重要作用,我们将基于属性,所以我们要在tableView里显示合适的cell.
value:这个属性对保持UI控制的值是有用的(例如,婚姻状态开关控制的值).并不是所有的cell都有哪些控制,所以大多数情况,这个属性会保持为空.
primaryTitle:它是cell主标题上的文本,很多次都包含了应该被显示在一个cell上实际的值.
secondaryTitle:它是cell子标题上的文本,或者是第二个标签的文本.
cellIdentifier:它是匹配当前描述的自定义cell的标识符.它不仅仅被app用来出队合适的cell,而且它也会决定应该采取适当地行动,取决于显示的cell,以及每个cell具体的高度.
additionalRows:当一个可以展开的cell被打开的时候,它包含了应该被显示附加行的总数.
上面的这些属性,将会被用来描述每一个我们在tableView中有的cell.在app级的术语,我们要做的就是使用一个简单易用的属性列表(plist)文件.在这个plist文件中,我们需要合适地填充这些在所有cell上的属性,这样,我们将会有一个完整地技术描述,可以让我们和这个app使用.并且所有这些没有写一行代码,是不是很好?
在这一点上,我们通常会在我们的工程中创建一个新的plist文件,然后我们将开始填充合适的数据.当然你也可以不这么做,你可以下载.plist文件.所以,下载它并把它添加到起始项目里去吧.设置所有cell的属性需要大量的空间,这将是没有意义的,并且你只是拷贝-粘贴或是输入缺失的值,也是又累又无聊的.然而,让我们讨论一下这一点:
首先,你(希望)下载的文件名为CellDescriptor.plist.根节点(root)是一个数组,它的每一项在tableView里都代表一组.这就意味着,在plist文件里,根数组里包含三个项(item),和我们想要在tableView里显示的数量一样多.
上面的item也是数组,并且它们自己的item描述了每组的cell.实际上,上面的属性被归类为字典,并且每个字典匹配单一的cell.下面就是一个简单地plist文件:
现在是最好花费你时间的时候了,更彻底地看这些属性以及所有那些我们将要显示在tableView上cell的值.在我们处理所需的代码时候,通过cell描述很容易理解,我们需要为创建并且管理可扩展的cell所写的已经明显变少了,那样,我们将不必控制关于app cell的各种状态了(例如,哪一个cell是可展开的,是否它允许一个特定cell的展开,用代码决定一个cell是否可见,等等).所有这些信息都存在你刚刚下载的plist文件里.
加载cell描述
是时候来写代码了,尽管我们使用plist文件已经节省了很多代码,但是还是需要在工程中添加一些代码.现在描述cell的plist文件已经存在了,我们要做的第一件事就是要用编程把plist文件的内容加载到一个数组里.在下面的部分,这个数组将会被用作tableView数据源的一部分.
首先,打开工程中的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数组.
下一步是调用上面的函数,在view正确出现之前,tableView已经配置之后(我们需要在显示数据之前就创建号tableView)我们要做的才是调用函数:
override func viewWillAppear(animated: Bool) {
super.viewWillAppear(animated)
configureTableView()
loadCellDescriptors()
}
如果你在上面代码的最后一行写了print(cellDescriptors)命令并且运行app,你将会在控制台上看见所有的plist文件里的内容.这就意味着它们已经成功地加载到了内存.
正常来说,我们的工作到这部分已经结束了,但是我们不会那么做的;我们还有别的要增加,下面的部分才是至关重要的.正如你到目前为止所发现的(特别是如果你检查了CellDescriptor.plist文件),不是所有的cell都会在app运行的时候显示.实际上,我们不知道它们是否能在一起同时看到,因为当用户需要的时候,它们可以展开或合拢.
在程序的世界中,那就意味着每个cell的行索引(index)不是不变的(我们写index.row来处理cell),因此我们在使用cell行的时候,不能仅仅通过数据源数组.这是强制性的工作以及拿出提供可见cell的行索引的解决方案.因为不可见的cell会导致一个实现错误,当然,app也会有异常.
所以,由于这个原因,我们将会实现一个新的方法getIndicesOfVisibleRows().它的名字说明了它的作用:这个方法会取得那些已经标记为仅可见的cell行的索引值.在我们实现之前,请再一次移到类的顶部加入如下代码:
var visibleRowsPerSection = [[Int]]()
这个二维数组将会存储每组中可见cell的索引(其中一维是组,另一维是行).
现在让我们实现这个新的函数吧.你可能猜到了,我们将通过所有的cell描述和我们在上面添加的cell索引的2D数组,把"可见"属性设置为YES.显然,我们需要处理一个嵌套循环,但是却不难处理.下面是这个函数的实现:
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数组中先前所有的内容,否则随后我们在调用这个函数的时候会得到错误的数据.
第一次上面的函数应该可以被正确地调用,之后cell描述符会从文件加载.所以,再看一下我们实现的第一个函数,我们做如下修改:
func loadCellDescriptors() {
if let path = NSBundle.mainBundle().pathForResource("CellDescriptor", ofType: "plist") {
cellDescriptors = NSMutableArray(contentsOfFile: path)
getIndicesOfVisibleRows()
tblExpandable.reloadData()
}
}
尽管tableView还没有起作用,我们触发一个预先加载的活动,所以我们要确保在app启动之后,会显示合适的cell.
显示cell
了解了每次app运行的时候cell描述符都会被加载,我们继续吧,在tableView上显示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的索引路径值(NSIndexPath),它返回了一个字典,包含了所有cell匹配的属性.在它函数体里的第一个任务就是找出匹配索引路径的可见行的索引,这很容易做,因为我们需要的是cell的组合行(section and row).到目前为止我们没有处理过tableView的代理方法,所以我必须提前说,每组的总行数将会匹配在每一个组里可见cell的个数.也就是说,在上面的实现中,任意indexPath.row的值匹配到了在visibleRowsPerSection里合适的可见cell的索引.
通过让每个cell都有行号,我们可以从cellDescriptors数组中,"提取"cell描述的字典.注意,指定为二维的索引是indexOfVisibleRow,而不是indexPath.row.使用第二个会返回错误的数据.
我们又创建了一个有用的工具,接下来它将会变得非常方便,所以让我们来修改ViewController类中已存在的tableView方法吧.首先,让我们指定tableView的组数:
func numberOfSectionsInTableView(tableView: UITableView) -> Int {
if cellDescriptors != nil {
return cellDescriptors.count
}
else {
return 0
}
}
你要明白,我们不能忽略cellDescriptor为nil这种情况.如果子数组已经被初始化,并且填充了cell描述符的值,那么我们返回的是子数组的大小.
然后,让我们指定每组的行数.正如我之前说的,这个数量总是等于可见cell的数量,我们可以在一行cell上返回信息:
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return visibleRowsPerSection[section].count
}
在那之后,让我们设置tableView每组的标题:
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"属性,它的值依赖于具体的行高.你可以验证各自的xib文件cell的高度值.
最后,实际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
}
我们又一次基于当前的索引值获得了合适的cell描述符.通过使用"cellIdentifier"属性,正确的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.在我们的demo里,带有idCellNormal标识符的cell实际上是顶层可展开和合拢的cell.
对于含一个文本输入框的cell来说,我们只需通过cell描述符的primaryTitle属性来设置placeholder的值.
关于包含开关控件的cell,我们需要做有两件事:在开关显示之前,我们就需要制定它的显示文本(在我们的例子中是不变的,你可以在CellDescriptor.plist文件里修改里卖弄的值),之后我们就看到了开关的状态,根据它是否被设置为"on"或者没有描述符.注意,之后我们会修改这个值.
也有一些cell有"idCellValuePicker"标识符.那些cell意味着提供了一列选项,并且一个选项的父cell被选中的时候,它将会自动合拢.在上面显示的情况,将会指定cell的文本标签.
最后,还有一种包含滑块的cell的情况.我们只是从currentCellDescriptor字典里取得了当前的值,我们把它转换成一个浮点数字,我们将把它分配给滑块设置,所以在任何时候,它都显示了合适的值(当它可见的时候).稍后我们将更改值,以及我们将会更新各自的cell描述符.
对于cell来说,在上述语句中,cell的标识符没有显示地增加,app也没有任何改变.然而,如果你想以一种不同的方式处理,随意修改代码并且添加任何丢失的部分.
现在你可以运行app看一下结果了.不要期望看到太多东西,你将会看到顶层的cell.不要忘了我们还没有启动打开功能,所以你点击的时候不会发生任何事.但是,不要泄气,因为你所看到的意味着到目前为止我们所做的工作是完美的.
未完待续~