Bootstrap

自定义UICollectionView布局(类似集五福)

一直以来想学习怎么样去自定义UICollectionViewLayout,但总是感觉太难,一直以来,都是看了一点点就放弃了。但其实任何事,只要去做了,就会发现,其实远没有想像的那么难。所以以后我遇事也要多动手。

废话说在前面

我之前尝试过去写这样一个关卡选择的功能,但是总是写不出来,后来同事用一个UIScrollView简单的写了一个,但是效果完全不给力,不但动画很生硬,而且没有复用机制。当关卡一多的时候,就会有很明显的卡顿,所以被我否决了。后来我想到了利用第三方库iCarousel来实现,但是并没有我所需要的效果,虽然可以自定义,但老实说我确实没有那种研究精神,而且我发现iCarousel与xib的结合使用似乎不太好。但出于简单省时的目的,我还是简单的修改了iCarousel的代码得到我需要的效果,但是同事却发现了其他的问题,于是最终还是决定自己实现一个,当然要实现这样的功能,当然是通过自定义UICollectionViewLayout来实现。本来我是在网上找资料,但是不知道是本性还是怎么,看一点就不想看下去了。我感觉没有一篇详细说明每个步骤的博文,所以决定把自己的实现过程纪录下来,供和我有同样需求的朋友参考。

效果展示

上面废话有点多,还是直接一点,上效果图吧。 
效果展示 
效果虽然很简单,但基本也能概括自定义UICollectionViewLayout的必要步骤吧。

CustomCarCollectionViewFlowLayout类的定义

CustomCarCollectionViewFlowLayout其实如果继承自UICollectionViewFlowLayout会很简单的实现该效果,但是我之所以让其继承自UICollectionViewLayout的原因主要是有两点:1、我自己想利用这次机会好好学习一下自定义。2、继承自UICollectionViewFlowLayout很多方面都不好控制,而继承自UICollectionViewLayout完全自由,定义如下:

@interface CustomCardCollectionViewFlowLayout : UICollectionViewLayout

@property(nonatomic, assign) CGFloat internalItemSpacing;
@property(nonatomic, assign) CGSize itemSize;
@property(nonatomic, assign) UIEdgeInsets sectionEdgeInsets;
@property(nonatomic, assign) CGFloat scale;
@property(nonatomic, assign) NSInteger currentItemIndex;
@property(nonatomic, assign) id<CustomCardCollectionViewFlowLayoutDelegate> delegate;

@end
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

类说明

属性说明

internalItemSpacing

@property(nonatomic, assign) CGFloat internalItemSpacing;
 
 
  • 1
  • 1

这个属性其实是参考了UICollectionViewFlowLayout里面的minimumInterItemSpacing,该属性表示每个Cell之间的间隔,不过UICollectionViewFlowLayout里的是指最小的,可变的。而internalItemSpacing则不可变

itemSize

@property(nonatomic, assign) CGSize itemSize;
 
 
  • 1
  • 1

这个也是参考的UICollectionViewFlowLayout,该属性表示每个Cell的大小

sectionEdgeInsets

@property(nonatomic, assign) UIEdgeInsets sectionEdgeInsets;
 
 
  • 1
  • 1

还是参考的UICollectionViewFlowLayout,该属性表示每个section之间的间距

scale

@property(nonatomic, assign) CGFloat scale;
 
 
  • 1
  • 1

即表示左边或右边的Cell的缩放系数,当Cell走到最左边或最右边的时候将会被缩放成指定的大小。

currentItemIndex

@property(nonatomic, assign) NSInteger currentItemIndex;
 
 
  • 1
  • 1

表示当前在中央的Cell在UICollectionView中的索引,只有当Cell处于最中间的时候才会设置。

代理定义

CustomCardCollectionViewFlowLayoutDelegate的定义如下:

@class CustomCardCollectionViewFlowLayout;
@protocol CustomCardCollectionViewFlowLayoutDelegate <NSObject>

@optional
-(void)scrolledToTheCurrentItemAtIndex:(NSInteger)itemIndex;

@end
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

该代理中的方法,就是当UICollectionView滚动停止后,当前所在中间的Cell的索引,参考效果图上的关卡指示(1/16)。

代码说明

下面我将一步步按照自己的编写顺序来说明该功能的实现。

prepareLayout

prepareLayout是一个必须要实现的方法,该方法的功能是为布局提供一些必要的初始化参数,我的代码如下:

-(void)prepareLayout {
    [super prepareLayout];

    _itemsCount = [self.collectionView numberOfItemsInSection:0];

    if(_internalItemSpacing == 0)
        _internalItemSpacing = 5;

    if(_sectionEdgeInsets.top == 0 && _sectionEdgeInsets.bottom == 0 && _sectionEdgeInsets.left == 0 && _sectionEdgeInsets.right == 0)
        _sectionEdgeInsets = UIEdgeInsetsMake(0, ([UIScreen mainScreen].bounds.size.width - self.itemSize.width) / 2, 0, ([UIScreen mainScreen].bounds.size.width - self.itemSize.width) / 2);

//    UITapGestureRecognizer* tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(collectionViewTapped:)];
//    [tapGesture setDelegate:self];
//    [self.collectionView addGestureRecognizer:tapGesture];

    return ;
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

首先是获取collectionView中共有多少个Cell,因为该功能一般只有一个section,所以我直接获取了section 0的数量。

其次是为该效果设置一些默认参数,如果用户没有提供值的话,将使用这些默认值。。

在最后有一个注释的UITapGestureRecognizer,这个手势本来是用来实现,点击两边的Cell能自动将点击的Cell滚动到中央。但是最后发现和UICollectionView的点击事件冲突了,导致滑动起来很吃力,到目前为止我还没想到更好的解决办法,于是只能暂时注释,慢慢想办法解决。也不妨将该手势的执行方法说一说。

手势处理

虽然手势不能使用,但还是可以拿来讲一讲,装装逼。其中有两段代码,一段是我处理手势冲突写的,但似乎效果不理想,最终还是没启用。

-(void)collectionViewTapped:(UIGestureRecognizer*)recognizer {
    CGPoint location = [recognizer locationInView:self.collectionView];
    NSIndexPath* indexPath = [self.collectionView indexPathForItemAtPoint:location];

    if(indexPath == nil)
        return ;

    if(_currentItemIndex == indexPath.item) {
        if([self.collectionView.delegate respondsToSelector:@selector(collectionView:didSelectItemAtIndexPath:)])
            [self.collectionView.delegate collectionView:self.collectionView didSelectItemAtIndexPath:indexPath];
    }
    else {
        _currentItemIndex = indexPath.item;
        [self.collectionView setContentOffset:CGPointMake(indexPath.item * (_internalItemSpacing + _itemSize.width), 0) animated:YES];

        if([self.delegate respondsToSelector:@selector(scrolledToTheCurrentItemAtIndex:)])
            [self.delegate scrolledToTheCurrentItemAtIndex:_currentItemIndex];
    }

    return ;
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

该方法是先获取到点击点在collectionView中的坐标,然后对应到点击的Cell的indexPath,当点击的Cell位于中间的话,则调用原来collectionView的didSelectItemAtIndexPath:方法,否则,调用setContentOffset方法,设置将点击的Cell设置到中间位置。

还有一个是处理手势冲突的,如下:

-(BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch {
    CGPoint location = [touch locationInView:self.collectionView];
    NSIndexPath* indexPath = [self.collectionView indexPathForItemAtPoint:location];

    if(indexPath == nil || indexPath.item == _currentItemIndex)
        return NO;
    return YES;
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

该方法是UIGestureRecognizerDelegate里的代理方法,同样的先获取到点击的indexPath,如果没有点击在Cell上或者点击了中间项,就不响应点击手势。但是效果并不理想,目前项目紧张也暂时不去考虑这么多了。

collectionViewContentSize

顾名思义,该方法也是一个必写的方法,该方法返回了collectionView的contentSize,我的代码如下:

-(CGSize)collectionViewContentSize {
    CGFloat contentWidth = _sectionEdgeInsets.left + _sectionEdgeInsets.right + _itemsCount * _itemSize.width + (_itemsCount - 1) * _internalItemSpacing;
    CGFloat contentHeight = _sectionEdgeInsets.top + _sectionEdgeInsets.bottom + self.collectionView.frame.size.height;
    return CGSizeMake(contentWidth, contentHeight);
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 1
  • 2
  • 3
  • 4
  • 5

那么collectionView的contentSize应该是多少呢?根据代码,我们知道height可以设置为0,因为需要纵向滚动,横向呢? 
contentSize
从图中我们可以很明显的看出来,宽度应该是 『左边间距 + Cell数 * Cell宽度 + (Cell数 - 1) * Cell间距 + 右边间距』。

layoutAttributesForItemAtIndexPath:方法

该方法也是一个必须要实现的方法,该方法是为每个Cell返回一个对应的Attributes,我们需要在该Attributes中设置对应的属性,如Frame等,代码如下:

-(UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath {
    UICollectionViewLayoutAttributes* attr = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];

    attr.size = _itemSize;
    attr.frame = CGRectMake((int)indexPath.row * (_itemSize.width + _internalItemSpacing) + _sectionEdgeInsets.left, (self.collectionView.bounds.size.height - _itemSize.height) / 2 + _sectionEdgeInsets.top, attr.size.width, attr.size.height);

    return attr;
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

首先调用”layoutAttributesForCellWithIndexPath:类方法创建一个Attributes,然后设置对应cell的frame,最后再返回该Attributes。

layoutAttributesForElementsInRect:

该方法是为在一个rect中的Cell返回Attributes,我们必须在该方法中做相应的处理,才能实现相应的效果。代码如下:

-(NSArray<UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect {
    NSMutableArray* attributes = [NSMutableArray array];

    CGRect visiableRect = CGRectMake(self.collectionView.contentOffset.x, 0, self.collectionView.bounds.size.width, self.collectionView.bounds.size.height);
    CGFloat centerX = self.collectionView.contentOffset.x + [UIScreen mainScreen].bounds.size.width / 2;

    for (NSInteger i=0 ; i < _itemsCount; i++) {
        NSIndexPath* indexPath = [NSIndexPath indexPathForItem:i inSection:0];
        UICollectionViewLayoutAttributes* attr = [self layoutAttributesForItemAtIndexPath:indexPath];
        [attributes addObject:attr];

        if(CGRectIntersectsRect(attr.frame, visiableRect) == false)
            continue ;
        CGFloat xOffset = fabs(attr.center.x - centerX);

        CGFloat scale = 1 - (xOffset * (1 - _scale)) / (([UIScreen mainScreen].bounds.size.width + self.itemSize.width) / 2 - self.internalItemSpacing);
        attr.transform = CGAffineTransformMakeScale(scale, scale);
    }

    return attributes;
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

首先,调用我们实现的layoutAttributesForItemAtIndexPath:方法,为每个Cell设置一个Attributes,然后遍历Attributes集,如果Cell没有和当前返回的rect相交,那么我们不用去处理,因为反正我们也看不到。否则设置scale,至于scale的计算,数学能力强的很容易写出来,我搞了好久,因为从小到大,数学就TM菜的一逼。最后设置transform进行缩放。

shouldInvalidateLayoutForBoundsChange:

该方法中,需要返回YES,当滚动的时候,重新生成对应属性

-(BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds {
    return YES;
}
 
 
  • 1
  • 2
  • 3
  • 1
  • 2
  • 3

targetContentOffsetForProposedContentOffset:withScrollingVelocity:

该方法的作用是当UICollectionView停止滚动时,用户希望停止在哪个位置上,对于该方法,我的代码如下所示:

-(CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity {

    NSInteger itemIndex = (NSInteger)(self.collectionView.contentOffset.x / (_itemSize.width + _internalItemSpacing));
    CGFloat xOffset = itemIndex * (_internalItemSpacing + _itemSize.width);
    CGFloat xOffset_1 = (itemIndex + 1) * (_internalItemSpacing + _itemSize.width);

    if(fabs(proposedContentOffset.x - xOffset) > fabs(xOffset_1 - proposedContentOffset.x)) {
        _currentItemIndex = itemIndex + 1;
        if([self.delegate respondsToSelector:@selector(scrolledToTheCurrentItemAtIndex:)])
            [self.delegate scrolledToTheCurrentItemAtIndex:_currentItemIndex];
        return CGPointMake(xOffset_1, 0);
    }

    _currentItemIndex = itemIndex;
    if([self.delegate respondsToSelector:@selector(scrolledToTheCurrentItemAtIndex:)])
        [self.delegate scrolledToTheCurrentItemAtIndex:_currentItemIndex];
    return CGPointMake(xOffset, 0);
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

首先,我根据偏移量计算出对应的当前Cell的index,然后分别获取到当前Cell和下一个Cell的偏移量,然后判断屏幕中央隔哪边比较近,就将哪一个调整到中间。最后修改中央Cell的index,调用结束之后的代理方法。

使用方法

至此,自定义UICollectionViewLayout就已经全部结束,其使用方法也和UICollectionViewFlowLayout差不多,我的使用代码如下:

((CustomCardCollectionViewFlowLayout*)self.m_pCollectionView.collectionViewLayout).itemSize = CGSizeMake(UI_IOS_WINDOW_WIDTH - 80, UI_IOS_WINDOW_HEIGHT - 64 - 40 - 105);

((CustomCardCollectionViewFlowLayout*)self.m_pCollectionView.collectionViewLayout).scale = 0.85f;

((CustomCardCollectionViewFlowLayout*)self.m_pCollectionView.collectionViewLayout).delegate = self;
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 1
  • 2
  • 3
  • 4
  • 5

关于UICollectionView的部分是在xib中设置的,没有相关代码,因为很多默认值都是根据我的项目需要来设置的,所以我这里只设置了itemSize和scale缩放系数这两个参数。效果就如上图所示。。

结束语

其实自定义一个布局真的不算太难,最难的点在于数学模型的建立,但是只要有决心,相信自己,用心钻研,也一定能搞定难题。虽然我不是这种人,但是原本我觉得很难,但这次无可奈何的情况下,只能硬着头皮去做,结果发现是我自己把事情想复杂了。所以最后再来一句:不要想,就是干。

代码链接

github代码链接

悦读

道可道,非常道;名可名,非常名。 无名,天地之始,有名,万物之母。 故常无欲,以观其妙,常有欲,以观其徼。 此两者,同出而异名,同谓之玄,玄之又玄,众妙之门。

;