因为项目中有课程表的相关模块,第一时间想到用UICollectionView。然而后期的需求越来越复杂,每个格子需要展示的内容越来越多,所以不得不寻找合适的解决方案。最后发现自定义UICollectionViewLayout可以实现我的需求。

先放效果图:

课程表.gif

需要知道的:

1. UICollectionViewLayoutAttributes

首先我们要明白,它是我们自定义layout非常关键的一个类,它包含了cell、header、footer等视图的边框,中心点,大小,形状,透明度,层次关系和是否隐藏等信息,其中frame是我们需要用到的。

2. 自定义UICollectionViewLayout需要重载的方法:

// 返回collectionView的内容的尺寸
- (CGSize)collectionViewContentSize;
// 返回rect中的所有的元素的布局属性
- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect;
// 返回对应于indexPath的位置的cell的布局属性
- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath;
// 当边界发生改变时,是否应该刷新布局。
// return YES 表示在边界变化(一般是scroll到其他地方)时,将重新计算需要的布局信息
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds;
// 以上四个方法是我们需要用到的,重载他们就行。下面两个方法暂时不说。
- (UICollectionViewLayoutAttributes _)layoutAttributesForSupplementaryViewOfKind:(NSString _)kind atIndexPath:(NSIndexPath *)indexPath;
- (UICollectionViewLayoutAttributes * )layoutAttributesForDecorationViewOfKind:(NSString_)decorationViewKind atIndexPath:(NSIndexPath _)indexPath;

3. 生命周期

当一个UICollectionViewLayout被创建后,会有一系列方法被系统自动调用;

// 首先,我们在这个方法中重新计算Attributes的各个属性
-(void)prepareLayout;
// 然后,我们从这个方法获取cell的布局属性
-(NSArray *)layoutAttributesForElementsInRect:(CGRect)rect;
// 另外,我们可以调用这个方法立即刷新layout
- (void)invalidateLayout;

具体实现:

1. 首先,我们需要一个变量来记录被选中列

// 被选中列
@property (nonatomic, assign) IBInspectable NSInteger extendIndex;

2. 接下来,我们根据UICollectionView的section数、每个section的item数确定全部cell的itemSize,并保存他们的集合。这里的重点是需要区分第一行,第一列还有选中列的itemSize。

// 计算得到表格视图itemSize的集合
- (void)calculateItemsSize {
NSMutableArray *sectionArray = [@[] mutableCopy];
for (NSInteger section = 0; section < [self.collectionView numberOfSections]; section ++) {
NSMutableArray *itemArray = [@[] mutableCopy];
for (NSUInteger index = 0; index < self.rows; index++) {
if (self.itemsSize.count <= index) {
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:index inSection:section];
CGSize itemSize = [self sizeForItemWithColumnIndexPath:indexPath];
NSValue *itemSizeValue = [NSValue valueWithCGSize:itemSize];
[itemArray addObject:itemSizeValue];
}
}
[sectionArray addObject:itemArray];
}
self.itemsSize = sectionArray;
}
// 获取对应indexPath的itemSize
static CGFloat const extendWidth = 100.0;
- (CGSize)sizeForItemWithColumnIndexPath:(NSIndexPath *)indexPath {
CGSize size = CGSizeMake(60, 100);//预设cell的size
//根据表格的特性,调整不同情况下cell的宽高
size.width = indexPath.section ? size.width : 40;
size.height = indexPath.item ? size.height : 50;
size.width = (indexPath.section == self.extendIndex && indexPath.section > 0) ? extendWidth : size.width;
return size;
}

3. 根据itemSize重载对应attributes

UICollectionViewLayoutAttributes *attributes = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];
// 确保第一行与第一列永远顶在NavigationBar的下方跟屏幕的左边
if (section == 0 && index == 0) {
attributes.zIndex = 1024;
} else if (section == 0 || index == 0) {
attributes.zIndex = 1023;
}
if (section == 0) { //第一列
CGRect frame = attributes.frame;
frame.origin.x = self.collectionView.contentOffset.x;
attributes.frame = frame;
}
if (index == 0) { // 第一行
CGRect frame = attributes.frame;
frame.origin.y = self.collectionView.contentOffset.y;
attributes.frame = frame;
}

PS:这里重点在于,collectionView横向或者竖向滚动时第一列与第一行的cell不能跟着移动位置,而通过self.collectionView.contentOffset我们可以知道collectionView分别在水平、竖直方向上的位移,那么有两种情况:

水平滚动:我们将collectionView在x轴上的偏移量赋给第一列cell的frame.origin.x,即可造成第一列没有滑动的视觉假象。

竖直滚动:原理同上。

for (int section = 0; section < [self.collectionView numberOfSections]; section++) {
NSMutableArray *sectionAttributes = [@[] mutableCopy];
for (NSUInteger index = 0; index < self.rows; index++) {
CGSize itemSize = [self.itemsSize[section][index] CGSizeValue];
NSIndexPath *indexPath = [NSIndexPath indexPathForItem:index inSection:section];
UICollectionViewLayoutAttributes *attributes = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];
attributes.frame = CGRectIntegral(CGRectMake(xOffset, yOffset, itemSize.width, itemSize.height));
yOffset = yOffset+itemSize.height;
column++;
if (column == self.rows) {
if (yOffset > contentHeight) {
contentHeight = yOffset;
}
// Reset values
column = 0;
xOffset += itemSize.width;
yOffset = 0;
}
}
}

这里我们通过遍历cell得到attributes,继而通过累加算出collectionView的contentSize:

U

ICollectionViewLayoutAttributes *attributes = [[self.itemAttributes lastObject] lastObject];
contentWidth = attributes.frame.origin.x + attributes.frame.size.width;
self.contentSize = CGSizeMake(contentWidth, contentHeight);

4. 之后,就是去重载UICollectionViewLayout的那几个需要用到的方法了:

#pragma mark - Overwrite
//返回collectionView的内容的尺寸
- (CGSize)collectionViewContentSize {
return self.contentSize;
}
// 返回对应于indexPath的位置的cell的布局属性
- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath {
return self.itemAttributes[indexPath.section][indexPath.row];
}
// 返回rect中的所有的元素的布局属性
- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect {
NSMutableArray *attributes = [@[] mutableCopy];
for (NSArray *section in self.itemAttributes) {
[attributes addObjectsFromArray:[section filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(UICollectionViewLayoutAttributes *evaluatedObject, NSDictionary *bindings) {
return CGRectIntersectsRect(rect, [evaluatedObject frame]);
}]]];
}
return attributes;
}
// 当边界发生改变时,是否应该刷新布局
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds {
return YES; // 在边界变化(一般是scroll到其他地方)时,将重新计算需要的布局信息
}

5. 最后,在-prepareLayout方法中清空布局数组、重新计算itemSize、刷新Attributes

- (void)prepareLayout {
[self clearLayoutArray];
[self calculateItemsSize];
[self updateItemsAttributes];
}

到这里也就大功告成了,放上

Tips:

通过IBInspectable关键字可申明该属性可在Storyboard中设置

// 被选中列
@property (nonatomic, assign) IBInspectable NSInteger extendIndex;
image.png