iOS的可折叠表格视图






4.93/5 (24投票s)
一组源代码文件,扩展了UITableView,使其具有可折叠和展开的section。
介绍
我曾经在一个iPhone应用程序上工作,该应用程序显示了大量输入项,这些输入项按不同类别分组,显示在一个UITableView
中。要更改其中一个输入项的值,用户会按下表视图中的相应行,并在出现的单独屏幕中更改该值。该表视图为每个类别都有一个section,每个section包含每个输入项的table cell(行)。
问题是输入项的数量非常非常多,导致用户无法很好地概览。即使是从表顶滚动到底部也很费力。
我们决定用户应该能够通过简单地按下section的header来折叠和展开section(类别)。我们要求实现这一点的代码应该是可重用的,并且需要对现有代码进行最少的更改。
下面的截图展示了带有可折叠section的表视图的外观:
iOS 6 及更早版本 | iOS 7 及更高版本 |
![]() | ![]() |
实现
我认为实现上述目标的最佳方法是创建一个名为CollapsableTableView
的UITableView
类的子类。这确保了代码的可重用性。如果做得好,不需要对UITableView
的委托或数据源进行任何更改——它们会将表视图视为常规UITableView
。唯一必要的更改是,在xib文件中将UITableView
的类更改为这个新的子类。为了确保客户端可以将表视图像常规UITableView
一样使用,我们必须尝试通过UITableView
类的接口,包括UITableViewDelegate
和UITableViewDataSource
协议,来完全操作表视图。
可折叠表视图必须以某种方式跟踪哪些section已折叠(收起),哪些已展开。也许最明显的方法是维护一个已展开section索引的集合,或者一个布尔数组,其中每个索引的值表示相应的section是否已展开。然而,如果我们假设表视图的客户端可以添加和删除section(在我们的场景中就是这种情况),则section的索引不会保持固定,因此使用索引最多也是麻烦的。因此,我们必须为section找到不同的标识符。我们可以为此目的使用section的header文本。当然,这假设section的header文本唯一地标识了该section,并且其header文本保持不变,但考虑到必须遵循UITableView
类的接口的限制,这可能是我们能做的最好的了。这还假设客户端实现了UITableViewDelegate
协议的tableView:titleForHeaderInSection:
选择器用于所有table cells。对于我们的项目,情况就是如此。在使用代码部分,我们解释了我们的类也支持实现tableView:viewForHeaderInSection:
选择器的客户端。
为了更方便地管理header视图,我们创建了一个名为CollapsableTableViewHeaderViewController
的UIViewController
类。该类有两个xib文件。一个xib用于普通布局的表,另一个用于分组布局的表。该类包含所有可操作的视图中的标签的IB outlet。它存储一个布尔值,指示section是否已折叠。这个视图控制器类还确保其视图在我们点击它时通知我们,以便CollapsableTableView
可以采取必要的行动。
以下是CollapsableTableViewHeaderViewController
的.h文件的内容
#import <UIKit/UIKit.h>
#import "TapDelegate.h"
#import "CollapsableTableViewTapRecognizer.h"
@interface CollapsableTableViewHeaderViewController : UIViewController
{
IBOutlet UILabel *collapsedIndicatorLabel,*titleLabel,*detailLabel;
CollapsableTableViewTapRecognizer* tapRecognizer;
BOOL viewWasSet;
id<TapDelegate> tapDelegate;
NSString* fullTitle;
BOOL isCollapsed;
}
@property (nonatomic, retain) NSString* fullTitle;
@property (nonatomic, readonly) UILabel* titleLabel;
@property (nonatomic, retain) NSString* titleText;
@property (nonatomic, readonly) UILabel* detailLabel;
@property (nonatomic, retain) NSString* detailText;
@property (nonatomic, assign) id<TapDelegate> tapDelegate;
@property (nonatomic, assign) BOOL isCollapsed;
@end
collapsedIndicatorLabel
是一个小标签,根据section是折叠还是展开,显示“–”或“+”。当isCollapsed
的值改变时,collapsedIndicatorLabel
的文本会相应设置为“–”或“+”。titleLabel
是包含header文本的标签,而detailLabel
显示标题右侧的可选详细文本。
以下是TapDelegate
协议的定义
#import <UIKit/UIKit.h>
@protocol TapDelegate
- (void) view:(UIView*) view tappedWithIdentifier:(NSString*) identifier;
@end
当header视图被点击时,会调用view:tappedWithIdentifier:
选择器,并且CollapsableTableView
实现了TapDelegate
协议,以便它可以响应此操作来折叠或展开相应的header。当调用选择器时,它会使用view
参数的header视图和identifier
参数的header标题字符串,以便CollapsableTableView
可以进行查找,以确定header当前是否已折叠以及其当前的section索引是什么。
在此项目首次发布的实现中,该选择器由相应header视图的CollapsableTableViewHeaderViewController
调用。这之所以有效,是因为在该版本中,CollapsableTableView
存储(并因此保留)了其所有section的CollapsableTableViewHeaderViewController
。然而,为了使实现更节省内存——特别是对于有很多section的表——CollapsableTableView
被更改为不再这样做。结果是,当header视图出现时,CollapsableTableViewHeaderViewController
很可能在不久后就被释放(只要header UIView
在表中可见,它就会留在内存中)。这意味着当header视图被点击时,可能没有CollapsableTableViewHeaderViewController
来调用TapDelegate
选择器。
在我们寻找此问题的解决方案之前,让我们先看看在CollapsableTableViewHeaderViewController.m中,header视图的点击是如何先前被检测和处理的。
- (void) setView:(UIView*) newView
{
if (viewWasSet)
{
[self.view removeGestureRecognizer:tapRecognizer];
[tapRecognizer release];
}
[super setView:newView];
tapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self
action:@selector(headerTapped)];
[self.view addGestureRecognizer:tapRecognizer];
viewWasSet = YES;
}
- (void) headerTapped
{
[tapDelegate viewTapped:self.view ofViewController:self];
}
因此,我们覆盖了UIViewController
类的setView:
方法,以便将一个UITapGestureRecognizer
添加到分配给CollapsableTableViewHeaderViewController
的UIView
上。这个UITapGestureRecognizer
被配置为在header视图被点击时调用CollapsableTableViewHeaderViewController
的一个方法。在新代码中,这种技术不再有效,因为当用户点击header时,CollapsableTableViewHeaderViewController
通常已经被释放了。
解决此问题最明显的一个方法可能是将UITapGestureRecognizer
配置为调用一个对象中的选择器,该对象在用户点击header时不会被释放。此对象的一些选择包括
CollapsableTableView
UIView
UITapGestureRecognizer
第二个选择行不通,因为我们无法控制传递到setView:
方法中的UIView
对象来自何处(也就是说,我们不能子类化UIView
以添加额外的方法;也许我们可以用我们自己的UIView
子类的实例来包装传入的UIView
对象,但我们就不往那方面考虑了!)。向CollapsableTableView
添加方法是一个选项,尽管没有参数的方法不起作用,因为CollapsableTableView
不知道哪个header被点击了。然而,在UITapGestureRecognizer
的文档中,我们看到替代的选择器类型是一个接受UITapGestureRecognizer
对象作为参数的选择器。但是,我们必须子类化UITapGestureRecognizer
来添加一个存储header标题字符串的属性。所以,如果我们必须子类化UITapGestureRecognizer
,那么选择第三种选择并将UITapGestureRecognizer
配置为调用其自身中的选择器可能会更优雅。这正是实现中所采取的方法:我们使用一个名为CollapsableTableViewTapRecognizer
的UITapGestureRecognizer
子类,并定义如下:
#import <Foundation/Foundation.h>
#import "TapDelegate.h"
@interface CollapsableTableViewTapRecognizer : UITapGestureRecognizer
{
id<TapDelegate> tapDelegate;
NSString* fullTitle;
UIView* tappedView;
}
@property (nonatomic, assign) id<TapDelegate> tapDelegate;
@property (nonatomic, retain) NSString* fullTitle;
@property (nonatomic, assign) UIView* tappedView;
- (id) initWithTitle:(NSString*) theFullTitle andTappedView:(UIView*)
theTappedView andTapDelegate:(id<TapDelegate>) theTapDelegate;
@end
在initWithTitle:andTappedView:andTapDelegate:
方法中,CollapsableTableViewTapRecognizer
对象被配置为在视图被点击时调用私有方法headerTapped
。
- (void) headerTapped
{
[tapDelegate view:tappedView tappedWithIdentifier:fullTitle];
}
现在让我们回到CollapsableTableView
。当它从客户端获取section的header标题时,它需要能够进行查找,以查看header是否已折叠,以及header的section索引是什么。为此,我们维护两个独立的NSMutableDictionary
对象:一个将header标题映射到一个布尔值,指示header是否已折叠,另一个将header标题映射到一个整数,给出header的section索引。还有一个字典可以方便我们查找指定索引的header标题(当然,当客户端添加或删除section时,这个字典必须更新)。
那么,CollapsableTableView
实际上将如何折叠和展开section呢?嗯,一个折叠的section将只有0行,所以即使客户端为section返回正常的行数,CollapsableTableView
也会为折叠的section返回0行数,或者为展开的section返回客户端返回的行数。这表明CollapsableTableView
需要拦截对tableView:numberOfRowsInSection:
方法的调用。它还必须为每个section返回一个CollapsableTableViewHeaderViewController
的视图,所以它也必须拦截对tableView:viewForHeaderInSection:
方法的调用。因此,为了使CollapsableTableView
能够响应这两个选择器,它必须实现UITableViewDelegate
和UITableViewDataSource
协议,并在运行时将其委托和数据源属性设置为……它自己!然而,对这些协议的选择器的许多调用必须转发给客户端,因此CollapsableTableView
存储实际委托和数据源的引用,以便在这些情况下可以咨询它们。
- (void) setDelegate:(id <UITableViewDelegate>) newDelegate
{
[super setDelegate:self];
realDelegate = newDelegate;
}
- (void) setDataSource:(id <UITableViewDataSource>) newDataSource
{
[super setDataSource:self];
realDataSource = newDataSource;
}
这是CollapsableTableView
的接口文件
#import <Foundation/Foundation.h>
#import "TapDelegate.h"
#define COLLAPSED_INDICATOR_LABEL_TAG 36
#define BUSY_INDICATOR_TAG 37
@interface CollapsableTableView :
UITableView <UITableViewDelegate,UITableViewDataSource,TapDelegate>
{
id<UITableViewDelegate> realDelegate;
id<UITableViewDataSource> realDataSource;
id<CollapsableTableViewDelegate> collapsableTableViewDelegate;
...
}
@property (nonatomic,assign) id<CollapsableTableViewDelegate> collapsableTableViewDelegate;
@property (nonatomic,retain) NSString* collapsedIndicator;
@property (nonatomic,retain) NSString* expandedIndicator;
@property (nonatomic,assign) BOOL showBusyIndicator;
@property (nonatomic,assign) BOOL sectionsInitiallyCollapsed;
@property (nonatomic,readonly) NSDictionary* headerTitleToIsCollapsedMap;
- (void) setIsCollapsed:(BOOL) isCollapsed forHeaderWithTitle:(NSString*) headerTitle;
- (void) setIsCollapsed:(BOOL) isCollapsed forHeaderWithTitle:(NSString*)
headerTitle andView:(UIView*) headerView;
- (void) setIsCollapsed:(BOOL) isCollapsed forHeaderWithTitle:(NSString*)
headerTitle withRowAnimation:(UITableViewRowAnimation) rowAnimation;
- (void) setIsCollapsed:(BOOL) isCollapsed forHeaderWithTitle:(NSString*)
headerTitle andView:(UIView*) headerView
withRowAnimation:(UITableViewRowAnimation) rowAnimation;
@end
CollapsableTableView
的实现几乎遵循了到目前为止的讨论。接下来的段落简要解释了该类的公共属性和方法的用途。
collapsableTableViewDelegate
属性可以设置为实现CollapsableTableViewDelegate
属性的对象,以便该对象将在section折叠或展开时,以及在完成折叠或展开时得到通知。
默认的折叠和展开指示符(默认为“+”和“–”分别)可以使用collapsedIndicator
和expandedIndicator
属性设置。showBusyIndicator
属性的默认值为YES
,如果设置了,它会使一个活动指示器视图(spinner)(位于header视图中,其tag号由BUSY_INDICATOR_TAG
定义)在折叠或展开其section的header视图时动画,如果耗时超过0.5秒。sectionsInitiallyCollapsed
属性的默认值为NO
,并控制新section是否初始折叠。
headerTitleToIsCollapsedMap
属性提供了一个NSDictionary
,它将header的标题字符串映射到一个NSNumber
对象,该对象包含一个布尔值,指示header是否已折叠。
setIsCollapsed:forHeaderWithTitle:...
方法用于以编程方式折叠或展开section。如果客户端拥有相应header的UIView
的引用,它可以调用包含andView:
的其中一个方法,并将该UIView
作为参数。如果调用了这两个其他方法中的任何一个,则必须重新加载相应的section(和header视图),并且动画有时不如使用...andView:
方法。
Using the Code
要在Xcode
项目中使用的源文件是zip文件中的CollapsableTableView文件夹中的文件(下载源代码 - 101.9 KB)。
CollapsableTableView
可以使用得就像一个常规UITableView
一样,只要客户端为所有table cells实现了tableView:titleForHeaderInSection:
选择器(而不是tableView:viewForHeaderInSection:
)。唯一必要的更改是在xib中将UITableView
的类更改为CollapsableTableView
。要做到这一点,请打开xib文件,选择UITableView
,打开Identity Inspector,然后在Class字段旁边键入“CollapsableTableView
”。
CollapsableTableView
的实现也允许使用tableView:viewForHeaderInSection:
,但在这里它无法访问header的标题字符串,它通常用作header的标识符。相反,它使用字符串“Tag %i”,其中%i是返回视图的tag
属性的值(如果tag
为0,但section索引不为0,则此数字在CollapsableTableView
中默认为section索引)。这意味着,如果客户端为某些cell返回视图(而不是header文本字符串),并且它可以添加和删除section,那么它必须为每个section对应的视图分配一个唯一的tag
编号。
如果客户端为某些cell返回视图,那么这些视图可以包含一个标签,指示header是否已折叠。只需将该标签的tag
属性设置为CollapsableTableView.h中定义的常量COLLAPSED_INDICATOR_LABEL_TAG
的值。当section折叠或展开时,CollapsableTableView
会将该标签的文本设置为“+”或“–”(除非collapsedIndicator
或expandedIndicator
属性已被设置为不同的字符串)。
CollapsableTableView
的客户端可能不知道它不是在处理一个常规UITableView
,但如果它确实知道UITableView
是CollapsableTableView
,那么它可以将其对象强制转换为后者类型,并使用headerTitleToIsCollapsedMap
属性来确定哪些section已折叠,并使用setIsCollapsed:forHeaderWithTitle:
方法以编程方式折叠或展开section。
正如在实现部分提到的,CollapsableTableView
也允许在header标题的右侧显示详细文本。要利用此功能,请在tableView:titleForHeaderInSection:
中返回一个格式为“Header Text|Detail Text”的字符串。
历史
- 2011/08/13
- 初始版本
- 2011/10/29
- 在之前的iOS 5版本中,所有header的高度都为0。
tableView:heightForHeaderInSection:
选择器现在会查找相应的header视图并直接询问其高度,这解决了问题。 - 添加了对多行header的支持。如果视图控制器xib中相应header中的标签的
numberOfLines
属性设置为0,并且header文本不适合单行,则该标签会将文本拆分成尽可能多的行,并且header视图控制器将设置标签和header视图的高度,以便所有行都能适应(这在CollapsableTableViewHeaderViewController.m的setTitleText:
选择器中完成)。可以通过将标签的numberOfLines
属性设置为最大行数来限制header的行数。 - 1st.osama指出,
setIsCollapsed:forHeaderWithTitle:
选择器直到在整个相应header的section被显式重新加载后才生效。这已得到修复。 - 当最后一个section被折叠然后展开时,表视图会滚动到该section最多第五行,以便用户可以看到一些行已出现。
- 2011/11/05
- 修复了在iOS 4上发生的导致普通样式表视图的header标题消失的bug。
- 2011/11/27
CollapsableTableView
已被更改为不再存储其所有section的CollapsableTableViewHeaderViewController
对象。此更改的目的是使实现更节省内存,特别是对于有很多section的表。这是一个非常大的变化,但文章中的相关部分已更新。CollapsableTableView
的getHeaderTitleToIsCollapsedMap
方法已被只读属性headerTitleToIsCollapsedMap
替换。- 自定义header视图现在可以包含一个具有魔术
tag
值36(由CollapsableTableView.h中的常量COLLAPSED_INDICATOR_LABEL_TAG
定义)的标签,当相应header折叠或展开时,该标签的文本将被更新为“+”或“–”。 - 2012/01/26
- 根据alaska22的要求,除了
initWithCoder:
方法之外,CollapsableTableView
还重写了init
、initWithFrame:
和initWithFrame:style:
方法来执行必要的初始化。这是为了让CollapsableTableView
也可以通过编程方式构建,而不是通过xib构建。 - 2012/02/12
- 正如magikcm在评论中所建议的,我对扩展section时的行插入进行了优化。我通过实现“不诚实的代理数据源”策略来实现这一点,该策略解释在此帖子的第一个答案中。
- 2012/08/10
以前,当表视图需要知道header视图的高度时,可折叠表视图会实际创建该header以确定和返回其高度。这会在表视图需要重新计算总高度时导致长时间延迟,因为在此过程中会查询所有header的高度。例如,展开或折叠section时会发生这种情况。随着section数量的增加,延迟尤其明显。
可折叠表视图现在缓存所有header视图的高度。这几乎消除了折叠或展开section时发生的过度延迟。
- 美学改进:展开section的header视图的折叠/展开指示符现在显示一个长破折号字符而不是常规的短破折号字符。
- 2012/11/14
- 当一个section为空时,它的折叠/展开指示符不会显示。
- 折叠和展开指示符现在可以通过
CollapsableTableView
的collapsedIndicator
和expandedIndicator
属性设置为自定义字符串。 - 2012/12/23
- 如果客户端为header同时提供视图和标题(通过实现
tableView:viewForHeaderInSection:
和tableView:titleForHeaderInSection:
),那么当为section返回的视图不是nil
时,将优先使用视图。
页脚的处理方式类似。这种行为与UITableView
的实现方式相对应。 - 添加了对section页脚的支持。
- 修复了当最后一个section不包含行并且被点击时发生的崩溃。
- 当一个section折叠或展开所需时间超过0.5秒时,header视图上会出现一个活动指示器。(哇!使用多线程进行用户界面编程很棘手!)
可以通过设置showBusyIndicator
属性(默认为开启)来打开或关闭此功能。要在自定义header视图中启用此行为,请在header视图中添加一个UIActivityIndicatorView
,其tag值为BUSY_INDICATOR_TAG
(在CollapsableTableView.h中定义为37
)。 - 可以为
CollapsableTableView
分配一个CollapsableTableViewDelegate
,以便在section开始折叠或展开时,或在完成折叠或展开时通知该委托。 CollapsableTableView
的新属性sectionsInitiallyCollapsed
控制新section是否初始折叠。默认值为NO
。- 2013/01/18
- 修复了当在折叠的section内的行上调用
scrollToRowAtIndexPath:
…、selectRowAtIndexPath:
…或deselectRowAtIndexPath:
…时发生的崩溃。 - 修复了上述方法,以便当它们以animated=
YES
调用时,效果将立即(同步)发生。 - 修复了偶尔在折叠和展开section时发生的崩溃。
- 实现了删除行优化。有了这个,折叠具有非常多行的section现在应该更快了。
CollapsableTableView
已与Storyboard中的静态单元格兼容。- 2013/02/10
- 修复了双击大section的header时发生的崩溃。
- 2013/09/08
- 修复了在iOS 6.1上旋转时使用自定义header视图时发生的崩溃。
- 在section扩展后,实现了即时编程行选择或行滚动。
- 2013/09/23
- 修复了一个bug,即在向空section添加行后,折叠/展开指示符未显示。
- 2013/10/26
- 针对iOS 7进行了优化。在iOS 7或更高版本上运行时,可折叠表视图现在为表section的header和footer使用单独的xib文件,以适应iOS 7的新外观。