在iOS中怎样创建可展开的Table View?(上)

原文地址

本文作者: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将要包含下面三个组:

  1. 个人(Personal)
  2. 偏好(Preferences)
  3. 工作经验(Work Experience)

每组(section)都将包含可展开的cell,这将触发显示或隐藏每组中附加的cell,具体来说,每组的顶级cell(那些将会打开或是合拢的cell)就是:

对于"Personal"组来说

  1. Full name(全名):它显示了用户的全名,并且当它打开的时候,它底下还包括两个可用于输入姓和名cell.

  2. Date of birth(生日):它显示了用户的出生日期,当它打开的时候,提供了一个日期选择器(date picker view),底部还有一个按钮,当选中一个日期的时候,点击按钮可以把设置的日期显示到顶部cell上.

  3. Marital status(婚姻状况):这个cell显示了用户的婚姻状况(已婚或者单身).当它打开的时候,提供了一个开关控件来设置用户的婚姻状态.

对于"Preferences"组来说:

  1. Favorite sport:我们的假表格要求用户选择最喜欢的运动.当这个cell打开的时候,四个包含运动名的选项就出现了,并且当一个选项被点击后,这个cell就会"自动地"合拢起来.

  2. 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
    }
}

你要明白,我们不能忽略cellDescriptornil这种情况.如果子数组已经被初始化,并且填充了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的值分别设置了给了textLabeldetailTextLabel.在我们的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.不要忘了我们还没有启动打开功能,所以你点击的时候不会发生任何事.但是,不要泄气,因为你所看到的意味着到目前为止我们所做的工作是完美的.

未完待续~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,542评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,596评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,021评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,682评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,792评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,985评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,107评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,845评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,299评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,612评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,747评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,441评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,072评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,828评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,069评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,545评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,658评论 2 350

推荐阅读更多精彩内容