最近接到的一个需求里,需要实现一个如下滑动效果的banner:
自然而然的可以想到用UICollectionView来实现此效果,而主要的难点便在于需要自定义UICollectionViewLayout来设置滑动时的动态布局属性。
关于UICollectionViewLayout
我们可以先了解下UICollectionView中不同类的依赖关系
UICollectionView在初始化的时候,需要指定一个对应的collectionViewLayout的实例。当UICollectionView在进行布局显示的时候,会向对应的UICollectionViewLayout获取所有内容的布局信息,包括cell的所有布局信息和追加视图、装饰视图的布局信息。UICollectionViewLayout就是掌管所有布局信息的类。
UICollectionViewLayout类中需要包含一个UICollectionViewLayoutAttributes的列表,其对应了UICollectionView中每一个cell(UICollectionViewCell)以及追加视图、装饰视图(UICollectionReusableView)布局的所有信息。而重写UICollectionViewLayout的过程就是我们自主的去控制这些布局信息的过程。
在图中还有一个UICollectionViewFlowLayout,它是由官方实现的一个流水布局Layout,继承自UICollectionViewLayout,一般我们实现常规的UICollectionView布局直接使用它比较方便。
既然要自定义UICollectionView的layout,那么了解下UICollectionViewLayout中需要重载的常用方法吧:
-(void)prepareLayout;
系统在进行layout布局前会调用这个方法,该方法一般用于初始化需要的布局变量属性等。
-(CGSize)collectionViewContentSize;
这里我们需要自己计算collectionView的contentSize大小并返回。
-(NSArray *)layoutAttributesForElementsInRect:(CGRect)rect;
返回给定rect中所有的item的布局属性(UICollectionViewLayoutAttributes)数据,其中包括cell,追加视图和装饰视图
-(UICollectionViewLayoutAttributes )layoutAttributesForItemAtIndexPath:(NSIndexPath )indexPath;
返回对应于indexPath位置的cell的布局属性,该方法内需要我们自己根据indexPath来设置对应cell的布局属性
-(UICollectionViewLayoutAttributes )layoutAttributesForSupplementaryViewOfKind:(NSString )kind atIndexPath:(NSIndexPath *)indexPath;
返回对应indexPath的位置的追加视图的布局属性,如果没有追加视图可不重载
-(UICollectionViewLayoutAttributes * )layoutAttributesForDecorationViewOfKind:(NSString)decorationViewKind atIndexPath:(NSIndexPath )indexPath;
返回对应indexPath的位置的装饰视图的布局属性,如果没有装饰视图可不重载
-(BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds;
返回YES表示一旦滑动就重新计算布局信息,包括重新调用prepareLayout。
- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity;
这个方法用于设置滑动停止时的位置,proposedContentOffset代表将要停止的位置,velocity代表现在滑动的速度
自定义UICollectionViewLayout
了解各个重载方法的大致作用,现在就按顺序来试着实现上述banner的滑动效果吧。
自定义步骤
-
创建一个类继承自UICollectionViewLayout并添加需要的属性,我添加了一些需要用到的布局属性,这里可以参考UICollectionViewFlowLayout对外的属性来编写。
在YXBannerLayout.h文件中,添加的属性用于外部调整collectionView的布局参数
@interface YXBannerLayout : UICollectionViewLayout //同一行两个item之间的距离 @property (nonatomic, assign) NSInteger itemSpace; //collectionView的section内容与collectionView边缘的间距(上,下,左,右) @property (nonatomic, assign) UIEdgeInsets sectionInset; //每一个item的长宽 @property (nonatomic, assign) CGSize itemSize; @end
在YXBannerLayout.m文件中
@interface YXBannerLayout () // 每一个cell对应的布局信息的数组,在init方法内进行初始化 @property (nonatomic, strong) NSMutableArray *attributesArray; @end
-
重载prepareLayout进行布局信息初始化
- (void)prepareLayout { //别忘记先调用super方法 [super prepareLayout]; //获取section为0时cell的个数,这里只考虑一个section的情况 NSInteger itemCount = [self.collectionView numberOfItemsInSection:0]; //清除历史布局数据,attributesArray已在init中初始化 [self.attributesArray removeAllObjects]; //为每一个cell创建一个attributes并存入数组 //这里调用的便是下面的 layoutAttributesForItemAtIndexPath for (int i = 0; i < itemCount; i++) { UICollectionViewLayoutAttributes *attributes = [self layoutAttributesForItemAtIndexPath:[NSIndexPath indexPathForItem:i inSection:0]]; [self.attributesArray addObject:attributes]; } }
该方法内主要是获取collectionview的cell数量,然后根据数量依次调用layoutAttributesForItemAtIndexPath创建相应数目的布局属性并保存。
-
重载layoutAttributesForItemAtIndexPath设置各个cell的初始布局信息
- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath { //根据indexPath创建item的attributes UICollectionViewLayoutAttributes *attributes = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath]; //根据当前的index计算item�左上角起始点的x,y值 CGFloat itemX = self.sectionInset.left + (self.itemSpace + _itemSize.width) * indexPath.row; CGFloat itemY = self.sectionInset.top; //设置attributes的frame attributes.frame = CGRectMake(itemX, itemY, _itemSize.width, _itemSize.height); return attributes; }
在该方法中主要是设置各个item在collectionView中的frame,光根据各个布局属性设置好对应item的frame基本上就可以满足寻常的展示需求了,然而我们的需求还需要进行额外的形变,这个后面再细说。不过在此之前,我们还需要根据设置的items的frame自己计算并返回collectionView的contentsize。
-
重载collectionViewContentSize并计算返回contentSize
为了方便返回contentsize,在类中我直接添加了个属性
@property (nonatomic, assign) CGFloat contentWidth;
接着在第3步的 layoutAttributesForItemAtIndexPath 方法中计算并保存contentWidth的值
... attributes.frame = CGRectMake(itemX, itemY, _itemSize.width, _itemSize.height); // 根据当前item的初始点的x,item的宽度,以及item的间距计算当前的contentWidth _contentWidth = itemX + _itemSize.width + self.itemSpace;
最后就比较简单了
- (CGSize)collectionViewContentSize { // 宽度最后还是要加上最后一个item距离collectionView右边缘的距离 return CGSizeMake(_contentWidth + self.sectionInset.right, self.collectionView.frame.size.height); }
-
重载layoutAttributesForElementsInRect进行布局属性变更
在这里我们可以完成这个Banner的滑动效果。
从动画可以看出,在滑动的同时,往中间靠拢的item逐步放大,往两边的移动的item逐步缩小,并且左边的item往旁边移动的时候其会向右平移,这样才能在最后和中间的item相重合。由此要实现的便是根据距离来计算各个item放大缩小的比例,同时设置左边item的陪平移的距离。
//返回rect范围内item的attributes - (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect { // 计算停留的中心点 CGFloat centerX = self.collectionView.contentOffset.x + self.sectionInset.left + self.itemSize.width*0.5; // 最小的间距值 CGFloat minDelta = MAXFLOAT; NSInteger minIndex = 0; NSInteger index = 0; //遍历所有item的布局信息,计算和centerX的差值大小,并保存距离最近的item的index,用于获取前一个需要平移的item for (UICollectionViewLayoutAttributes *attrs in _attributesArray) { // cell的中心点x 和 collectionView最中心点的x值 的间距 CGFloat delta = ABS(attrs.center.x - centerX); // 根据item边缘与中心点的距离来计算缩放比例,距离中心点越近,展示比例越大 CGFloat scale = 1 - (delta-self.itemSize.width*0.5)*0.15 / self.itemSize.width; //限制最小缩放比例与最大比例 scale = (scale>0.88) ?scale : 0.88; scale = (scale>1) ?1 :scale; // 设置缩放比例,这里用的形变方法会按照原始frame进行形变 attrs.transform = CGAffineTransformMakeScale(scale, scale); //计算最靠近中心点的item的index if (ABS(minDelta) > ABS(attrs.center.x - centerX)) { minDelta = attrs.center.x - centerX; minIndex = index; } index ++; } //假如最靠近中心点的item前面还有item,则需要对前面一个item进行向右平移,使之与中间item有重合 if (minIndex>=1) { UICollectionViewLayoutAttributes *preAttr = _attributesArray[minIndex-1]; CGFloat delta = ABS(preAttr.center.x - centerX); //进行向右平移的形变,这里用的形变方法会在传入形变的基础上叠加形变 preAttr.transform = CGAffineTransformTranslate(preAttr.transform, (delta-self.itemSize.width*0.5)*0.3 , 0); //调整zIndex,使其处于中间item的下面 preAttr.zIndex = -1; } return self.attributesArray; }
该方法中进行的形变均是基于与中心点距离的线性变换,较为简单,可以在坐标轴中作图帮助理解。
相信大家也会注意到这里还调整了UICollectionViewLayoutAttributes得zIndex属性,这个属性代表view的层级关系,其值越大,代表其展示的层级越高,层级高的view能覆盖层级低的view。这里将左边的view的zIndex设置为-1,让其始终居于中间item的下面。要注意的是在这里设置zIndex并不会影响其他item的zIndex,因为每次运行到这里的时候,都会先调用layoutAttributesForItemAtIndexPath重置所有item的布局信息,包括zIndex,这样在显示的时候能保证只有左边的item的view层级被调整到下层。
当然,滚动重新布局必须依赖为了shouldInvalidateLayoutForBoundsChange的返回,只有返回真的时候才能在滑动的同时不断的更新缩放比例以及平移距离。
//返回YES表示一旦滑动就重新计算所有布局信息 - (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds { return YES; }
到这里基本上实现了滑动缩放以及重叠的效果,但是有个细节还有待考察,目前的banner不会停在item居于最中央的位置,不具有翻页的效果。
实现翻页效果
存在的问题
为了实现类滑动翻页效果,在每次滑动之后都能停在一个恰当的位置,我们需要重载targetContentOffsetForProposedContentOffset 方法,指定滑动停止时的位置。那么现在有几个问题需要先考虑一下
-
怎么判断是该滑往前一个item的frame还是滑往下一个item的frame,或者停在当前item?
最开始的时候我是判断速度velocity的正负来决定滑向哪一个页面,后来发现这样判断会出现超出滑动意向预期的滑动效果,比如你想滑到下一页,但是滑动结束的时候手指往回不小心勾了一下,就会出现往回滑的表现。所以这里做的优化就是判断中间的item往哪个方向偏移了,这样偏移的方向就是滑动翻页的方向。
-
这里的翻页是一页翻了多少?
这里的翻页并不是真的翻了一个屏幕宽度,因为每翻一页都是一个item居中,所以它只是翻了一个item的原始宽度,并不能设置collectionView的page属性来实现。
-
怎么获取需要滑到的contentOffset值呢?
因为翻页效果每翻一次是一个item的宽度,因此这里我们可以根据要滑到的item的index计算出contentOffset值。而首先需要知道滑动前处于中间的item的index,这里我的做法是在collectionView里面实现scrollViewWillBeginDragging来获取在滑动将要开始时居中item的index,将其传递给layout,然后在targetContentOffsetForProposedContentOffset方法中使用。这依赖于scrollViewWillBeginDragging是在将要开始拖拽的时候调用,后者是在拖拽结束的时候调用。
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
// 获取当前可见的items列表
NSArray<UICollectionViewCell *> *cells = [self.collectionView visibleCells];
if (!cells || cells.count==0) {
return;
}
//计算item停靠中心位置
CGFloat centerX = self.collectionView.contentOffset.x + _layout.sectionInset.left + _layout.itemSize.width*0.5;
NSInteger index = 0;
CGFloat minDelta = MAXFLOAT;
//获取距离中心点最近的Item的index
for (NSInteger i=0; i<cells.count ; i++) {
UICollectionViewCell *cell = cells[I];
if (minDelta > ABS(cell.center.x - centerX)) {
minDelta = ABS(cell.center.x - centerX);
index = I;
}
}
NSIndexPath *indexPath = [self.collectionView indexPathForCell:cells[index]];
_layout.currentIndex = indexPath.row;
}
解决方案的实现
解决好以上几个问题之后,便可以轻松的写出滑到适当位置的逻辑
- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity {
CGFloat leftX = self.collectionView.contentOffset.x + self.sectionInset.left;
NSInteger itemCount = [self.collectionView numberOfItemsInSection:0];
//获取滑动前居中的item
UICollectionViewCell *cell = [self.collectionView cellForItemAtIndexPath:[NSIndexPath indexPathForRow:_currentIndex inSection:0]];
//根据滑动前居中的item的位置来判断需要滑到的item的index
//不要忘记第一个item和最后一个item的情况
if (leftX > cell.frame.origin.x && _currentIndex+1 < itemCount) {
_currentIndex += 1;
}else if(leftX < cell.frame.origin.x && _currentIndex-1 >= 0){
_currentIndex -= 1;
}
//设置目标位置的contentOffset
proposedContentOffset.x = (_itemSpace + _itemSize.width) * _currentIndex;
return proposedContentOffset;
}
看起来已经完美了,然而运行的结果有些差强人意。在给banner翻页的时候,假如以很慢的速度滑动,banner也会以很慢的速度慢腾腾的滑到下一页,banner翻页的速度完全取决于用户滑动的速度,这离达到视觉大大的要求还是有一段距离的。
那么怎么解决呢,也许可以直接不使用该方法的减速停止机制,而是直接设置collectionView的contentOffset。这样的效果会怎么样呢?
//设置目标停止位置和当前所在的位置一致,提前结束减速滑动效果
proposedContentOffset.x = self.collectionView.contentOffset.x;
//直接设置目标位置的contentOffset
self.collectionView.contentOffset = CGPointMake((_itemSpace + _itemSize.width) * _currentIndex, self.collectionView.contentOffset.y);
运行一遍果然是没有减速过程,但是直接瞬移到了目标位置,所以我们离结果只差一个滑动动画而已。
proposedContentOffset.x = self.collectionView.contentOffset.x;
//动画滑动到指定位置
[self.collectionView scrollRectToVisible:CGRectMake((_itemSpace + _itemSize.width) * _currentIndex, 0, self.collectionView.frame.size.width, self.collectionView.frame.size.height) animated:YES];
到这里,我们已经通过自定义UICollectionViewLayout完全实现了Banner的滑动特效,但是在上面的 targetContentOffsetForProposedContentOffset 中其实还存在着一个Bug,它也会导致某种情况下的滑动结果超出预期。
UICollectionViewCell *cell = [self.collectionView cellForItemAtIndexPath:[NSIndexPath indexPathForRow:_currentIndex inSection:0]];
相信大家都知道,collectionView中的cell在滑出屏幕的时候就会被回收,那么这时候通过index获取到的cell便是nil,这样在滑动的时候便会出现无脑滑到下一页的情况。复现操作就是对banner从左边缘滑到右边缘,这样可以观察到触发bug之后的表现。怎么解决呢,我们可以用自己保存的UICollectionViewLayoutAttributes来进行判断。
UICollectionViewLayoutAttributes *attr = _attributesArray[_currentIndex];
if (leftX > attr.frame.origin.x && _currentIndex+1 < itemCount) {
_currentIndex += 1;
}else if(leftX < attr.frame.origin.x && _currentIndex-1 >= 0){
_currentIndex -= 1;
}
修改后的完整代码如下:
- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity {
CGFloat leftX = self.collectionView.contentOffset.x + self.sectionInset.left;
UICollectionViewLayoutAttributes *attr = _attributesArray[_currentIndex];
//根据滑动前居中的item的位置来判断需要滑到的item的index
//不要忘记第一个item和最后一个item的情况
if (leftX > attr.frame.origin.x && _currentIndex+1 < _attributesArray.count) {
_currentIndex += 1;
}else if(leftX < attr.frame.origin.x && _currentIndex-1 >= 0){
_currentIndex -= 1;
}
//设置目标停止位置和当前所在的位置一致,提前结束减速滑动效果
proposedContentOffset.x = self.collectionView.contentOffset.x;
//动画滑动到指定位置
[self.collectionView scrollRectToVisible:CGRectMake((_itemSpace + _itemSize.width) * _currentIndex, 0, self.collectionView.frame.size.width, self.collectionView.frame.size.height) animated:YES];
return proposedContentOffset;
}
到这里我们已经通过自定义UICollectionViewLayout的方式实现了想要的banner效果,如果有什么错漏的话,还请联系指正。