UICollectionView 在 iOS6 中第一次被引入,也是 UIKit 视图类中的一颗新星。 它和 UITableView 共享一套 API 设计,但也在 UITableView 上作了一些扩展。UICollectionView 最强大、同时显著超出 UITableView 的特点就是其彻底灵活的布局结构。在这篇文章中,咱们将会实现一个至关复杂的自定义 collection view 布局,而且顺便讨论一下这个类设计的重要部分。项目的示例代码在 GitHub 上。html
布局对象 (Layout Objects)
UITableView 和 UICollectionView 都是 data-source 和 delegate 驱动的。它们在显示其子视图集的过程当中仅扮演容器角色(dumb containers
),且对子视图集真正的内容绝不知情。ios
UICollectionView
在此之上进行了进一步抽象。它将其子视图的位置,大小和外观的控制权委托给一个单独的布局对象。经过提供一个自定义布局对象,你几乎能够实现任何你能想象到的布局。布局继承自 UICollectionViewLayout
抽象基类。iOS6 中以 UICollectionViewFlowLayout
类的形式提出了一个具体的布局实现。git
咱们可使用 flow layout 实现一个标准的 grid view,这多是在 collection view 中最多见的使用案例了。尽管大多数人都这么想,可是 Apple 很聪明,没有明确的命名这个类为 UICollectionViewGridLayout
, 而使用了更为通用的术语 flow layout,更好的描述了该类的功能:它经过一个接一个的放置 cell 来创建本身的布局,当须要的时候,插入横排或竖排的分栏符。经过自定义滚动方向,大小和 cell 之间的间距,flow layout 也能够在单行或单列中布局 cell。实际上,UITableView
的布局能够想象成 flow layout 的一种特殊状况。github
在你准备本身写一个 UICollectionViewLayout
的子类以前,你须要问你本身,你是否可以使用 UICollectionViewFlowLayout
实现你内心的布局。这个类是很容易定制的,而且能够继承自己进行进一步的定制。感兴趣的看这篇文章。数组
Cells 和其余 Views
为了适应任意布局,collection view 创建了一个相似、但比 table view 更灵活的视图层级(view hierarchy)。像往常同样,你的主要内容显示在 cell 中,cell 能够被任意分组到 section 中。Collection view 的 cell 必须是 UICollectionViewCell
的子类。除了 cell,collection view 额外管理着两种视图:supplementary views 和 decoration views。架构
collection view 中的 Supplementary views 至关于 table view 的 section header 和 footer views。像 cells 同样,他们的内容都由数据源对象驱动。然而和 table view 中用法不同,supplementary view 并不必定会做为 header 或 footer view;他们的数量和放置的位置彻底由布局控制。app
Decoration views 纯粹为一个装饰品。他们彻底属于布局对象,并被布局对象管理,他们并不从 data source 获取的 contents。当布局对象指定须要一个 decoration view 的时候,collection view 会自动建立,并将布局对象提供的布局参数应用到上面去。并不须要为自定义视图准备任何内容。编辑器
Supplementary views 和 decoration views 必须是 UICollectionReusableView 的子类。布局使用的每一个视图类都须要在 collection view 中注册,这样当 data source 让它们从 reuse pool 中出列时,它们才可以建立新的实例。若是你是使用的 Interface Builder,则能够经过在可视编辑器中拖拽一个 cell 到 collection view 上完成 cell 在 collection view 中的注册。一样的方法也能够用在 supplementary view 上,前提是你使用了 UICollectionViewFlowLayout
。若是没有,你只能经过调用 registerClass:
或者 registerNib:
方法手动注册视图类了。你须要在 viewDidLoad
中作这些操做。ide
自定义布局
做为一个很是有意义的自定义 collection view 布局的例子,咱们不妨设想一个典型的日历应用程序中的周 (week) 视图。日历一次显示一周,星期中的每一天显示在列中。每个日历事件将会在咱们的 collection view 中以一个 cell 显示,位置和大小表明事件起始日期时间和持续时间。布局
通常有两种类型的 collection view 布局:
1.独立于内容的布局计算。这正是你所知道的像 UITableView 和 UICollectionViewFlowLayout 这些状况。每一个 cell 的位置和外观不是基于其显示的内容,但全部 cell 的显示顺序是基于内容的顺序。能够把默认的 flow layout 作为例子。每一个 cell 都基于前一个 cell 放置(或者若是没有足够的空间,则从下一行开始)。布局对象没必要访问实际数据来计算布局。
2.基于内容的布局计算。咱们的日历视图正是这样类型的例子。为了计算显示事件的起始和结束时间,布局对象须要 直接访问 collection view 的数据源。在不少状况下,布局对象不只须要取出当前可见 cell 的数据,还须要从全部记录中取出一些决定当前哪些 cell 可见的数据。
在咱们的日历示例中,布局对象若是访问某一个矩形内 cells 的属性,那就必须迭代数据源提供的全部事件来决定哪些位于要求的时间窗口中。 与一些相对简单,数据源独立计算的 flow layout 比起来,这足够计算出 cell 在一个矩形内的 index paths 了(假设网格中全部cells的大小都同样)。
若是有一个依赖内容的布局,那就是暗示你须要写自定义的布局类了,同时不能使用自定义的 UICollectionViewFlowLayout
,因此这正是咱们须要作的事情。
UICollectionViewLayout的文档列出了子类须要重写的方法。
collectionViewContentSize
因为 collection view 对它的 content 并不知情,因此布局首先要提供的信息就是滚动区域大小,这样 collection view 才能正确的管理滚动。布局对象必须在此时计算它内容的总大小,包括 supplementary views 和 decoration views。注意,尽管大多数经典的 collection view 限制在一个轴方向上滚动(正如 UICollectionViewFlowLayout
同样),但这不是必须的。
在咱们的日历示例中,咱们想要视图垂直的滚动。好比,若是咱们想要在垂直空间上一个小时占去 100 点,这样显示一成天的内容高度就是 2400 点。注意,咱们不可以水平滚动,这就意味这咱们 collection view 只能显示一周。为了可以在日历中的多个星期间分页,咱们能够在一个独立(分页)的 scroll view (可使用 UIPageViewController)中使用多个collection view(一周一个),或者坚持使用一个 collection view 而且返回足够大的内容宽度,这会使得用户感受在两个方向上滑动自由。
- (CGSize)collectionViewContentSize { // Don't scroll horizontally CGFloat contentWidth = self.collectionView.bounds.size.width; // Scroll vertically to display a full day CGFloat contentHeight = DayHeaderHeight + (HeightPerHour * HoursPerDay); CGSize contentSize = CGSizeMake(contentWidth, contentHeight); return contentSize; }
为了清楚起见,我选择布局在一个很是简单的模型上:假定每周天数相同,天天时长相同,也就是说天数用 0-6 表示。在一个真实的日历程序中,布局将会为本身的计算大量使用基于 NSCalendaar
的日期。
layoutAttributesForElementsInRect:
这是任何布局类中最重要的方法了,同时可能也是最容易让人迷惑的方法。collection view 调用这个方法并传递一个自身坐标系统中的矩形过去。这个矩形表明了这个视图的可见矩形区域(也就是它的 bounds ),你须要准备好处理传给你的任何矩形。
你的实现必须返回一个包含 UICollectionViewLayoutAttributes
对象的数组,为每个 cell 包含一个这样的对象,supplementary view 或 decoration view 在矩形区域内是可见的。UICollectionViewLayoutAttributes
类包含了 collection view 内 item 的全部相关布局属性。默认状况下,这个类包含 frame
,center
,size
,transform3D
,alpha
,zIndex
和 hidden
属性。若是你的布局想要控制其余视图的属性(好比背景颜色),你能够建一个 UICollectionViewLayoutAttributes
的子类,而后加上你本身的属性。
布局属性对象 (layout attributes objects) 经过 indexPath
属性和他们对应的 cell,supplementary view 或者 decoration view 关联在一块儿。collection view 为全部 items 从布局对象中请求到布局属性后,它将会实例化全部视图,并将对应的属性应用到每一个视图上去。
注意!这个方法涉及到全部类型的视图,也就是 cell,supplementary views 和 decoration views。一个幼稚的实现可能会选择忽略传入的矩形,而且为 collection view 中的全部视图返回布局属性。在原型设计和开发布局阶段,这是一个有效的方法。可是,这将对性能产生很是坏的影响,特别是可见 cell 远少于全部 cell 数量的时候,collection view 和布局对象将会为那些不可见的视图作额外没必要要的工做。
你的实现须要作这几步:
-
建立一个空的可变数组来存放全部的布局属性。
-
肯定 index paths 中哪些 cells 的 frame 彻底或部分位于矩形中。这个计算须要你从 collection view 的数据源中取出你须要显示的数据。而后在循环中调用你实现的
layoutAttributesForItemAtIndexPath:
方法为每一个 index path 建立并配置一个合适的布局属性对象,并将每一个对象添加到数组中。 -
若是你的布局包含 supplementary views,计算矩形内可见 supplementary view 的 index paths。在循环中调用你实现的
layoutAttributesForSupplementaryViewOfKind:atIndexPath:
,而且将这些对象加到数组中。经过为 kind 参数传递你选择的不一样字符,你能够区分出不一样种类的supplementary views(好比headers和footers)。当须要建立视图时,collection view 会将 kind 字符传回到你的数据源。记住 supplementary 和 decoration views 的数量和种类彻底由布局控制。你不会受到 headers 和 footers 的限制。 -
若是布局包含 decoration views,计算矩形内可见 decoration views 的 index paths。在循环中调用你实现的
layoutAttributesForDecorationViewOfKind:atIndexPath:
,而且将这些对象加到数组中。 -
返回数组。
咱们自定义的布局没有使用 decoration views,可是使用了两种 supplementary views(column headers和row headers):
- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect { NSMutableArray *layoutAttributes = [NSMutableArray array]; // Cells // We call a custom helper method -indexPathsOfItemsInRect: here // which computes the index paths of the cells that should be included // in rect. NSArray *visibleIndexPaths = [self indexPathsOfItemsInRect:rect]; for (NSIndexPath *indexPath in visibleIndexPaths) { UICollectionViewLayoutAttributes *attributes = [self layoutAttributesForItemAtIndexPath:indexPath]; [layoutAttributes addObject:attributes]; } // Supplementary views NSArray *dayHeaderViewIndexPaths = [self indexPathsOfDayHeaderViewsInRect:rect]; for (NSIndexPath *indexPath in dayHeaderViewIndexPaths) { UICollectionViewLayoutAttributes *attributes = [self layoutAttributesForSupplementaryViewOfKind:@"DayHeaderView" atIndexPath:indexPath]; [layoutAttributes addObject:attributes]; } NSArray *hourHeaderViewIndexPaths = [self indexPathsOfHourHeaderViewsInRect:rect]; for (NSIndexPath *indexPath in hourHeaderViewIndexPaths) { UICollectionViewLayoutAttributes *attributes = [self layoutAttributesForSupplementaryViewOfKind:@"HourHeaderView" atIndexPath:indexPath]; [layoutAttributes addObject:attributes]; } return layoutAttributes; }
layoutAttributesFor…IndexPath
有时,collection view 会为某个特殊的 cell,supplementary 或者 decoration view 向布局对象请求布局属性,而非全部可见的对象。这就是当其余三个方法开始起做用时,你实现的 layoutAttributesForItemAtIndexPath:
须要建立并返回一个单独的布局属性对象,这样才能正确的格式化传给你的 index path 所对应的 cell。
你能够经过调用 +[UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:]
这个方法,而后根据 index path 修改属性。为了获得须要显示在这个 index path 内的数据,你可能须要访问 collection view 的数据源。到目前为止,至少确保设置了 frame 属性,除非你全部的 cell 都位于彼此上方。
- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath { CalendarDataSource *dataSource = self.collectionView.dataSource; id event = [dataSource eventAtIndexPath:indexPath]; UICollectionViewLayoutAttributes *attributes = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath]; attributes.frame = [self frameForEvent:event]; return attributes; }
若是你正在使用自动布局,你可能会感到惊讶,咱们正在直接修改布局参数的 frame 属性,而不是和约束共事,但这正是 UICollectionViewLayout 的工做。尽管你可能使用自动布局来定义collection view 的 frame 和它内部每一个 cell 的布局,但 cells 的 frames 仍是须要经过老式的方法计算出来。
相似的,layoutAttributesForSupplementaryViewOfKind:atIndexPath:
和 layoutAttributesForDecorationViewOfKind:atIndexPath:
方法分别须要为 supplementary 和 decoration views 作相同的事。只有你的布局包含这样的视图你才须要实现这两个方法。UICollectionViewLayoutAttributes
包含另外两个工厂方法,+layoutAttributesForSupplementaryViewOfKind:withIndexPath:
和 +layoutAttributesForDecorationViewOfKind:withIndexPath:
,用他们来建立正确的布局属性对象。
shouldInvalidateLayoutForBoundsChange:
最后,当 collection view 的 bounds 改变时,布局须要告诉 collection view 是否须要从新计算布局。个人猜测是:当 collection view 改变大小时,大多数布局会被做废,好比设备旋转的时候。所以,一个幼稚的实现可能只会简单的返回 YES。虽然实现功能很重要,可是 scroll view 的 bounds 在滚动时也会改变,这意味着你的布局每秒会被丢弃屡次。根据计算的复杂性判断,这将会对性能产生很大的影响。
当 collection view 的宽度改变时,咱们自定义的布局必须被丢弃,但这滚动并不会影响到布局。幸运的是,collection view 将它的新 bounds 传给 shouldInvalidateLayoutForBoundsChange:
方法。这样咱们便能比较视图当前的bounds 和新的 bounds 来肯定返回值:
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds { CGRect oldBounds = self.collectionView.bounds; if (CGRectGetWidth(newBounds) != CGRectGetWidth(oldBounds)) { return YES; } return NO; }
动画
插入和删除
UITableView 中的 cell 自带了一套很是漂亮的插入和删除动画。可是当为 UICollectionView 增长和删除 cell 定义动画功能时,UIKit 工程师遇到这样一个问题:若是 collection view 的布局是彻底可变的,那么预先定义好的动画就没办法和开发者自定义的布局很好的融合。他们提出了一个优雅的方法:当一个 cell (或者supplementary或者decoration view)被插入到 collection view 中时,collection view 不只向其布局请求 cell 正常状态下的布局属性,同时还请求其初始的布局属性,好比,须要在开始有插入动画的 cell。collection view 会简单的建立一个 animation block,并在这个 block 中,将全部 cell 的属性从初始(initial)状态改变到常态(normal)。
经过提供不一样的初始布局属性,你能够彻底自定义插入动画。好比,设置初始的 alpha 为 0 将会产生一个淡入的动画。同时设置一个平移和缩放将会产生移动缩放的效果。
一样的原理应用到删除上,此次动画是从常态到一系列你设置的最终布局属性。这些都是你须要在布局类中为initial或final布局参数实现的方法.
-
initialLayoutAttributesForAppearingItemAtIndexPath:
-
initialLayoutAttributesForAppearingSupplementaryElementOfKind:atIndexPath:
-
initialLayoutAttributesForAppearingDecorationElementOfKind:atIndexPath:
-
finalLayoutAttributesForDisappearingItemAtIndexPath:
-
finalLayoutAttributesForDisappearingSupplementaryElementOfKind:atIndexPath:
-
finalLayoutAttributesForDisappearingDecorationElementOfKind:atIndexPath:
布局间切换
能够经过相似的方式将一个 collection view 布局动态的切换到另一个布局。当发送一个 setCollectionViewLayout:animated:
消息时,collection view 会为 cells 在新的布局中查询新的布局参数,而后动态的将每一个 cell(经过index path在新旧布局中判断出相同的cell)从旧参数变换到新的布局参数。你不须要作任何事情。
结论
根据自定义 collection view 布局的复杂性,写一个一般很不容易。确切的说,本质上这和从头写一个完整的实现相同布局自定义视图类同样困难了。由于所涉及的计算须要肯定哪些子视图当前是可见的,以及它们的位置。尽管如此,使用 UICollectionView
仍是给你带来了一些很好的效果,好比 cell 重用,自动支持动画,更不要提整洁的独立布局,子视图管理,以及数据提供架构规定(data preparation its architecture prescribes.)。
自定义 collection view 布局也是向轻量级 view controller 迈出很好的一步,正如你的 view controller 不要包含任何布局代码。正如 Chris 的文章中解释的同样,将这一切和一个独立的 datasource 类结合在一块儿,collection view 的视图控制器将很难再包含任何代码。
每当我使用 UICollectionView
的时候,我被其简洁的设计所折服。对于一个有经验的 Apple 工程师,为了想出如此灵活的类,极可能须要首先考虑 NSTableView
和 UITableView
。
扩展阅读
- Collection View Programming Guide.
- NSHipster on
UICollectionView
. UICollectionView
: The Complete Guide, e-book by Ash Furrow.MSCollectionViewCalendarLayout
by Eric Horacek is an excellent and more complete implementation of a custom layout for a week calendar view.