Swift 可展开可收缩的表视图

主要学习与运行效果

在本节的内容中,我们将通过一个具体实例的实现过程,详细讲解在表视图当中,如何创建一个可展开可收缩的表视图。为了让读者有着更为直观的印象,我们将通过模仿QQ好友列表来实现这个效果。

该示例主要演示:

1.表视图外观设计

2.自定义用户组设计

3.从plist文件中读取数据

4.将数据显示在表视图中

5.实现表格的展开、收缩效果

运行效果如下所示:

74454-b6eeeddfe8f96b70.png

表视图外观设计

我们使用Single View Application模板创建一个swift项目,命名为Friend List,为了简便起见,Devices我们选择iPhone进行开发。

打开Main.storyboard文件,删除View Controller Scene,从Xcode右下方的Object Library面板中将Navagation Controller拖动到故事板中,如下图所示:

74454-72ccab93df2dbfd0.png

Navigation Controller Scene包含了两个视图,一个是导航视图,一个是表视图。选中Navagation Controller Scene,取消Use Size Classes选项,勾选Is Initial View Controller选项,将其作为初始视图控制器运行。

设置完成后,故事板将如下图所示:

74454-c7b660c7b72463ce.png

选 中Root View Controller的导航栏,修改其标题为“联系人”。完成此操作后,选择Table View中的Table View Cell,在Identifier项中输入:FriendCellIdentifier来为其增加标识符。对于Xcode来说,每一种表视图单元格都需要 声明其标识符,以让Xcode能够对其进行定位。如图所示:

74454-f8afef2e54181509.png

此时的单元格高度还比较窄,为了能够达到我们想要的结果,我们需要设置其高度,同时也因为其高度固定,因此我们定位到单元格的Size inspector中,将Row Height设置为66(或者其他你看起来舒服的值)。如图所示:

74454-22a3322873820a1a.png

向单元格中添加一个Image控件,两个Label控件,以完成粗略的QQ好友信息设计。设计效果如下图所示:

74454-3101ba12b7f426aa.png

由于我们创建了一个自定义的表视图单元格,因此我们最好为其创建一个专门的类来定义其结构,以便以后能够借用此数据结构来保存从文件中读取的信息。

创建一个Friend.swift文件,文件内容如下:

import UIKit
class Friend: NSObject {
    var Avatars: String =  "user_default"   // 图片名称,定义朋友的头像
    var Name: String = ""           // 字符串,定义朋友的名字
    var Intro: String = ""            // 字符串,定义朋友的个性签名
    var VIP: Bool = false     // 布尔值,确定朋友是否为VIP
}

其中,user_default文件为用户默认头像,已经放置在Image.xcassets中,用户可以自行添加你喜爱的头像并放置在xcassets中。

然后我们创建一个FriendCell.swift文件,这个文件将和我们刚刚创建的那个单元格进行绑定。文件内容如下:

import UIKit
class FriendCell: UITableViewCell {
    @IBOutlet weak var ImgAvatars: UIImageView!
    @IBOutlet weak var LblName: UILabel!
    @IBOutlet weak var LblIntro: UILabel!
    var friend: Friend = Friend()
    // 设置朋友信息
    func setFriend(newfriend: Friend) {
        var Image: UIImage? = UIImage(named: "\(newfriend.Avatars)")
        if Image != nil {
            ImgAvatars.image = Image!
        }else{
            ImgAvatars.image = UIImage(named: "user_default")
        }
        LblName.text = newfriend.Name
        LblIntro.text = newfriend.Intro
        if friend.VIP {
            LblName.textColor = UIColor.redColor()
        }else{
            LblName.textColor = UIColor.blackColor()
        }
    }
}

其中,ImgAvatars与单元格中的Image控件进行绑定,LblName和LblIntro分别与单元格中的两个Label控件进行绑定。注意此时单元格的Identify inspector中的Class要设置为我们刚刚创建的FriendCell类。

接下来的代码就比较直观。这里我们对头像的读取进行了一个可选值的判定。首先先根据记录中的头像名称去读取存储好的头像。如果没有读取成功,那么Image变量将会返回一个nil值,这时就将头像设置为我们默认的头像:user_default。

自定义用户组设计

在iOS应用中,我们可以自定义表视图单元格的风格,其实原理就是向单元格中添加子视图。添加子视图的方法主要有三种:使用代码、从.xib文件加载以及直接使用storyboard进行设计。在上一节中我们就是使用storyboard进行设计,非常方便和直观。

在本节自定义用户组中,我们要设计单元格折叠后的父类单元格。出于代码的简便和直观起见,同时为了也为了让读者尽可能多的掌握自定义表视图的方法,因此这里我们采用.xib文件进行加载。

新建一个xib文件,依次选择New File -> iOS -> User Interface -> Empty,命名为SectionHeaderView

。这样我们就创建了一个xib文件。由图中可以看到,xib文件和storyboard的区别并不是很大。简单理解来说,可以把StoryBoard看做是一组viewController对应的xib,以及它们之间的转换方式的集合。

我 们强烈建议大家采用storyboard进行界面设计,因为storyboard是iOS 5之后苹果提供的以及强烈建议开发者使用的配置。但是,由于storyboard中已经不允许有单个view的存在,因此在某些时候我们还是需要借助于单 个的xib来自定义UI。这是由于storyboard的设计理念造成的。storyboard重视层次结构,重视UI的架构和设计,更重视项目的流程。 而对于单个的UI来说,则更注重于重用和定制。

74454-d4e4c78806d74da7.png

向xib界面中拖入一个View,将其Attributes inspector中的Size修改为Freeform(允许调整View的大小),Status Bar修改为None(取消状态栏显示)。

向view中拖入一个Button控件和Label控件,适当调整大小,如图所示:

74454-457d5eb9bd5092b7.png

这时我们同样需要创建一个类来定义这个自定义用户组的数据结构,新建一个Group.swift文件,文件内容如下:

import UIKit
class Group: NSObject {
    var name: String = ""       // 字符串,定义组名称
    var friends: NSArray = NSArray()    // 数组,定义了该组内所有朋友
}

接下来,我们需要为我们自定义的表视图创建一个类来与之进行绑定。创建一个SectionHeaderView.swift文件,文件内容如下:

import UIKit
// 该协议将被用户组的委托实现; 当用户组被打开/关闭时,它将通知发送给委托,来告知Xcode调用何方法
protocol SectionHeaderViewDelegate: NSObjectProtocol {
    func sectionHeaderView(sectionHeaderView: SectionHeaderView, sectionOpened: Int)
    func sectionHeaderView(sectionHeaderView: SectionHeaderView, sectionClosed: Int)
}
class SectionHeaderView: UITableViewHeaderFooterView {
    @IBOutlet weak var LblTitle: UILabel!
    @IBOutlet weak var BtnDisclosure: UIButton!
    var delegate: SectionHeaderViewDelegate!
    var section: Int!
    var HeaderOpen: Bool = false  // 标记HeaderView是否展开
    override func awakeFromNib() {
        // 设置disclosure 按钮的图片(被打开)
        self.BtnDisclosure.setImage(UIImage(named: "carat-open"), forState: UIControlState.Selected)
        // 单击手势识别
        var tapGesture = UITapGestureRecognizer(target: self, action: "btnTap:")
        self.addGestureRecognizer(tapGesture)
    }
    @IBAction func btnTap(sender: UITapGestureRecognizer) {
        self.toggleOpen(true)
    }
    func toggleOpen(userAction: Bool) {
        BtnDisclosure.selected = !BtnDisclosure.selected
        // 如果userAction传入的值为真,将给委托传递相应的消息
        if userAction {
            if HeaderOpen {
                delegate.sectionHeaderView(self, sectionClosed: section)
            }
            else {
                delegate.sectionHeaderView(self, sectionOpened: section)
            }
        }
    }
}

使用协议的原因是SectionHeaderView是我们自定义的一个视图,而且我们专门为其新建了一个类文件来进行管理。……这 个协议定义了两个方法,这两个方法名称相同,但是参数不同,被称为函数重载。在接下来我们的使用中,可以直接使用Selector选择其参数名来进行调 用。

awakeFromNib()是.nib文件被加载的时候,创建view对象前调用的方法。其和viewDidLoad()的区别是, 当view对象被加载到内存时系统才会调用viewDidLoad()方法。因此,Xcode会先执行awakeFromNib()方法,才会执行 viewDidLoad()方法。

那为什么我们要用awakeFromNib()方法呢,那是因为UITableViewHeaderFooterView里面并不存在viewDidLoad()方法,我们没有办法对其重载调用,所以只能使用awakeFromNib()方法。

由于向.xib文件中直接添加手势,会导致该nib文件注册失败,因此我们用代码的形式来定义一个单击手势。这个手势将控制单元格的展开和收缩。

最 后要注意的是,由于我们的类继承的是UITableViewHeaderFooterView,虽然在UIKit中,这个类是UIView的子类,但是由 于swift还不完善的原因,在IB面板中(包括xib文件和storyboard文件)的Identity inspector的Class项中都不能够显示出我们刚刚创建出来的类,但是我们可以手动输入这个类的名称。

74454-fc68f5c9efc4d578.png

此时我们仅仅只是定义了SectionHeaderView.xib的属性和方法,但是由于用户组里面会包含多个FriendCell,因此我们还要在定义一个类来标明Group和SectionHeaderView之间的结构联系。

新建一个SectionInfo.swift文件,文件内容如下:

import Foundation
// 定义了用户组以及FriendCell的一系列属性、方法
class SectionInfo: NSObject {
    var group: Group = Group()
    var headerView: SectionHeaderView = SectionHeaderView()
}

至此,所有的表格结构、数据结构已经定义完成,接下来就是要从数据层面进行操作了。

从plist文件中读取数据

为了方便数据管理,我们使用数据持久化功能来保存用户组信息和朋友信息。这里我们采用属性列表来保存数据。应用程序在启动时会将该文件的全部内容读入内存,并在退出时注销。

新 建一个plist文件,依次选择New File -> iOS -> Resource -> Property List,命名为FriendInfo。这样我们就创建了一个plist文件。将文件中的Property List Type修改为None,然后按照下图所示设计文件内容:

74454-25bfb5d431b7a1cb.png

接下来打开ViewController.swift文件,创建一个函数,命名为loadFriendInfo,用来读取文件。函数如下:

func loadFriendInfo() -> NSArray {
    var FriendInfo: NSMutableArray?
    // 定位到plist文件并将文件拷贝到数组中存放
    var fileUrl = NSBundle.mainBundle().URLForResource("FriendInfo", withExtension: "plist")
    var GroupDictionariesArray = NSArray(contentsOfURL: fileUrl!)
    FriendInfo = NSMutableArray(capacity: GroupDictionariesArray!.count)
    // 遍历数组,根据组和单元格的结构分别赋值
    for GroupDictionary in GroupDictionariesArray! {
        var group: Group = Group()
        group.name = GroupDictionary["GroupName"] as String
        var friendDictionaries: NSArray = GroupDictionary["Friends"] as NSArray
        var friends = NSMutableArray(capacity: friendDictionaries.count)
        for friendDictionary in friendDictionaries {
            var friendAsDic: NSDictionary = friendDictionary as NSDictionary
            var friend: Friend = Friend()
            friend.setValuesForKeysWithDictionary(friendAsDic)
            friends.addObject(friend)
        }
        group.friends = friends
        FriendInfo!.addObject(group)
    }
    return FriendInfo!
}

我们来逐次分析这个函数。首先我们需要定位到我们创建的FriendInfo.plist文 件,NSBundle.mainBundle中保存了一系列当前项目的信息,包括版本号、程序名等等内容,这里我们使用URLForResource() 来获取FriendInfo.plist的URL地址。

NSArray的contentsOfURL:

由此,我们就完成了从plist文件中读取数据的操作,接下来我们可以使用FriendInfo数组里面的值,来完成值的赋予。

将数据显示在表视图中

有了上面的操作,现在我们就可以将我们读取到的数据显示在表视图当中了。

我们首先定义一个类型为NSArray数组的变量,用来存放我们读取后的数据,以及存放用户组、单元格信息的NSMutableArray变量:

var groups: NSArray!
var sectionInfoArray: NSMutableArray!

接下来,我们在viewDidLoad()方法中添加如下语句,完成数据的读取和存放:

self.tableView.sectionHeaderHeight = CGFloat(HeaderHeight)    // 用户组高度
opensectionindex = NSNotFound
groups = loadFriendInfo()
let sectionHeaderNib: UINib = UINib(nibName: "SectionHeaderView", bundle: nil)
self.tableView.registerNib(sectionHeaderNib, forHeaderFooterViewReuseIdentifier: SectionHeaderViewIdentifier)

后面两个语句本章后面会对其详细介绍。

接下来,我们从父类视图中重写viewWillAppear方法,来完成分组表的定义。

override func viewWillAppear(animated: Bool) {
    super.viewWillAppear(animated)
    // 检查SectionInfoArray是否已被创建,如果已被创建,则检查组的数量是否匹配当前实际组的数量。通常情况下,您需要保持SectionInfo与组、单元格信息保持同步。如果扩展功能以让用户能够在表视图中编辑信息,那么需要在编辑操作中适当更新SectionInfo
    if sectionInfoArray == nil || sectionInfoArray.count != self.numberOfSectionsInTableView(self.tableView) {
        // 对于每个用户组来说,需要为每个单元格设立一个一致的SectionInfo对象
        var infoArray: NSMutableArray = NSMutableArray()
        for group in groups {
            var dictionary: NSArray = (group as Group).friends
            var sectionInfo = SectionInfo()
            sectionInfo.group = group as Group
            sectionInfo.headerView.HeaderOpen = false
            infoArray.addObject(sectionInfo)
        }
        sectionInfoArray = infoArray
    }
}

接下来依次实现这几个方法:

override func canBecomeFirstResponder() -> Bool {
    return true
}

判断一个对象是否可以成为第一响应者。默认返回false。

如果一个响应对象通过这个方法返回true,那么它成为了第一响应对象,并且可以接收触摸事件和动作消息。

我们的UITableView是UIView的子类,因此必须重写这个方法才可以成为第一响应者。

override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
    return self.groups.count
}

numberOfSectionsInTableView()方法返回表视图有多少个section。一个用户组对应一个section。

override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    var sectionInfo: SectionInfo = sectionInfoArray[section] as SectionInfo
    var numStoriesInSection = sectionInfo.group.friends.count
    var sectionOpen = sectionInfo.headerView.HeaderOpen
    return sectionOpen ? numStoriesInSection : 0
}

tableView:numberOfRowsInSection:方法返回对应的section中有多少个元素,也就是多少行。在这里我们先确定用户组是否被打开,如果打开则返回对应的用户组中的所有朋友数量,否则为0。

override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
    let FriendCellIdentifier = "FriendCellIdentifier"
    var cell: FriendCell = tableView.dequeueReusableCellWithIdentifier(FriendCellIdentifier) as FriendCell
    var group: Group = (sectionInfoArray[indexPath.section] as SectionInfo).group
    cell.friend = group.friends[indexPath.row] as Friend
    cell.setFriend(cell.friend)
    return cell
}

tableView:cellForRowAtIndexPath:方法返回指定的行的单元格。一个朋友对应一个单元格。在这个方法 中,我们通过dequeueReusableCellWithIdentifier()方法来读取对应标识符的单元格,在这里是我们在 main.Stroyboard中定义的那个单元格。还记得我们给那个单元格添加了“FriendCellIdentifier”标识符吗?

override func tableView(tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
    // 返回指定的section header视图
    var sectionHeaderView: SectionHeaderView = self.tableView.dequeueReusableHeaderFooterViewWithIdentifier(SectionHeaderViewIdentifier) as SectionHeaderView
    var sectionInfo: SectionInfo = sectionInfoArray[section] as SectionInfo
    sectionInfo.headerView = sectionHeaderView
    sectionHeaderView.LblTitle.text = sectionInfo.group.name
    sectionHeaderView.section = section
    sectionHeaderView.delegate = self
    return sectionHeaderView
}

和上面的方法相似,tableView:viewForHeaderInSection:方法返回section中的表头 (Header)类型。我们的SectionHeaderView声明的是UITableViewHeaderFooterView类型,这个方法是专门 用来返回该类型的实例的。

可以看到,这个方法中使用了和上面方法极其相似的 dequeueReusableHeaderFooterViewWithIdentifier()方法。这个方法的作用同样也是读取对应标识符的单元 格。不过不同的是,使用这个方法前需要注册nib文件或者注册描述这个单元格的类。因此,之前我们就使用了如下两条语句注册nib文件,以便于swift 能够读取到这个单元格。

let sectionHeaderNib: UINib = UINib(nibName: "SectionHeaderView", bundle: nil)
self.tableView.registerNib(sectionHeaderNib, forHeaderFooterViewReuseIdentifier: SectionHeaderViewIdentifier)
override func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
    return CGFloat(DefaultRowHeight)
}

这个方法返回指定的单元格的高度,这里我们返回的是单元格的默认高度。

实现表格的展开、收缩效果

我们给ViewController这个类继承SectionHeaderViewDelegate协议,此时,类的头部变成这样:

class ViewController: UITableViewController, SectionHeaderViewDelegate

接下来,我们在ViewController类中实现协议中定义的两个函数:

func sectionHeaderView(sectionHeaderView: SectionHeaderView, sectionOpened: Int) {
    var sectionInfo: SectionInfo = sectionInfoArray[sectionOpened] as SectionInfo
    sectionInfo.headerView.HeaderOpen = true
    //创建一个包含单元格索引路径的数组来实现插入单元格的操作:这些路径对应当前节的每个单元格
    var countOfRowsToInsert = sectionInfo.group.friends.count
    var indexPathsToInsert = NSMutableArray()
    for (var i = 0; i < countOfRowsToInsert; i++) {
        indexPathsToInsert.addObject(NSIndexPath(forRow: i, inSection: sectionOpened))
    }
    // 创建一个包含单元格索引路径的数组来实现删除单元格的操作:这些路径对应之前打开的节的单元格
    var indexPathsToDelete = NSMutableArray()
    var previousOpenSectionIndex = opensectionindex
    if previousOpenSectionIndex != NSNotFound {
        var previousOpenSection: SectionInfo = sectionInfoArray[previousOpenSectionIndex] as SectionInfo
        previousOpenSection.headerView.HeaderOpen = false
        previousOpenSection.headerView.toggleOpen(false)
        var countOfRowsToDelete = previousOpenSection.group.friends.count
        for (var i = 0; i < countOfRowsToDelete; i++) {
            indexPathsToDelete.addObject(NSIndexPath(forRow: i, inSection: previousOpenSectionIndex))
        }
    }
    // 设计动画,以便让表格的打开和关闭拥有一个流畅的效果
    var insertAnimation: UITableViewRowAnimation
    var deleteAnimation: UITableViewRowAnimation
    if previousOpenSectionIndex == NSNotFound || sectionOpened < previousOpenSectionIndex {
        insertAnimation = UITableViewRowAnimation.Top
        deleteAnimation = UITableViewRowAnimation.Bottom
    }else{
        insertAnimation = UITableViewRowAnimation.Bottom
        deleteAnimation = UITableViewRowAnimation.Top
    }
    // 应用单元格的更新
    self.tableView.beginUpdates()
    self.tableView.deleteRowsAtIndexPaths(indexPathsToDelete, withRowAnimation: deleteAnimation)
    self.tableView.insertRowsAtIndexPaths(indexPathsToInsert, withRowAnimation: insertAnimation)
    opensectionindex = sectionOpened
    self.tableView.endUpdates()
    }

我们来解析一下这个函数。首先创建了一个包含单元格索引路径的数组来实现插入单元格的操作,这个数组存放有将要打开的用户组 中的所有朋友信息。接下来是创建了一个包含单元格索引路径的数组来实现删除单元格的操作。首先将先前已打开的用户组关闭(调用toggleOpen()函 数),随后将数组中放入已打开的用户组中的所有朋友信息。

最后,执行删除行的操作,再执行插入行的操作,注意顺序不要颠倒了(想想为什么?)

我们使用beginUpdates()方法和endUpdates()方法将删除、插入操作“包”了起来,这两个方法是配合起来使用的,标记了一个tableView的动画块,分别代表动画的开始和结束。

func sectionHeaderView(sectionHeaderView: SectionHeaderView, sectionClosed: Int) {
    // 在表格关闭的时候,创建一个包含单元格索引路径的数组,接下来从表格中删除这些行
    var sectionInfo: SectionInfo = self.sectionInfoArray[sectionClosed] as SectionInfo
    sectionInfo.headerView.HeaderOpen = false
    var countOfRowsToDelete = self.tableView.numberOfRowsInSection(sectionClosed)
    if countOfRowsToDelete > 0 {
        var indexPathsToDelete = NSMutableArray()
        for (var i = 0; i < countOfRowsToDelete; i++) {
            indexPathsToDelete.addObject(NSIndexPath(forRow: i, inSection: sectionClosed))
        }
        self.tableView.deleteRowsAtIndexPaths(indexPathsToDelete, withRowAnimation: UITableViewRowAnimation.Top)
    }
    opensectionindex = NSNotFound
}

和上面的方法类似,在此我们不做过多的解释了。

到这里,我们的教程就结束了。有兴趣的同学可以去我的 Github 上面下载demo项目的源代码:TVAnimationsGestures-Swift,这个 demo 是苹果官方提供的 demo 的 Swift 版本,大家可以基于这个版本来实现可展开可收缩的表视图。