65.9K
CodeProject 正在变化。 阅读更多。
Home

iOS的可折叠表格视图

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.93/5 (24投票s)

2011年8月15日

CPOL

16分钟阅读

viewsIcon

371790

downloadIcon

6770

一组源代码文件,扩展了UITableView,使其具有可折叠和展开的section。

介绍 

我曾经在一个iPhone应用程序上工作,该应用程序显示了大量输入项,这些输入项按不同类别分组,显示在一个UITableView中。要更改其中一个输入项的值,用户会按下表视图中的相应行,并在出现的单独屏幕中更改该值。该表视图为每个类别都有一个section,每个section包含每个输入项的table cell(行)。

问题是输入项的数量非常非常多,导致用户无法很好地概览。即使是从表顶滚动到底部也很费力。 

我们决定用户应该能够通过简单地按下section的header来折叠和展开section(类别)。我们要求实现这一点的代码应该是可重用的,并且需要对现有代码进行最少的更改。 

下面的截图展示了带有可折叠section的表视图的外观: 

iOS 6 及更早版本iOS 7 及更高版本
    collapsabletableview/collapsable_table_view_iOS7.png

实现

我认为实现上述目标的最佳方法是创建一个名为CollapsableTableViewUITableView类的子类。这确保了代码的可重用性。如果做得好,不需要对UITableView的委托或数据源进行任何更改——它们会将表视图视为常规UITableView。唯一必要的更改是,在xib文件中将UITableView的类更改为这个新的子类。为了确保客户端可以将表视图像常规UITableView一样使用,我们必须尝试通过UITableView类的接口,包括UITableViewDelegateUITableViewDataSource协议,来完全操作表视图。

可折叠表视图必须以某种方式跟踪哪些section已折叠(收起),哪些已展开。也许最明显的方法是维护一个已展开section索引的集合,或者一个布尔数组,其中每个索引的值表示相应的section是否已展开。然而,如果我们假设表视图的客户端可以添加和删除section(在我们的场景中就是这种情况),则section的索引不会保持固定,因此使用索引最多也是麻烦的。因此,我们必须为section找到不同的标识符。我们可以为此目的使用section的header文本。当然,这假设section的header文本唯一地标识了该section,并且其header文本保持不变,但考虑到必须遵循UITableView类的接口的限制,这可能是我们能做的最好的了。这还假设客户端实现了UITableViewDelegate协议的tableView:titleForHeaderInSection:选择器用于所有table cells。对于我们的项目,情况就是如此。在使用代码部分,我们解释了我们的类也支持实现tableView:viewForHeaderInSection:选择器的客户端。

为了更方便地管理header视图,我们创建了一个名为CollapsableTableViewHeaderViewControllerUIViewController类。该类有两个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添加到分配给CollapsableTableViewHeaderViewControllerUIView上。这个UITapGestureRecognizer被配置为在header视图被点击时调用CollapsableTableViewHeaderViewController的一个方法。在新代码中,这种技术不再有效,因为当用户点击header时,CollapsableTableViewHeaderViewController通常已经被释放了。

解决此问题最明显的一个方法可能是将UITapGestureRecognizer配置为调用一个对象中的选择器,该对象在用户点击header时不会被释放。此对象的一些选择包括

  • CollapsableTableView
  • UIView
  • UITapGestureRecognizer

第二个选择行不通,因为我们无法控制传递到setView:方法中的UIView对象来自何处(也就是说,我们不能子类化UIView以添加额外的方法;也许我们可以用我们自己的UIView子类的实例来包装传入的UIView对象,但我们就不往那方面考虑了!)。向CollapsableTableView添加方法是一个选项,尽管没有参数的方法不起作用,因为CollapsableTableView不知道哪个header被点击了。然而,在UITapGestureRecognizer的文档中,我们看到替代的选择器类型是一个接受UITapGestureRecognizer对象作为参数的选择器。但是,我们必须子类化UITapGestureRecognizer来添加一个存储header标题字符串的属性。所以,如果我们必须子类化UITapGestureRecognizer,那么选择第三种选择并将UITapGestureRecognizer配置为调用其自身中的选择器可能会更优雅。这正是实现中所采取的方法:我们使用一个名为CollapsableTableViewTapRecognizerUITapGestureRecognizer子类,并定义如下:

#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能够响应这两个选择器,它必须实现UITableViewDelegateUITableViewDataSource协议,并在运行时将其委托和数据源属性设置为……它自己!然而,对这些协议的选择器的许多调用必须转发给客户端,因此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折叠或展开时,以及在完成折叠或展开时得到通知。

默认的折叠和展开指示符(默认为“+”和“–”分别)可以使用collapsedIndicatorexpandedIndicator属性设置。
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会将该标签的文本设置为“+”或“–”(除非collapsedIndicatorexpandedIndicator属性已被设置为不同的字符串)。

CollapsableTableView的客户端可能不知道它不是在处理一个常规UITableView,但如果它确实知道UITableViewCollapsableTableView,那么它可以将其对象强制转换为后者类型,并使用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.msetTitleText:选择器中完成)。可以通过将标签的numberOfLines属性设置为最大行数来限制header的行数。
    • 1st.osama指出,setIsCollapsed:forHeaderWithTitle:选择器直到在整个相应header的section被显式重新加载后才生效。这已得到修复。
    • 当最后一个section被折叠然后展开时,表视图会滚动到该section最多第五行,以便用户可以看到一些行已出现。
  • 2011/11/05
    • 修复了在iOS 4上发生的导致普通样式表视图的header标题消失的bug。
  • 2011/11/27
    • CollapsableTableView已被更改为不再存储其所有section的CollapsableTableViewHeaderViewController对象。此更改的目的是使实现更节省内存,特别是对于有很多section的表。这是一个非常大的变化,但文章中的相关部分已更新。
    • CollapsableTableViewgetHeaderTitleToIsCollapsedMap方法已被只读属性headerTitleToIsCollapsedMap替换。
    • 自定义header视图现在可以包含一个具有魔术tag值36(由CollapsableTableView.h中的常量COLLAPSED_INDICATOR_LABEL_TAG定义)的标签,当相应header折叠或展开时,该标签的文本将被更新为“+”或“–”。
  • 2012/01/26
    • 根据alaska22的要求,除了initWithCoder:方法之外,CollapsableTableView还重写了initinitWithFrame: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为空时,它的折叠/展开指示符不会显示。
    • 折叠和展开指示符现在可以通过CollapsableTableViewcollapsedIndicatorexpandedIndicator属性设置为自定义字符串。
  • 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的新外观。 
© . All rights reserved.