UICollectionView及其新功能drag and drop

UICollectionView是我们常说的集合视图,它在iOS 6中引入,是iOS开发者中最受欢迎的UI元素之一。其布局灵活、可变,可用于显示有序数据项集,最常见的用途是以类似于网格的形式呈现item,除此之外还可以通过子类化UICollectionViewLayout类,精准地控制可视化元素布局,并动态改变布局。因此,可以实现网格、堆栈、圆形、动态变化等形式布局,以及其它任何你可以想象出的布局。

CollectionView.png

UICollectionView将数据源和用于呈现数据的视觉元素进行了严格的分离。下图显示了UICollectionView与相关对象关系:

UICollectionView.png

其中,data source提供用于呈现数据的视图对象,collection view layout提供视图布局信息,而collection view负责将数据和布局信息合并后呈现到屏幕上。需要注意的是,在创建UICollectionView时,必须传递一个UICollectionViewLayout对象,这里的UICollectionViewLayout是一个抽象基类abstract base class,不能直接使用,必须使用其子类。例如,在创建网格布局时一般使用UICollectionViewFlowLayout具体concrete类。

下面表格列出了UIKit中与集合视图相关的类,并按照各自扮演的角色进行分类:

用途 类/协议 描述
集合视图和集合视图控制器 UICollectionView

UICollectionViewController
UICollectionView派生自UIScrollView,定义集合视图内容区域,将dataSource的数据与layout提供的布局信息合并后呈现到屏幕上。

UICollectionViewController为集合视图提供了控制器级别支持,UICollectionViewController的使用是可选的。
内容控制 UICollectionViewDataSource协议

UICollectionViewDelegate协议
dataSource为集合视图提供数据,是UICollectionView中最重要、必须提供的对象。要实现dataSource中的方法,必须创建一个遵守UICollectionViewDataSource协议的对象。

通过UICollectionViewdelegate对象可以监听集合视图状态、自定义视图。例如,使用delegate跟踪item是否高亮、选中。与数据源对象不同,代理对象不是必须实现。
呈现视图 UICollectionReusableView

UICollectionViewCell
UICollectionView中显示的所有视图都必须是UICollectionReusableView类的实例,该类支持回收机制(循环使用视图,而非创建新的视图),以便提高性能,特别是在滑动屏幕时。

UICollectionViewCell用来显示主要数据,也是可重用视图。
布局 UICollectionViewLayout

UICollectionViewLayoutAttributes

UICollectionViewUpdateItem
使用UICollectionViewLayout的子类为集合视图内元素提供位置、大小、视觉属性等布局信息。

在布局过程中,layout对象创建UICollectionViewLayoutAttributes实例,用以告知特定item如何布局。

当collection view的数据源发生插入、删除、移动变化时,UICollectionView会创建UICollectionViewUpdateItem类的实例,并发送给layoutprepareForCollectionViewUpdates:方法,layout会为即将到来的布局变化作出准备。你不需要创建该类的实例。
Flow layout UICollectionViewFlowLayout

UICollectionViewDelegateFlowLayout协议
UICollectionViewFlowLayout类是用于实现网格或其它基于行布局的具体类,可以直接使用,也可以将其与UICollectionViewDelegateFlowLayout代理结合使用,以便自定义布局。

注意:上面的UICollectionViewLayoutUICollectionViewReusableView类必须子类化才可以使用,其它类可以直接使用。

另外,UICollectionView自iOS 6引入以来,其功能也是不断丰富的:

  • iOS 9中为集合视图添加了交互式重新排序功能。
  • iOS 10中为集合视图添加了预加载cell数据功能,这在获取cell内容非常耗时(例如网络请求)的情况下非常有用。
  • iOS 11增加了系统范围的拖放操作drag and drop,让用户可以快速简单的将文本、图像和文件从一个app移动到另一个app。

现在我们就通过这篇文章,对UICollectionView进行全面的学习。

1.创建demo

这篇文章将使用纯代码创建一个UICollectionView,用来学习集合视图。效果如下:

CollectionViewDragAndDrop.gif

打开Xcode,点击File > New > Project...,选择iOS > Application > Single View App模板,点击NextProduct NameCollectionViewLanguageObjective-C,点击Next;选择文件位置,点击Create创建工程。

2.添加UICollectionView

为视图控制器添加UICollectonView,进入ViewController.m,在接口部分添加以下声明:

@interface ViewController ()

@property (strong, nonatomic) UICollectionView *collectionView;
@property (strong, nonatomic) UICollectionViewFlowLayout *flowLayout;

@end

在实现部分初始化UICollectionViewFlowLayoutUICollectionView对象。

- (UICollectionViewFlowLayout *)flowLayout {
    if (!_flowLayout) {
        // 初始化UICollectionViewFlowLayout对象,设置集合视图滑动方向。
        _flowLayout = [[UICollectionViewFlowLayout alloc] init];
        _flowLayout.scrollDirection = UICollectionViewScrollDirectionVertical;
    }
    return _flowLayout;
}

- (UICollectionView *)collectionView {
    if (!_collectionView) {
        // 设置集合视图内容区域、layout、背景颜色。
        _collectionView = [[UICollectionView alloc] initWithFrame:self.view.bounds collectionViewLayout:self.flowLayout];
        _collectionView.backgroundColor = [UIColor whiteColor];
        
        // 设置代理。
//        _collectionView.dataSource = self;
//        _collectionView.delegate = self;
    }
    return _collectionView;
}

最后添加self.collectionView到视图控制器。

- (void)viewDidLoad {
    [super viewDidLoad];
    
    // 添加collection view。
    [self.view addSubview:self.collectionView];
}

3.重用视图以提高性能

UICollectionView使用了视图回收机制以提高性能。当视图被滑出屏幕外时,从视图层级结构中移除的视图不会直接删除,而是置于重用队列中。当UICollectionView显示新的内容时,将从重用队列中获取视图、填充新的内容。为便于回收和重用,UICollectionView显示的所有视图必须派生自UICollectionReusableView

UICollectionView支持三种不同类型的可重用视图,每种视图都有特定的用途:

  • 集合视图单元格UICollectionViewCell:显示集合视图的主要内容。cell必须是UICollectionViewCell类的实例。cell默认支持管理自身高亮highlight选中selection状态。
  • 补充视图Supplementary View:显示关于section的信息。和cell一样supplementary view也是数据驱动的,但与cell不同的是supplementary view的使用不是必须的,layout控制supplementary view的位置和是否使用。例如,流式布局UICollectionViewFlowLayout可以选择性添加页眉section header页脚section footer补充视图。
  • 装饰视图Decoration View:由layout完全拥有的装饰视图,且不受数据源的束缚。例如,layout可以使用装饰视图自定义集合视图背景。

UITableView不同,UICollectionView不会在数据源提供的cell和supplementary view 上施加特定的样式,只提供空白的画布。你需要为其构建视图层次结构、显示图像,也可以动态绘制内容。

UICollectionView的数据源对象负责提供cell和supplementary view,但dataSource从来不会直接创建cell、supplementary view。当需要展示新的视图时,数据源对象使用集合视图的dequeueReusableCellWithReuseIdentifier: forIndexPath:dequeueReusableSupplementaryViewOfKind: withReuseIdentifier: forIndexPath:方法出列所需类型的视图。如果队列存在所需类型的视图,则会直接出列所需视图;如果队列没有所需视图,则会利用提供的nib文件、storyboard或代码创建。

现在,添加UICollectionReusableView类,在重用视图上添加UILabel用以显示header、footer相关内容。

创建一个新的文件,选择iOS > Source > Cocoa Touch Class模板,点击NextClass内容为CollectionReusableViewSubclass of一栏选择UICollectionReusableView,点击Next;选择文件位置,点击Create创建文件。

进入CollectionReusableView.h,声明一个label属性。

@interface CollectionReusableView : UICollectionReusableView

@property (strong, nonatomic) UILabel *label;

@end

进入CollectionReusableView.m,在实现部分初始化UILabel对象:

- (instancetype)initWithFrame:(CGRect)frame {
    self = [super initWithFrame:frame];
    if (self) {
        // 初始化label,设置文字颜色,最后添加label到重用视图。
        _label = [[UILabel alloc] initWithFrame:CGRectMake(20, 0, self.bounds.size.width-40, self.bounds.size.height)];
        _label.textColor = [UIColor blackColor];
        [self addSubview:_label];
    }
    return self;
}

4.数据源方法

UICollectionView必须有数据源data source,数据源对象为UICollectionView提供展示的内容。数据源对象可能来自于app的data model,也可能来自管理UICollectionView的视图控制器。数据源对象必须遵守UICollectionViewDataSource协议,并为UICollectionView提供以下内容:

  • 通过实现numberOfSectionsInCollectionView:方法获取集合视图包含的section数量。如果没有实现该方法,section数量默认为1。
  • 通过实现collectionView: numberOfItemsInSection:方法获取指定section所包含的item数量。
  • 通过实现collectonView: cellForItemAtIndexPath:方法返回指定item所使用的视图类型。

Section和item是UICollectionView基本组织结构。UICollectionView至少包含一个section,每个section包含零至多个item。Item用来显示主要内容,section将这些item分组显示。

要实现UICollectionViewDataSource数据源方法,必须遵守UICollectionViewDataSource协议。在ViewController.minterface声明遵守UICollectionViewDataSource协议:

@interface ViewController ()<UICollectionViewDataSource>

将数据源委托给当前控制器,需要将collectionView初始化方法中的_collectionView.dataSource = self代码取消注释。

下面实现UICollectionViewDataSource协议方法:

- (NSInteger)numberOfSectionsInCollectionView:(UICollectionView *)collectionView {
    return 2;
}

- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
    return 6;
}

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(nonnull NSIndexPath *)indexPath {
    UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:cellIdentifier forIndexPath:indexPath];
    // randomColor为UIColor类扩展方法。
    cell.backgroundColor = [UIColor randomColor];
    return cell;
}

- (UICollectionReusableView *)collectionView:(UICollectionView *)collectionView viewForSupplementaryElementOfKind:(NSString *)kind atIndexPath:(NSIndexPath *)indexPath {
    CollectionReusableView *reusableView;
    if ([kind isEqualToString:UICollectionElementKindSectionHeader]) {
        // 设置header内容。
        reusableView = [collectionView dequeueReusableSupplementaryViewOfKind:UICollectionElementKindSectionHeader withReuseIdentifier:headerIdentifier forIndexPath:indexPath];
        reusableView.label.textAlignment = NSTextAlignmentCenter;
        reusableView.label.text = [NSString stringWithFormat:@"Section %li",indexPath.section];
    } else {
        // 设置footer内容。
        reusableView = [collectionView dequeueReusableSupplementaryViewOfKind:UICollectionElementKindSectionFooter withReuseIdentifier:footerIdentifier forIndexPath:indexPath];
        reusableView.label.textAlignment = NSTextAlignmentNatural;
        reusableView.label.text = [NSString stringWithFormat:@"Section %li have %li items",indexPath.section,[collectionView numberOfItemsInSection:indexPath.section]];
    }
    return reusableView;
}

NSTextAlignmentNatural会使用app当前本地化方式对齐文本。如果默认从左到右对齐,则为NSTextAlignmentLeft;如果默认从右到左对齐,则为NSTextAlignmentRight

通过上面代码可以看到,collectionView有两个section,每个section有6个item。randomColorUIColor分类扩展方法。

现在添加UIColor扩展文件,点击File > New > File...,选择iOS > Source > Objective-C File模板,点击Next;在File名称一栏填写RandomColorFile Type选取CategoryClass选取UIColor,点击Next;选择文件位置,点击Create创建文件。

进入UIColor+RandomColor.h方法,添加以下类方法:

@interface UIColor (RandomColor)

+ (UIColor *)randomColor;

@end

进入UIColor+RandomColor.m,在实现部分添加以下代码:

+ (UIColor *)randomColor {
    CGFloat red = arc4random_uniform(255)/255.0;
    CGFloat green = arc4random_uniform(255)/255.0;
    CGFloat blue = arc4random_uniform(255)/255.0;
    return [UIColor colorWithRed:red green:green blue:blue alpha:1.0];
}

在调用dequeueReusableCellWithReuseIdentifier: forIndexPath:方法前,必须使用registerClass: forCellWithReuseIdentifier:registerNib: forCellWithIdentifier:方法告知集合视图如何创建指定类型cell。当重用队列中没有指定类型cell时,collection view会使用上述注册方法自动创建cell。如果你想要取消注册,可以将class指定为nil。注册时的标志符不能为nil和空字符串。

注册supplementary view时,还需要额外指定一个称为类型字符串kind string的附加标志符。layout负责定义各自支持的补充视图种类。例如,UICollectionViewFlowLayout支持两种补充视图:section header、section footer。为了识别这两种类型视图,flow layout定义了UICollectionElementKindSectionHeaderUICollectionElementKindSectionFooter字符串常量。在布局时,集合视图将包括类型字符串和其它布局属性的layout发送给数据源,数据源使用类型字符串kind string重用标志符reuse identifier决定出列视图。

注册是一次性操作,且必须在尝试出列cell、supplementary view前注册。注册之后,可以根据需要出列任意次数cell、supplementary view,无需再次注册。不建议出列一个或多个视图后更改注册信息,最好一次注册,始终使用。

下面注册cell、header、footer:

static NSString * const cellIdentifier = @"cellIdentifier";
static NSString * const headerIdentifier = @"headerIdentifier";
static NSString * const footerIdentifier = @"footerIdentifier";

@implementation ViewController

- (void)viewDidLoad {
    ...
    
    // 注册cell、headerView。
    [self.collectionView registerClass:[UICollectionViewCell class] forCellWithReuseIdentifier:cellIdentifier];
    [self.collectionView registerClass:[CollectionReusableView class] forSupplementaryViewOfKind:UICollectionElementKindSectionHeader withReuseIdentifier:headerIdentifier];
    [self.collectionView registerClass:[CollectionReusableView class] forSupplementaryViewOfKind:UICollectionElementKindSectionFooter withReuseIdentifier:footerIdentifier];
}

现在运行demo,显示如下:

CollectionViewExDelegate.png

虽然是网格布局,但cell大小、间距均需修改,且没有显示section header、section footer,这些内容由UICollectionViewDelegateFlowLayout协议定义。

5.使用Flow Layout

UICollectionViewDelegate是一个可选但推荐实现的协议,用于管理与内容呈现、交互相关的问题。其主要工作是管理cell的高亮、选中,但可以为其扩展其它功能。例如,流布局UICollectionViewDelegateFlowLayout协议增加了控制cell大小、间距功能。

Flow Layout实现了基于行的中断布局,即layout将cell放置在线性路径上,并尽可能多的沿着该路径排布cell。如果当前路径空间不足,layout将创建一个新路径并继续布局。下图显示了垂直滚动的流布局。在这种情况下,cell横向放置,新增加的路径位于之前路径下方。Section可以选择性的添加section header、section
footer视图。

CollectionViewFlowLayout.png

Flow Layout除了实现网格布局,还可以实现许多不同设计。例如:通过调整cell间距minimumInteritemSpacing、大小itemSize来创建在滚动方向只有一个cell的布局。cell大小也可以不同,这样会产生比传统网格更不对称的布局。

可以通过Xcode中的Interface Builder,或纯代码配置flow layout。步骤如下:

  1. 创建flow layout,并将其分配给UICollectionView
  2. 配置cell大小itemSize。如果没有设置,默认宽高均为50
  3. 配置cell行minimumLineSpacing、cell间minimumInteritemSpacing间距,默认值为10.0
  4. 如果用到了section header、section footer,配置其大小headerReferenceSizefooterReferenceSize。默认值为(0,0)。
  5. 指定layout滑动方向scrollDirection。默认滑动方向为UICollectionViewScrollDirectionVertical

UICollectionView所使用的layout与应用程序视图层级结构中使用的自动布局Auto Layout不同,不要混淆集合视图内layout对象与父视图内重新定位子视图的layoutSubviewslayout对象从不直接触及其管理的视图,因为实质上layout并不拥有任何视图。相反,layout只生成集合视图中cell、supplementary view、decoration view的位置、大小和可视外观属性,并将这些属性提供给UICollectionView,由UICollectionView将这些属性应用于实际视图对象。

声明ViewController遵守UICollectionViewDelegateUICollectionViewDelegateFlowLayout协议。将delegate赋给当前控制器,即取消collectionView初始化方法中_collectionView.delegate = self;的注释。

5.1设置cell大小itemSize

所有cell大小一致,最为快捷方式是为itemSize属性赋值,如果cell大小不同,则必须使用collectionView: layout: sizeForItemAtIndexPath:方法。

itemSize.png

如果cell大小不同,则每行cell数量可能不同。

进入ViewController.m,在实现部分添加以下代码,配置cell大小。

// 设置item大小。
- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
    return CGSizeMake(153, 128);
}

运行demo,如下所示:

CollectionViewItemSizePre.png

5.2设置section header和section footer大小

在布局section header、section footer时,只有与滑动方向相同的值会被采用。例如,垂直滚动的UICollectionViewlayout只使用colllectionView: layout: referenceSizeForHeaderInSection:collectionView: layout: referenceSizeForFooterInSection:headerReferenceSizefooterReferenceSize提供的高,宽会被设置为UICollectionView的宽。如果滑动方向的长度被设置为0,则supplementary view不可见。

进入ViewController.m,在实现部分添加以下代码,设置section header、section footer大小。

// 设置section header大小。
- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout referenceSizeForHeaderInSection:(NSInteger)section {
    return section == 0 ? CGSizeMake(40, 40) : CGSizeMake(45, 45);
}

// 设置section footer大小。
- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout referenceSizeForFooterInSection:(NSInteger)section {
    return CGSizeMake(35, 35);
}

运行demo,如下所示:

SupplementaryView.png

5.3设置item间距minimumInteritemSpacing

利用flow layout可以指定cell间、行间最小间距,但其实际间距可能大于最小间距。当布局时,flow layout将cell添加到当前行,直到没有足够的空间来放置另一个cell。如果刚好可以排布整数个cell,那么cell间的间距等于最小间距。如果行尾有额外的空间,又不能放下另一个cell,flow layout将增加cell间距,直到cell在行内均匀排布,这时cell间距将大于minimumInteritemSpacing

minimumInteritemSpacing.png

进入ViewController.m,在实现部分添加以下代码,设置item间距。

// 设置item间距。
- (CGFloat)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout minimumInteritemSpacingForSectionAtIndex:(NSInteger)section {
    return 20;
}

运行demo,如下所示:

InteritemSpacing.png

这里每行只能排布两个cell,所以实际间距大于设置的最小间距20

5.4设置行间距minimumLineSpacing

对于行间距,flow layout采用与设置cell间距一样技术。如果所有cell大小相同,flow layout会严格遵守最小间距设置,即每一行的cell在同一条线上,相邻行cell间距等于minimumLineSpacing

如果cell大小不同,flow layout会在滑动方向选取每行最大cell。例如,在垂直方向滑动,flow layout会选取每行高最大的cell,随后设置这些高最大的cell间距为minimumLineSpacing。如果这些高最大的cell位于行不同位置,行间距看起来会大于minimumLineSpacing。如下所示:

minimumLineSpacing.png

进入ViewController.m,在实现部分添加以下代码,设置item行间距。

// 设置行间距。
- (CGFloat)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout minimumLineSpacingForSectionAtIndex:(NSInteger)section {
    return 20;
}

运行demo,如下所示:

CollectionViewLineSpace.png

这个demo中所有cell大小相同,所以这里的minimumLineSpacing会严格遵守设置的minimumLineSpacing间距20

5.5使用section inset设置内容边距

使用sectionInset可以调整可供放置cell区域大小,如增加section header、section footer与cell间距,增加行首、行尾间距。下图显示了sectionInset如何影响垂直滚动的UICollectionView

sectionInset.png

因为sectionInset减少了可供放置cell的空间,可以用此属性限制每行cell数量。例如,在非滑动方向设置inset,可以减少每行可用空间,同时配合设置itemSize,可以控制每行cell数量。

继续在ViewController.m实现部分添加以下代码,设置sectionInset

// 设置页边距。
- (UIEdgeInsets)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout insetForSectionAtIndex:(NSInteger)section {
    return UIEdgeInsetsMake(0, 20, 0, 20);
}

运行demo,如下所示:

sectionInsetPre.png

使用UICollectionViewDelegateFlowLayout协议可以动态调整布局信息。例如,不同item大小不同,不同section内item间距不同。如果没有提供代理方法,flow layout会使用通过属性设置的值。上面代码除设置section header大小部分,均可使用属性进行设值,如下所示:

- (UICollectionViewFlowLayout *)flowLayout {
    if (!_flowLayout) {
        ...
        // 通过属性设值。
        _flowLayout.itemSize = CGSizeMake(153, 128);
        _flowLayout.footerReferenceSize = CGSizeMake(35, 35);
        _flowLayout.minimumLineSpacing = 20;
        _flowLayout.minimumInteritemSpacing = 20;
        _flowLayout.sectionInset = UIEdgeInsetsMake(0, 20, 0, 20);
    }
    return _flowLayout;
}

现在运行app,如下所示:

CollectionViewAfDelegate.gif

6.数据模型

高性能的数据源使用section和item来组织其底层数据对象,这样会使数据源方法更易实现。数据源方法会被频繁调用,所以在数据源检索数据时必须足够快。

一个简单的解决办法(但不是唯一的)是让数据模型使用一组嵌套数组,嵌套数组内元素为section的数组,section数组内元素为该section内item。检索某个item就变成了先检索其section数组,再在该section数组内检索该item。这种模式适合于中等规模的数据模型。

dataSource.png

当设计数据结构时,始终可以从简单数组开始,根据需要迁移到更高效结构。通常,数据对象不应成为性能瓶颈。UICollectionView通过访问数据对象以获得共有多少个对象,并获取当前屏幕上显示对象的视图。如果layout仅依赖于数据对象,当数据对象包含数千个对象时,性能会受到严重影响。

现在,为这个demo添加一个数据模型。

打开Xcode,选择File > New > File...,在弹出窗口选择iOS > Source > Cocoa Touch Class模板,点击NextClass一栏填写SimpleModelSubclass of选择NSObject,点击Next;选择文件位置,点击Create创建文件。

进入SimpleModel.h文件,声明一个可变数组model

@interface SimpleModel : NSObject

@property (strong, nonatomic) NSMutableArray *model;

@end

进入SimpleModel.m文件,设置model可变数组包含另外两个可变数组section1section2,这两个可变数组分别包含六个元素。

- (instancetype)init {
    self = [super init];
    if (self) {
        NSMutableArray *section1 = [NSMutableArray arrayWithObjects:@"1",@"2",@"3",@"4",@"5",@"6", nil];
        NSMutableArray *section2 = [NSMutableArray arrayWithObjects:@"A",@"B",@"C",@"D",@"E",@"F", nil];
        _model = [NSMutableArray arrayWithObjects:section1,section2, nil];
    }
    return self;
}

打开Assets.xcassets,添加github/pro648/BasicDemos-iOS这里的照片,也可以通过文章底部的源码链接下载源码获取。

7.自定义UICollectionViewCell子类

自定义UICollectionViewCell子类,并为其添加UIImageViewUILabel对象的属性。

打开Xcode,选择File > New > File...,在弹出窗口选择iOS > Source > Cocoa Touch Class,点击NextClass一栏填写CollectionViewCellSubclass of选择UICollectionViewCell,点击Next;选择文件位置,点击Create创建文件。

进入CollectionViewCell.h文件,声明一个imageView和一个label属性。

@interface CollectionViewCell : UICollectionViewCell

@property (strong, nonatomic) UIImageView *imageView;
@property (strong, nonatomic) UILabel *label;

@end

进入CollectionViewCell.m文件,初始化imageViewlabel属性。

- (instancetype)initWithFrame:(CGRect)frame {
    self = [super initWithFrame:frame];
    if (self) {
        // 1.初始化imageView、label。
        CGFloat cellWidth = self.bounds.size.width;
        CGFloat cellHeight = self.bounds.size.height;
        _imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, cellWidth, cellHeight * 4/5)];
        _label = [[UILabel alloc] initWithFrame:CGRectMake(0, cellHeight * 4/5, cellWidth, cellHeight * 1/5)];
        _label.textAlignment = NSTextAlignmentCenter;
        
        // 2.添加imageView、label到cell。
        [self.contentView addSubview:_imageView];
        [self.contentView addSubview:_label];
    }
    return self;
}

进入ViewController.m文件,导入CollectionViewCell.hSimpleModel.h文件,声明类型为SimpleModelsimpleModel属性。

#import "CollectionViewCell.h"
#import "SimpleModel.h"

@interface ViewController ()<UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout>

...
@property (strong, nonatomic) SimpleModel *simpleModel;

@end

更新cell注册方法,并初始化simpleModel属性。

- (void)viewDidLoad {
    ...
    // 更新cell注册方法。
    [self.collectionView registerClass:[CollectionViewCell class] forCellWithReuseIdentifier:cellIdentifier];
    ...
    
    // 初始化simpleModel
    self.simpleModel = [[SimpleModel alloc] init];
}

现在更新数据源方法。

- (NSInteger)numberOfSectionsInCollectionView:(UICollectionView *)collectionView {
    return self.simpleModel.model.count;
}

- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
    return [self.simpleModel.model[section] count];
}

- (CollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(nonnull NSIndexPath *)indexPath {
    CollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:cellIdentifier forIndexPath:indexPath];
    
    // 设置imageView图片,label文字。
    NSString *imageName = [self.simpleModel.model[indexPath.section] objectAtIndex:indexPath.item];
    cell.imageView.image = [UIImage imageNamed:imageName];
    NSString *labelText = [NSString stringWithFormat:@"(%li, %li)",indexPath.section, indexPath.item];
    cell.label.text = labelText;
    
    return cell;
}

dataSource必须返回一个有效的视图,不能为nil,即使由于某种原因该视图不该被显示。layout期望返回有效视图,如果返回nil视图会导致app终止。

运行app,如下所示:

SimpleModel.png

8.重新排序cell

自iOS 9,Collection View允许根据用户手势重新排序cell。如需支持重新排序功能,需要添加手势识别器跟踪用户手势与集合视图的交互,同时更新数据源中item位置。

UICollectionView添加长按手势识别器,并实现响应方法。

- (void)viewDidLoad {
    ...
    // 为collectionView添加长按手势。
    UILongPressGestureRecognizer *longPressGesture = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(reorderCollectionView:)];
    [self.collectionView addGestureRecognizer:longPressGesture];
}

// 长按手势响应方法。
- (void)reorderCollectionView:(UILongPressGestureRecognizer *)longPressGesture {
    switch (longPressGesture.state) {
        case UIGestureRecognizerStateBegan:{
            // 手势开始。
            CGPoint touchPoint = [longPressGesture locationInView:self.collectionView];
            NSIndexPath *selectedIndexPath = [self.collectionView indexPathForItemAtPoint:touchPoint];
            if (selectedIndexPath) {
                [self.collectionView beginInteractiveMovementForItemAtIndexPath:selectedIndexPath];
            }
            break;
        }
            
        case UIGestureRecognizerStateChanged:{
            // 手势变化。
            CGPoint touchPoint = [longPressGesture locationInView:self.collectionView];
            [self.collectionView updateInteractiveMovementTargetPosition:touchPoint];
            break;
        }
            
        case UIGestureRecognizerStateEnded:{
            // 手势结束。
            [self.collectionView endInteractiveMovement];
            break;
        }
            
        default:{
            [self.collectionView cancelInteractiveMovement];
            break;
        }
    }
}

长按手势响应步骤如下:

  • 要开始交互式移动item,Collection View调用beginInteractiveMovementForItemAtIndexPath:方法;
  • 当手势识别器跟踪到手势变化时,集合视图调用updateInteractiveMovementTargetPosition:方法报告最新触摸位置;
  • 当手势结束时,UICollectionView调用endInteractiveMovement方法结束交互并更新视图;
  • 当手势中途取消或识别失败,UICollectionView调用cancelInteractiveMovement方法结束交互。

如果想要对手势识别器进行更全面了解,可以查看手势控制:点击、滑动、平移、捏合、旋转、长按、轻扫这篇文章。

在交互过程中,Collection view会动态的使布局无效,以反映当前item最新布局。默认的layout会自动重新排布item,你也可以自定义布局动画。

UICollectionViewController默认安装了长按手势识别器,用来重新排布集合视图中cell,如果需要禁用重新排布cell手势,设置installStandardGestureForInteractiveMovement属性为NO

当交互手势结束时,如果item位置放生了变化,UICollectionView会调用以下方法更新数据源。

// 是否允许移动item。
- (BOOL)collectionView:(UICollectionView *)collectionView canMoveItemAtIndexPath:(NSIndexPath *)indexPath {
    return YES;
}

// 更新数据源。
- (void)collectionView:(UICollectionView *)collectionView moveItemAtIndexPath:(NSIndexPath *)sourceIndexPath toIndexPath:(NSIndexPath *)destinationIndexPath {
    NSString *sourceObject = [self.simpleModel.model[sourceIndexPath.section] objectAtIndex:sourceIndexPath.item];
    [self.simpleModel.model[sourceIndexPath.section] removeObjectAtIndex:sourceIndexPath.item];
    [self.simpleModel.model[destinationIndexPath.section] insertObject:sourceObject atIndex:destinationIndexPath.item];
    // 重新加载当前显示的item。
    [collectionView reloadItemsAtIndexPaths:[collectionView indexPathsForVisibleItems]];
}

集合视图会先调用collectionView: canMoveItemAtIndexPath:方法,看当前item是否允许移动。如果没有实现该方法,但实现了collectionView: moveItemAtIndexPath: toIndexPath:方法,集合视图会允许所有item被移动。当交互手势结束时,UICollectionView会自动调用collectionView: moveItemAtIndexPath: toIndexPath:,如果该方法没有实现,则移动cell请求会被忽略。

运行app,移动item。

CollectionViewMoveItem.gif

在更新数据源时,按照以下步骤操作:

  1. 更新数据源中数据。
  2. 调用UICollectionView方法进行插入、删除、移动section或item操作。

必须先更新数据源,后更改UICollectionViewUICollectionView中方法会假定当前数据源包含正确数据,如果数据有误,集合视图可能会得到错误数据,也可能请求不存在的数据,导致app崩溃。

以编程的方式添加、删除、移动单个item时,collection view会自动创建动画以反映更改。如果你想要将多个插入、删除、移动操作合并为一个动画,则必须将这些操作放到一个块内,并将该块传递给performBatchUpdates: completion:方法。批量更新会在同一时间更新所有操作。

performBatchUpdates: completion:方法中,删除操作会在插入操作之前进行。也就是说,删除操作的index是collection view在执行批量更新batch update前的index,插入操作的index是collection view在执行完批量更新中删除操作后的index。

9.使用drag and drop排序

iOS 11增加了系统范围的拖放操作drag and drop,让用户可以快速简单的将文本、图像和文件从一个app移动到另一个app,只需轻点并按住即可提取其内容,拖放到其它位置。

UICollectionView通过专用API支持drag和drop,我们可以使用drag和drop来重新排序cell。

  • 为了支持drag操作,定义一个drag delegate对象,并将其赋值给collection view的dragDelegate,该对象必须遵守UICollectionViewDragDelegate协议;
  • 为了支持drop操作,定义一个drop delegate对象,并将其赋值给collection view的dropDelegate,该对象必须遵守UICollectionViewDropDelegate协议。

注释掉上一部分使用长按手势重新排序cell的代码,现在使用drag and drop重新排序。

所有拖放drag and drop功能都可以在iPad上使用。在iPhone上,拖放功能只能在应用内使用,不可在应用间拖放。

app可以只遵守UICollectionViewDragDelegateUICollectionViewDropDelegate中的一个协议。

进入ViewController.m文件,声明视图控制器遵守UICollectionViewDragDelegateUICollectionViewDropDelegate协议。同时,将视图控制器赋值给dragDelegatedropDelegate属性。

@interface ViewController ()<UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout, UICollectionViewDragDelegate, UICollectionViewDropDelegate>

- (void)viewDidLoad {
    ...
    // 开启拖放手势,设置代理。
    self.collectionView.dragInteractionEnabled = YES;
    self.collectionView.dragDelegate = self;
    self.collectionView.dropDelegate = self;
}

9.1从集合视图中拖起item

UICollectionView管理大部分与拖动相关的交互,但你需要指定要拖动的item。当拖动手势发生时,集合视图创建一个拖动会话,调用collectionView:itemsForBeginningDragSession:atIndexPath:代理方法。如果该方法返回非空数组,则集合视图将开始拖动指定item。如果不允许拖动指定索引路径的item,则返回空数组。

在实现collectionView:itemsForBeginningDragSession:atIndexPath:方法时,按照以下步骤操作:

  1. 创建一个或多个NSItemProvider,使用NSItemProvider传递集合视图item内容。
  2. 将每个NSItemProvider封装在对应UIDragItem对象中。
  3. 考虑为每个dragItemlocalObject分配要传递的数据。这一步骤是可选的,但在同一app内拖放时,localObject可以加快数据传递。
  4. 返回dragItem

ViewController.m文件中,实现上述方法:

- (NSArray <UIDragItem *>*)collectionView:(UICollectionView *)collectionView itemsForBeginningDragSession:(id<UIDragSession>)session atIndexPath:(NSIndexPath *)indexPath {
    NSString *imageName = [self.simpleModel.model[indexPath.section] objectAtIndex:indexPath.item];
    NSItemProvider *itemProvider = [[NSItemProvider alloc] initWithObject:imageName];
    UIDragItem *dragItem = [[UIDragItem alloc] initWithItemProvider:itemProvider];
    dragItem.localObject = imageName;
    return @[dragItem];
}

如果需要支持一次拖动多个item,还需要实现collectionView:itemsForAddingToDragSession:atIndexPath:point:方法,其实现代码与上面部分相同。

运行app,如下所示:

CollectionViewDrag.gif

使用collectionView:dragPreviewParametersForItemAtIndexPath:方法,可以自定义拖动过程中cell外观。如果没有实现该方法,或实现后返回nil,collection view将使用cell原样式呈现。

在该方法的实现部分,创建一个UIDragPreviewParameters对象,并更新指定item的预览信息。使用UIDragPreviewParameters可以指定cell的可视部分,或改变cell背景颜色,如下所示:

// 设置拖动预览信息。
- (nullable UIDragPreviewParameters *)collectionView:(UICollectionView *)collectionView dragPreviewParametersForItemAtIndexPath:(NSIndexPath *)indexPath {
    // 预览图为圆角,背景色为clearColor。
    UIDragPreviewParameters *previewParameters = [[UIDragPreviewParameters alloc] init];
    CollectionViewCell *cell = (CollectionViewCell *)[collectionView cellForItemAtIndexPath:indexPath];
    previewParameters.visiblePath = [UIBezierPath bezierPathWithRoundedRect:cell.bounds cornerRadius:10];
    previewParameters.backgroundColor = [UIColor clearColor];
    return previewParameters;
}

运行app,如下所示:

CollectionViewPreview.gif

可以看到,预览cell为圆角。

9.2 接收拖动cell内容

当内容被拖入集合视图边界内时,集合视图会调用collectonView:canHandleDropSession:方法,查看当前数据模型是否可以接收拖动的内容。如果可以接收拖动的内容,集合视图会继续调用其它方法。

当用户手指移动时,集合视图跟踪手势,检测可能的drop位置,并通知collectionView:dropSessionDidUpdate:withDestinationIndexPath:代理方法。该方法可选实现,但一般推荐实现。实现该方法后,UICollectonView会及时反馈将如何合并、放置拖动的cell到当前视图。该方法会被频繁调用,实现过程要尽可能快速、简单。

当手指离开屏幕时,UICollectionView会调用collectionView:performDropWithCoordinator:方法,必须实现该方法以接收拖动的数据。实现步骤如下:

  1. 枚举coordinator的items属性。

  2. 不同类型item,采取不同接收方法:

    • 如果item的sourceIndexPath存在,则item始于集合视图,可以使用批量更新batch update从当前位置删除item,插入到新的位置。
    • 如果item的localObject属性存在,则item始于app其它位置,必须插入item到数据模型。
    • 前面两种均不满足时,使用NSItemProvideritemProvider属性,异步提取数据,插入到数据模型。
  3. 更新数据模型,删除、插入collection view中item。

继续在ViewController.m中添加以下代码:

// 是否接收拖动的item。
- (BOOL)collectionView:(UICollectionView *)collectionView canHandleDropSession:(id<UIDropSession>)session {
    return [session canLoadObjectsOfClass:[NSString class]];
}

// 拖动过程中不断反馈item位置。
- (UICollectionViewDropProposal *)collectionView:(UICollectionView *)collectionView dropSessionDidUpdate:(id<UIDropSession>)session withDestinationIndexPath:(NSIndexPath *)destinationIndexPath {
    UICollectionViewDropProposal *dropProposal;
    if (session.localDragSession) {
        // 拖动手势源自同一app。
        dropProposal = [[UICollectionViewDropProposal alloc] initWithDropOperation:UIDropOperationMove intent:UICollectionViewDropIntentInsertAtDestinationIndexPath];
    } else {
        // 拖动手势源自其它app。
        dropProposal = [[UICollectionViewDropProposal alloc] initWithDropOperation:UIDropOperationCopy intent:UICollectionViewDropIntentInsertAtDestinationIndexPath];
    }
    return dropProposal;
}

- (void)collectionView:(UICollectionView *)collectionView performDropWithCoordinator:(id<UICollectionViewDropCoordinator>)coordinator {
    // 如果coordinator.destinationIndexPath存在,直接返回;如果不存在,则返回(0,0)位置。
    NSIndexPath *destinationIndexPath = coordinator.destinationIndexPath ? coordinator.destinationIndexPath : [NSIndexPath indexPathForItem:0 inSection:0];

    // 在collectionView内,重新排序时只能拖动一个cell。
    if (coordinator.items.count == 1 && coordinator.items.firstObject.sourceIndexPath) {
        NSIndexPath *sourceIndexPath = coordinator.items.firstObject.sourceIndexPath;

        // 将多个操作合并为一个动画。
        [collectionView performBatchUpdates:^{
            // 将拖动内容从数据源删除,插入到新的位置。
            NSString *imageName = coordinator.items.firstObject.dragItem.localObject;
            [self.simpleModel.model[sourceIndexPath.section] removeObjectAtIndex:sourceIndexPath.item];
            [self.simpleModel.model[destinationIndexPath.section] insertObject:imageName atIndex:destinationIndexPath.item];

            // 更新collectionView。
            [collectionView moveItemAtIndexPath:sourceIndexPath toIndexPath:destinationIndexPath];
        } completion:nil];
        
        // 以动画形式移动cell。
        [coordinator dropItem:coordinator.items.firstObject.dragItem toItemAtIndexPath:destinationIndexPath];
    }
}

现在运行app,如下所示:

CollectionViewDrop.gif

对于必须使用NSItemProvider检索的数据,需要使用dropItem:toPlaceHolderInsertedAtIndexPath:withReuseIdentifier:cellUpdateHandler:方法先将占位符placeholder插入,之后异步检索数据,具体方法这里不再介绍。

iOS 11也为UITableView增加了drag和drop功能,其API非常相似。

10. 总结

UICollectionView非常强大,除系统提供的这些布局风格,你还可以使用自定义布局custom layout满足你的各种需求。

如果觉得从数据源获取数据很耗时,可以使用UICollectionViewDataSourcePrefetching协议,该协议会协助你的数据源在还未调用collectionView:cellForItemAtIndexPath:方法时进行预加载。详细内容可以查看文档进一步学习。

Demo名称:CollectionView
源码地址:https://github.com/pro648/BasicDemos-iOS

参考资料:

  1. About iOS Collection Views
  2. Supporting Drag and Drop in Collection Views

欢迎更多指正:https://github.com/pro648/tips/wiki

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

推荐阅读更多精彩内容