实现循环轮播图的各种方案
-
轮播图的实现方案有很多种,大体上分为CollectionView和ScrollView实现的两个方向。其中CollectionView实现的方案较多利用了CollectionView的特性,实现比较简单,但是比较受限于CollectionView的这个框架,而且出于学习的目的出发会比较不够深入。ScrollView实现的比较大众比较令人满意的方案是使用三个UIImageView实现轮播效果。原理这里有位同学有提到,iOS无限轮播图片的两种方式,就不赘述啦。然后其实使用两个UIImageView也是可以实现的。但是不管是使用两个还是三个UIImageView,都是只限于可见范围内只存在一个banner的情况,如果可见范围内有n个banner,就需要n+2个UIImageView。所以这种方法还是不够灵活,看看有没有其他方案。
-
本文描述的方案是通过UIScrollView实现Cell的重用机制,然后实现循环轮播的功能。这样既可以解决使用2或者3个UIImageView的不灵活的问题,也可以突破UICollectionView框架的限制,提高组件的可扩展性。
如何实现
用ScrollView实现类似CollectionView的Cell重用机制
- 首先为什么要用ScrollView去实现Cell的重用机制呢。除了出于学习的角度考虑之外,我们知道Cell的重用机制其实就是建立一个Cell的复用池,当可见的Cell滑动出屏幕外的时候将其回收,下一个Cell将要显示于屏幕上时从复用池中拿一个Cell进行复用,如果没有就new一个。所以,假如静止时可见范围内只存在一个banner页的话,那么最多滑动时可见的就是两个,一共只需要new两个Cell出来。
- 我们先用一个继承于UIView的View来承载这个banner组件,叫KiraBanner。它的结构很简单,一个UIScrollView用于滑动,一个Cell的集合作为Cell的重用池,一个PageControl。然后我们在init方法中进行初始化。当然,在init方法中需要对一些KiraBanner的一些默认属性进行初始化设置,如isCircle(是否循环)、topBottomSpace(上下边距)、leftRightSpace(左右边距)等等。
@property (nonatomic, strong) UIScrollView *scrollView;
@property (nonatomic, strong) NSMutableSet *reuseCells;
@property (nonatomic,retain) UIPageControl *pageControl;
- (instancetype)init {
self = [super init];
if (self) {
[self commonInit];
}
return self;
}
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
[self commonInit];
}
return self;
}
- (instancetype)initWithCoder:(NSCoder *)aDecoder {
self = [super initWithCoder:aDecoder];
if (self) {
[self commonInit];
}
return self;
}
- (void)commonInit {
self.clipsToBounds = YES;
self.pageCount = 0;
self.isAutoScroll = YES;
//默认左右间距为20,上下间距为30
self.leftRightSpace = 20;
self.topBottomSpace = 30;
_currentIndex = 0;
//默认自动滚动时间间隔为5s
_autoTime = 5.0;
_visibleRange = NSMakeRange(0, 0);
self.reuseCells = [[NSMutableSet alloc] init];
self.scrollView.scrollsToTop = NO;
self.scrollView.clipsToBounds = NO;
self.scrollView.showsVerticalScrollIndicator = NO;
self.scrollView.showsHorizontalScrollIndicator = NO;
self.scrollView.backgroundColor = [UIColor redColor];
_currentIndex = 0;
[self.scrollView setFrame:self.bounds];
[self addSubview:self.scrollView];
}
2.仿照CollectionView的使用,我们需要两个委托方法来告诉KiraBanner中banner的数量以及每个banner展示的内容是什么,也就是KiraBanner的DataSource。
@protocol KiraBannerDataSource <NSObject>
@required
/**
* 设置banner的数量
*/
- (NSInteger)numberOfItemsInKiraBanner:(KiraBanner *)banner;
/**
* 设置某一页banner的内容
*/
- (UIView *)kiraBanner: (KiraBanner *)banner viewForItemAtIndex:(NSInteger)index;
@optional
@end
3.然后我们在KiraBanner中增加一个Class的属性,对外提供regiseterClassForCells的方法来注册Cell。
/**
* 注册的cell类型
*/
@property (nonatomic, strong) Class cellClass;
- (void)regiseterClassForCells: (Class) cellClass {
self.cellClass = cellClass;
}
4.dequeueReusableCell 方法拿到可复用Cell,也就是即将进入屏幕中的cell的来源
- (UIView *)dequeueReusableCell {
UIView *cell = [self.reuseCells anyObject];
if (cell) {
[self.reuseCells removeObject:cell];
NSLog(@"add a cell");
}
if (!cell) {
cell = [[self.cellClass alloc] init];
NSLog(@"produce a new cell");
}
return cell;
}
- recycleCell方法,用于滑动时将消失于屏幕外的cell回收。
- (void)recycleCell: (UIView *)cell {
[self.reuseCells addObject:cell];
[cell removeFromSuperview];
}
6、我们将每页banner的size作为一个委托方法交给外部设置,同时增加私有属性pageSize。包括banner的点击等方法,都需要作为代理给外部调用。
@protocol KiraBannerDelegate <UIScrollViewDelegate>
/**
* 设置一个page的size
*/
- (CGSize)sizeForPageInKiraBanner:(KiraBanner *)banner;
/**
* 当前banner滚动到了哪一页
*/
- (void)didScrollToIndex:(NSInteger)index inKiraBanner:(KiraBanner *)banner;
/**
* 点击某个cell
*/
- (void)didSelectCell:(UIView *)cell inKiraBannerAtIndex:(NSInteger)index;
/**
* 当前page滚动过了整页的百分比
*/
- (void)didScrollPercent:(float)percent OfPageInScrollView:(UIScrollView *)scrollView;
@end
7、在外部ViewController中简单配置使用一下KiraBanner,并实现KiraBannerDataSource和Delegate的委托方法。
- (void)viewDidLoad {
[super viewDidLoad];
self.dataArray = @[@"1.jpg",@"2.jpg",@"3.jpg",@"4.jpg"];
self.banner = [[KiraBanner alloc] initWithFrame:CGRectMake(0, 72, Width, Width * 9 / 16)];
[self.view addSubview:self.banner];
self.banner.backgroundColor = [UIColor blackColor];
self.banner.isCircle = YES;
self.banner.leftRightSpace = 50;
self.banner.topBottomSpace = 30;
self.banner.clipsToBounds = YES;
self.automaticallyAdjustsScrollViewInsets = NO;
[self.banner regiseterClassForCells:[UIImageView class]];
self.banner.bannerType = KiraBannerTypeHorizontal;
self.banner.minimumPageAlpha = 1;
self.banner.dataSource = self;
self.banner.delegate = self;
}
- (UIView *)kiraBanner:(KiraBanner *)banner viewForItemAtIndex:(NSInteger)index {
UIImageView *cell = (UIImageView *)[self.banner dequeueReusableCell];
cell.image = [UIImage imageNamed:self.dataArray[index]];
[cell setContentMode:UIViewContentModeScaleAspectFill];
return cell;
}
- (CGSize)sizeForPageInKiraBanner:(KiraBanner *)banner {
return CGSizeMake(Width - 60, (Width - 60) * 9 / 16);
}
- (NSInteger)numberOfItemsInKiraBanner:(KiraBanner *)banner {
return self.dataArray.count;
}
- (void)didSelectCell:(UIView *)cell inKiraBannerAtIndex:(NSInteger)index {
NSLog(@"banner of index : %ld is clicked.",(long)index);
}
- 前置项已经准备好了,接下来就是在didScrollView的代理方法中去做cell复用的逻辑。这里封装一个方法setVisibleCellsAtContentOffset在didScrollView中调用,顾名思义就是根据contentoffset添加可见cell。那怎么做复用的逻辑呢。首先我们遍历scrollView中的每个子Cell,根据cell的frame.origin.x(拿横向滑动举例)和frame.size.width计算并与contentoffset作比较,判断该cell是否在显示区域外,如果是,则调用recycleCell方法对cell进行回收。如果不是,则调用fillPageAtIndex将cell添加到scrollView上。
- (void)setVisibleCellsAtContentOffset:(CGPoint)offset {
CGPoint startPoint = CGPointMake(offset.x - _scrollView.frame.origin.x, offset.y - _scrollView.frame.origin.y);
CGPoint endPoint = CGPointMake(startPoint.x + self.bounds.size.width, startPoint.y + self.bounds.size.height);
switch (self.bannerType) {
case KiraBannerTypeHorizontal: {
for (UIView *cellView in [self cellSubView]) {
if (cellView.frame.origin.x + cellView.frame.size.width < startPoint.x) {
[self recycleCell:cellView];
}
if (cellView.frame.origin.x > endPoint.x) {
[self recycleCell:cellView];
}
}
NSInteger startIndex = MAX(0, floor(startPoint.x / _pageSize.width));
NSInteger endIndex = MIN(_pageCount, ceil(endPoint.x / _pageSize.width));
_visibleRange = NSMakeRange(startIndex, endIndex - startIndex + 1);
for (NSInteger i = startIndex; i < endIndex ; i++) {
[self fillPageAtIndex:i];
}
}
break;
case KiraBannerTypeVertical: {
for (UIView *cellView in [self cellSubView]) {
if (cellView.frame.origin.y + cellView.frame.size.height < startPoint.y) {
[self recycleCell:cellView];
}
if (cellView.frame.origin.y > endPoint.y) {
[self recycleCell:cellView];
}
}
NSInteger startIndex = MAX(0, floor(startPoint.y / _pageSize.height));
NSInteger endIndex = MIN(_pageCount, ceil(endPoint.y / _pageSize.height));
//visibleRange表示可见的banner的index范围
_visibleRange = NSMakeRange(startIndex, endIndex - startIndex + 1);
for (NSInteger i = startIndex; i < endIndex ; i++) {
[self fillPageAtIndex:i];
}
}
break;
default:
break;
}
}
- (NSArray *) cellSubView {
NSMutableArray * cells = [[NSMutableArray alloc] init];
for (UIView *subView in self.scrollView.subviews) {
if ([subView isKindOfClass:[_cellClass class]]) {
[cells addObject:subView];
}
}
return [cells copy];
}
- (void)fillPageAtIndex:(NSInteger)index {
//cellForIndex的实现在后面给出,现在只需要知道这是根据index取到对应的cell的方法
UIView *cell = [self cellForIndex:index];
if (!cell) {
UIView *cell = [self.dataSource kiraBanner:self viewForItemAtIndex:index % self.numberOfItems];
cell.clipsToBounds = YES;
switch (self.bannerType) {
case KiraBannerTypeHorizontal: {
float originX = index * self.pageSize.width;
cell.frame = CGRectMake(originX,
self.topBottomSpace,
self.pageSize.width,
self.pageSize.height);
}
break;
case KiraBannerTypeVertical: {
float originY = index * self.pageSize.height;
cell.frame = CGRectMake(self.leftRightSpace,
originY,
self.pageSize.width,
self.pageSize.height);
}
break;
default:
break;
}
}
}
- cellForIndex 在上述的fillPageAtIndex方法中,我们看到需要将index对应的cell拿出来重用。但是我们的重用池是Set不是Array,无法用下标直接访问,所以需要将index和cell以某种关系联系起来。这边我们采用的是runtime的关联对象方法,将index转换为NSNumber并将其与cell对象关联在一起。关联的代码写在fillPageAtIndex中,然后在cellForIndex方法中读取,最后在recycleCell的时候需要进行remove操作。
//在fillPageAtIndex中补充
if (cell) {
//将cellforindex方法的地址作为objc_setAssociatedObject的key,保证唯一性
objc_setAssociatedObject(cell, @selector(cellForIndex:),[NSNumber numberWithInteger:index], OBJC_ASSOCIATION_COPY);
[self.scrollView insertSubview:cell atIndex:0];
}
- (UIView *) cellForIndex: (NSInteger)index {
for (UIView *cellView in [self cellSubView]) {
NSNumber *value = objc_getAssociatedObject(cellView, @selector(cellForIndex:));
if (value.integerValue == index) {
return cellView;
}
}
return nil;
}
- (void)recycleCell: (UIView *)cell {
objc_removeAssociatedObjects(cell);
[self.reuseCells addObject:cell];
[cell removeFromSuperview];
}
10.最后在scrollViewDidScroll中调用setVisibleCellsAtContentOffset方法,理论上到这里是已经完成cell的复用逻辑了。
实现KiraBanner的循环轮播
1.在上述过程中已经实现了Cell的复用逻辑,其实就是非循环banner的实现。但是现在的页面没有单页滑动的效果,所以我们需要对ScrollView进行设置。
self.scrollView.pagingEnabled = YES;
2.reloadData方法 我们需要在reloadData方法中判断是否循环,如果是,则将scrollView的contentSize设置为3组banner的大小,并且设置scrollView的初始contentOffset为第二组banner第一个的位置。
{
_needsReload = YES;
//reload的时候移除所有cell
for (UIView *view in self.scrollView.subviews) {
if ([NSStringFromClass(view.class) isEqualToString:NSStringFromClass(_cellClass.class)]) {
[view removeFromSuperview];
}
}
//停止计时器
[self stopTimer];
if (_needsReload) {
if (self.dataSource && [self.dataSource respondsToSelector:@selector(numberOfItemsInKiraBanner:)]) {
_numberOfItems = [self.dataSource numberOfItemsInKiraBanner:self];
if (self.isCircle) {
//如果是循环banner,则把scrollView的长度设为3组
_pageCount = self.numberOfItems == 1 ? 1 : self.numberOfItems * 3;
} else {
_pageCount = self.numberOfItems == 1 ? 1 : self.numberOfItems;
}
if (_pageCount == 0) {
return;
}
if (self.pageControl && [self.pageControl respondsToSelector:@selector(setNumberOfPages:)]) {
[self.pageControl setNumberOfPages:self.numberOfItems];
}
}
//重置page的宽度
CGFloat width = _scrollView.bounds.size.width - 4 * self.leftRightSpace;
_pageSize = CGSizeMake(width, width * 9 / 16);
if (self.delegate && [self.delegate respondsToSelector:@selector(sizeForPageInKiraBanner:)]) {
_pageSize = [self.delegate sizeForPageInKiraBanner:self];
}
[_reuseCells removeAllObjects];
_visibleRange = NSMakeRange(0, 0);
switch (self.bannerType) {
case KiraBannerTypeHorizontal: {
[self.scrollView setFrame:CGRectMake(0, 0, _pageSize.width, _pageSize.height)];
[self.scrollView setContentSize:CGSizeMake(_pageSize.width * _pageCount, 0)];
_scrollView.center = CGPointMake(CGRectGetMidX(self.bounds), CGRectGetMidY(self.bounds));
if (self.numberOfItems > 1) {
if (self.isCircle) {
//设置contentOffset为第二组第一个banner的位置
[_scrollView setContentOffset:CGPointMake(_pageSize.width * self.numberOfItems, 0) animated:NO];
self.page = self.numberOfItems;
[self startTimer];
} else {
[_scrollView setContentOffset:CGPointMake(0, 0) animated:NO];
self.page = self.numberOfItems;
}
}
}
break;
case KiraBannerTypeVertical: {
[self.scrollView setFrame:CGRectMake(0, 0, _pageSize.width, _pageSize.height)];
[self.scrollView setContentSize:CGSizeMake(0, _pageSize.height * _pageCount)];
_scrollView.center = CGPointMake(CGRectGetMidX(self.bounds), CGRectGetMidY(self.bounds));
if (self.numberOfItems > 1) {
if (self.isCircle) {
[_scrollView setContentOffset:CGPointMake(_pageSize.height * self.numberOfItems, 0) animated:NO];
self.page = self.numberOfItems;
[self startTimer];
} else {
[_scrollView setContentOffset:CGPointMake(0, 0) animated:NO];
self.page = self.numberOfItems;
}
}
}
break;
default:
break;
}
_needsReload = NO;
}
[self setVisibleCellsAtContentOffset:_scrollView.contentOffset];
//refreshView是做cell在移动过程中变化的函数,下面会给出
[self refreshView];
}
3.在scrollViewDidScroll方法中对scrollView的contentOffset进行判断。由offset除以cell的宽度计算出当前是第几个cell。如果右滑超出第二组最后一个cell的范围,就将offset设置到第二组第一个cell处,同理如果左滑超出第二组第一个cell,就将offset设置到第二组最后一个cell处,从而达到循环的效果。
case KiraBannerTypeHorizontal:
{
if (scrollView.contentOffset.x / _pageSize.width >= 2 * self.numberOfItems) {
[scrollView setContentOffset:CGPointMake(_pageSize.width * self.numberOfItems, 0) animated:NO];
self.page = self.numberOfItems;
}
if (scrollView.contentOffset.x / _pageSize.width <= self.numberOfItems - 1) {
[scrollView setContentOffset:CGPointMake((2 * self.numberOfItems - 1) * _pageSize.width, 0) animated:NO];
self.page = 2 * self.numberOfItems - 1;
}
}
break;
4.refreshView 在滑动过程中根据offset做transform形变。
- (void)refreshView {
if (CGRectIsNull(self.scrollView.frame)) {
return;
}
switch (self.bannerType) {
case KiraBannerTypeHorizontal: {
CGFloat offset = _scrollView.contentOffset.x;
for (NSInteger i = self.visibleRange.location; i < self.visibleRange.location + self.visibleRange.length ; i++) {
UIView *cell = [self cellForIndex:i];
CGFloat origin = cell.frame.origin.x;
CGFloat delta = fabs(origin - offset);
CGRect originCellFrame = CGRectMake(_pageSize.width * i, 0, _pageSize.width, _pageSize.height);
//TODO:透明度渐变
if (delta < _pageSize.width) {
CGFloat leftRightInset = self.leftRightSpace * delta / _pageSize.width;
CGFloat topBottomInset = self.topBottomSpace * delta / _pageSize.width;
cell.layer.transform = CATransform3DMakeScale((_pageSize.width-leftRightInset*2)/_pageSize.width,(_pageSize.height-topBottomInset*2)/_pageSize.height, 1.0);
cell.frame = UIEdgeInsetsInsetRect(originCellFrame, UIEdgeInsetsMake(topBottomInset, leftRightInset, topBottomInset, leftRightInset));
} else {
cell.layer.transform = CATransform3DMakeScale((_pageSize.width-self.leftRightSpace * 2)/_pageSize.width,(_pageSize.height-self.topBottomSpace * 2)/_pageSize.height, 1.0);
cell.frame = UIEdgeInsetsInsetRect(originCellFrame, UIEdgeInsetsMake(self.topBottomSpace,
self.leftRightSpace,
self.topBottomSpace,
self.leftRightSpace));
}
}
}
break;
case KiraBannerTypeVertical: {
CGFloat offset = _scrollView.contentOffset.y;
for (NSInteger i = self.visibleRange.location; i < self.visibleRange.location + self.visibleRange.length ; i++) {
UIView *cell = [self cellForIndex:i];
CGFloat origin = cell.frame.origin.y;
CGFloat delta = fabs(origin - offset);
CGRect originCellFrame = CGRectMake(0, _pageSize.width * i, _pageSize.width, _pageSize.height);
//TODO:透明度渐变
if (delta < _pageSize.height) {
CGFloat leftRightInset = self.leftRightSpace * delta / _pageSize.height;
CGFloat topBottomInset = self.topBottomSpace * delta / _pageSize.height;
cell.layer.transform = CATransform3DMakeScale((_pageSize.width-leftRightInset*2)/_pageSize.width,(_pageSize.height-topBottomInset*2)/_pageSize.height, 1.0);
cell.frame = UIEdgeInsetsInsetRect(originCellFrame, UIEdgeInsetsMake(topBottomInset, leftRightInset, topBottomInset, leftRightInset));
} else {
cell.layer.transform = CATransform3DMakeScale((_pageSize.width-self.leftRightSpace * 2)/_pageSize.width,(_pageSize.height-self.topBottomSpace * 2)/_pageSize.height, 1.0);
cell.frame = UIEdgeInsetsInsetRect(originCellFrame, UIEdgeInsetsMake(self.topBottomSpace,
self.leftRightSpace,
self.topBottomSpace,
self.leftRightSpace));
}
}
}
break;
default:
break;
}
}
5 .自动轮播。自动轮播就是通过设置timer来控制自动播放。其实自动播放也是通过设置scrollview的contentOffset来实现。需要注意在stoptimer的时候将time置为nil,并且在合适的时机调用start及stoptimer方法。
- (void)startTimer {
if (self.numberOfItems > 1 && self.isAutoScroll && self.isCircle) {
NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:self.autoTime target:self selector:@selector(autoPlay) userInfo:nil repeats:YES];
self.timer = timer;
[[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
}
}
- (void)stopTimer {
[self.timer invalidate];
self.timer = nil;
}
- (void)autoPlay {
self.page ++;
switch (self.bannerType) {
case KiraBannerTypeHorizontal: {
[_scrollView setContentOffset:CGPointMake(self.page * _pageSize.width, 0) animated:YES];
}
break;
case KiraBannerTypeVertical: {
[_scrollView setContentOffset:CGPointMake(0, self.page * _pageSize.height) animated:YES];
}
break;
default:
break;
}
}
6.Cell的点击回调。cell的点击回调通过在fillPageAtIndex方法中,对cell添加UITapGestureRecognizer手势,并且在@selector方法中,通过sender.view来拿到对应的cell。然后通过objc_getAssociatedObject拿到该cell关联的index进行回调。
//在fillPageAtIndex函数中,完善以下代码
UIView *cell = [self.dataSource kiraBanner:self viewForItemAtIndex:index % self.numberOfItems];
UITapGestureRecognizer * tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(cellTapped:)];
[cell addGestureRecognizer:tap];
cell.userInteractionEnabled = YES;
- (void)cellTapped:(UITapGestureRecognizer *)sender {
UIView * cell = sender.view;
NSInteger index = -1;
NSNumber *value = objc_getAssociatedObject(cell, @selector(cellForIndex:));
if (value) {
index = value.integerValue % self.numberOfItems;
}
if ([self.delegate respondsToSelector:@selector(didSelectCell:inKiraBannerAtIndex:)]) {
[self.delegate didSelectCell:cell inKiraBannerAtIndex:index];
}
}
END
- 实现还有很多不完善的地方,也可能会存在一些bug,希望大家能及时指出,一起讨论,共同进步
- 这里是gayhub的地址
网友评论