美文网首页收藏ios
UIScrollView实现循环轮播Banner(自定义Cell

UIScrollView实现循环轮播Banner(自定义Cell

作者: 沙琪玛dd | 来源:发表于2018-02-25 20:26 被阅读128次
    效果图

    实现循环轮播图的各种方案

    • 轮播图的实现方案有很多种,大体上分为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出来。
    1. 我们先用一个继承于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;
    }
    
    1. 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);
    }
    
    1. 前置项已经准备好了,接下来就是在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;
            }
        }
    }
    
    1. 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的地址

    相关文章

      网友评论

        本文标题:UIScrollView实现循环轮播Banner(自定义Cell

        本文链接:https://www.haomeiwen.com/subject/ecyvtftx.html