美文网首页iOS学习开发IOSiOS开发笔记
UICollectionView及其新功能drag and dr

UICollectionView及其新功能drag and dr

作者: pro648 | 来源:发表于2017-12-20 22:41 被阅读323次

    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属性,异步提取数据,插入到数据模型。
    1. 更新数据模型,删除、插入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 deleteItemsAtIndexPaths:@[sourceIndexPath]];
                [collectionView insertItemsAtIndexPaths:@[destinationIndexPath]];
            } completion:nil];
        }
    }
    

    现在运行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

    相关文章

      网友评论

      • FengxinLi:[self.collectionView updateInteractiveMovementTargetPosition:[longPress locationInView:_longPress.view]]; 请问下楼主这行代码为什么不是在self.collectionView上面呢?
        pro648:@FengxinLi longPress.view指手势触摸的视图。有些情况可能无法接触到响应手势的视图,这时可以通过longPress.view获取,也就是longPress.view更为通用。
        FengxinLi:@pro648 我看了你的代码 和另外的代码搞混了 我看下写的是 //手势变化。
        CGPoint touchPoint = [longPressGesture locationInView:self.collectionView];
        [self.collectionView updateInteractiveMovementTargetPosition:touchPoint]; 是self.collectionView。有些写的是longPress.View 有什么区别吗?
        pro648:这行代码在什么位置?搜索了一下没有找到:wink:
      • FengxinLi:请问下楼主 NSItemProvider *itemProvider = [[NSItemProvider alloc] initWithObject:imageName]; 这个地方imageName 可以是一个model类型吗?
        FengxinLi:@pro648 谢谢 确实可以实现的 需要实现 NSItemProviderWriting,NSItemProviderReading 这二个
        pro648:应该可以,需要实现NSItemProviderWriting协议。可以参考这里 https://stackoverflow.com/questions/48410979/drag-and-drop-create-an-nsitemprovider-from-my-model
      • 各种垫底:好详细啊
        各种垫底:感谢楼主
      • 怪咖ZH:在使用drag and drop的时候,您这边更新的只是源和目的的item,其它item就没有被更新了
        pro648:试一下reloadSections:
        怪咖ZH:@pro648 是的,该怎么更新呢?求教
        pro648:是的,其它item需要更新吗?
      • 怪咖ZH:楼主写的非常详细,感谢分享!

      本文标题:UICollectionView及其新功能drag and dr

      本文链接:https://www.haomeiwen.com/subject/qtgnwxtx.html