微信公众号:Android部落格
一、背景
电商App一般都会存在图片轮播的场景,而iOS没有轮播UI组件,因此需要自定义一个UI组件以适应项目需要。
二、框架
整体框架是UIScrollView作为父视图,在视图中添加多个子视图,同时设置好子视图的frame,再设置滚动视图的内容宽度,让UISCrollView能够左右滑动。接下来添加一个定时器,按照设定的播放时间间隔重复触发,循环播放子UIView即可。
三、定义属性
将对外暴露的属性定义到一个CustomBanner.h文件中,如下:
#import <UIKit/UIKit.h>
@protocol UICustomBannerDelegate <NSObject>
@optional
-(void)pageSelected:(int)index;
@end
@interface CustomBanner : UIView
@property bool showIndicator;//whether show bottom page control view
@property UIColor *indicatorColor;//default page indicator
@property UIColor *currentIndicatorColor;//selected page indicator color
@property float playIntervalTime;//images or some other uiviews interval play time
@property bool showScrollIndicator;//whether show scroll view indicator,including horizontal and vertical
@property (nonatomic, weak) id<UICustomBannerDelegate> delegate;
-(void)start:(NSArray *)views;
-(void)stop;
@end
- 首先定义了一个代理,用于对外发送当前播放view的序号,回调方法可选。
- 有一些项目的banner需要在播放的图片下边展示指示器,用于指示当前view被选中了,这里先提供是否展示的属性,如果可以展示的话,再提供默认和选中时指示器的颜色。这里的指示器使用cocoa touch默认提供的组件UIPageControl。
- 设置播放间隔,也就是设置定时器的触发间隔。
- 是否展示滚动视图的滚动条,一般是不显示。
- 对外提供代理设置对象。
- start方法对外暴露,用于提供一组播放UIView,调用这个方法的同时,会将子视图添加到UIScrollView中。
- stop方法用于在一些情景下停止播放。
四、实现
4.1 扩展
实现放在CustomBanner.m文件中,先扩展一下接口CustomBanner:
@interface CustomBanner () <UIScrollViewDelegate>
@property NSTimer *timer;
@property CGSize currentViewSize;
@property UIScrollView *contentScrollView;
@property UIPageControl *pageControlView;
@property CGSize scrollViewSize;
@end
这里定义了一个NSTimer对象,用于实现定时器,currentViewSize用于获取当前父视图的大小,用来调整UIScrollView的大小,contentScrollView是可滚动视图,pageControlView用来做指示器,scrollViewSize获取的是contentScrollView的大小,用来约束pageControlView的位置。
4.2 初始化
初始化如下:
-(instancetype)initWithCoder:(NSCoder *)aDecoder{
self = [super initWithCoder:aDecoder];
if(self){
[self initView:self.frame];
}
return self;
}
-(instancetype)initWithFrame:(CGRect)frame{
self = [super initWithFrame:frame];
if(self){
[self initView:frame];
}
return self;
}
-(void)initView:(CGRect)frame{
_contentScrollView = [[UIScrollView alloc] init];
_contentScrollView.delegate = self;
[_contentScrollView setPagingEnabled:true];
_pageControlView = [[UIPageControl alloc] init];
_pageControlView.currentPage = 0;
_pageControlView.hidesForSinglePage = true;
}
当从xib或storyboard里面加载的时候,会调用initWithCoder;如果直接用代码创建的话,就走initWithFrame,最终会走到initView方法,在这个方法里面,初始化了_contentScrollView和_pageControlView,这里暂时先不设置frame,当添加子视图的时候开始设置frame。另外要把_contentScrollView的setPagingEnabled属性设置为true,否则拖动的时候就没有分页效果了。
4.3 设置定时器
定时器用于定时触发,触发的时候修改当前展示的view,并将当前view的序号回调给消息注册者:
-(void)startTimer{
_timer = [NSTimer timerWithTimeInterval:_playIntervalTime == 0?2:_playIntervalTime
target:self
selector:@selector(updateTimer:)
userInfo:nil
repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:_timer forMode:NSDefaultRunLoopMode];
}
定时间隔不设置的话,默认2s。
看看触发的方法:
- (void)updateTimer:(NSTimer *)timer {
NSLog(@"updateTimer");
++default_init_index;
if(default_init_index == CHILD_ITEM_COUNT){
default_init_index = 0;
}
[self startScroll];
}
这里修改初始化子view的序号,不断累加,并重置。
4.4 添加子视图
添加子视图之后需要约束子视图的位置,以及设置UIScrollView和PageControl的一些属性,如下:
-(void)start:(NSArray *)views{
if(!views || views.count == 0){
return;
}
[self customInit];
NSInteger count = views.count;
CHILD_ITEM_COUNT = count;
_pageControlView.numberOfPages = count;
CGRect pageScrollRect = _pageControlView.frame;
[_pageControlView setFrame:CGRectMake(pageScrollRect.origin.x - pageScrollRect.size.width / 2, _scrollViewSize.height, 39, FIX_PAGE_CONTROL_VIEW_HEIGHT)];
for(int index = 0; index < count;index++){
UIView *childView = [views objectAtIndex:index];
float childViewPointX = index * _scrollViewSize.width;
[childView setFrame:CGRectMake(childViewPointX, 0, _scrollViewSize.width, _scrollViewSize.height)];
[_contentScrollView addSubview:childView];
}
[_contentScrollView setContentSize:CGSizeMake(_scrollViewSize.width * count, _scrollViewSize.height)];
[self startTimer];
}
- 先获取子视图的个数,个数就是_pageControlView要控制的页面数
- 调整_pageControlView的位置。整个_pageControlView应该是居中的,因此这里的逻辑是将他的center和_contentScrollView的center对齐,然后减去_pageControlView宽度的一半就可以整体居中了。y坐标就是_contentScrollView的高度就行了。
- 将子视图添加到_contentScrollView,并随后设置他的ContentSize属性,如果不设置的话,就不能左右滑动了。宽度是父视图宽度乘以子view的个数,高度就是父视图高度。
4.5 调整视图
在customInit方法中,将_pageControlView和_contentScrollView的宽高做了一些调整:
-(void)customInit{
_currentViewSize = self.frame.size;
NSLog(@"size width height %f %f ",_currentViewSize.width,_currentViewSize.height);
CGRect scrollViewRect = CGRectMake(0, 0, _currentViewSize.width, _showIndicator?_currentViewSize.height - FIX_PAGE_CONTROL_VIEW_HEIGHT:_currentViewSize.height);
[_contentScrollView setFrame:scrollViewRect];
_scrollViewSize = scrollViewRect.size;
if(!_showScrollIndicator){
_contentScrollView.showsHorizontalScrollIndicator = false;
_contentScrollView.showsVerticalScrollIndicator = false;
}
if(_showIndicator){
_pageControlView.pageIndicatorTintColor = _indicatorColor?_indicatorColor:[UIColor colorWithRed:190.0/255 green:190.0/255 blue:190.0/255 alpha:1];
_pageControlView.currentPageIndicatorTintColor = _currentIndicatorColor?_indicatorColor:[UIColor colorWithRed:0 green:0 blue:0 alpha:1];
_pageControlView.currentPage = 0;
_pageControlView.hidesForSinglePage = true;
_pageControlView.center = _contentScrollView.center;
[_pageControlView setFrame:CGRectMake(_contentScrollView.center.x, _scrollViewSize.height, 39, FIX_PAGE_CONTROL_VIEW_HEIGHT)];
}
[self addSubview:_contentScrollView];
if(_showIndicator){
[self addSubview:_pageControlView];
}
}
-
重新设置_contentScrollView的frame属性是因为,在调用的时候,将这个banner放到storyboard里面添加与父视图等宽的约束之后,发现banner的宽度总是小于模拟器设备的宽度。
通过搜索发现,如果在UIScrollView里面添加一个UIView作为contentView,并对这个view设置等宽,左右间距为0,centerX,centerY,然后在UIScrollView的size inspector视图中将intrinsic size设置为placeholder之后,UIScrollView的宽度就与父视图等宽了。这里不会使用xib去初始化UIScrollView,所以在start之前还是要设置一下banner的frame,后面讲怎么用的时候会讲到这个问题。
-
接下来分别处理了是否展示滚动条和展示分页指示器,如果不展示分页指示器的话,就不将其放到父视图里面了。
4.6 开始播放
通过定时器默认启动播放,如下:
-(void)startScroll{
if ([self.delegate respondsToSelector:@selector(pageSelected:)]) {
[self.delegate pageSelected:default_init_index];
}
_pageControlView.currentPage = default_init_index;
int offsetIndex = default_init_index % CHILD_ITEM_COUNT;
float currentPointX = offsetIndex * _currentViewSize.width;
[_contentScrollView setContentOffset:CGPointMake(currentPointX, 0) animated:true];
}
每次滚动的时候,重新算一个当前的序号,并回调给消息注册者。其实滚动的实现依靠设置UIScrollView的内容偏移量。
4.7 处理手势拖拽
在上面扩展章节,可以看到我们继承了UIScrollViewDelegate,因此在.m文件中,可以定义相关代理方法处理回调,如下:
#pragma scrollview delegate
-(void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView{
float currentOffsetX = _contentScrollView.contentOffset.x;
default_init_index = (int)(currentOffsetX / _currentViewSize.width);
NSLog(@"scrollViewDidEndDecelerating currentOffsetX = %f,default_init_index:%d",currentOffsetX,default_init_index);
_pageControlView.currentPage = default_init_index;
if ([self.delegate respondsToSelector:@selector(pageSelected:)]) {
[self.delegate pageSelected:default_init_index];
}
[self startTimer];
}
-(void)scrollViewWillBeginDragging:(UIScrollView *)scrollView{
NSLog(@"scrollViewWillBeginDragging");
[self stop];
}
#pragma scrollview delegate
- scrollViewDidEndDecelerating 意思就是滚动已经结束了,在这里我们重新计算了当前子view的序号,并把最新的序号设置给_pageControlView,并发送页面变动消息给注册者。处理完毕重新开启定时器。
- scrollViewWillBeginDragging 用户开始拖拽了,这时候需要将定时器取消,以用户的操作为主
自此所有实现已经完成。
五、使用
在ViewController中既可以通过代码初始化也可以通过storyboard添加一个UIView并将其属性设置为CustomBanner。以后者为例,看看在ViewController中怎么调用的。
5.1 引入代理
在ViewController中添加代理
@interface ViewController ()<UIScrollViewDelegate , UICustomBannerDelegate>
@property (strong, nonatomic) IBOutlet CustomBanner *customBanner;
@end
5.2 添加子视图
在viewWillAppear回调方法中开始添加子视图,这里以添加UIImageView举例说明:
-(void)viewWillAppear:(BOOL)animated{
CGSize currentSize = self.view.frame.size;
NSLog(@"parent size %f %f",currentSize.width,currentSize.height);
_customBanner.showIndicator = true;
_customBanner.delegate = self;
NSArray *imagesUrl = [[NSArray alloc] initWithObjects:@"a1.jpg",
@"a2.jpg",
@"a3.jpeg",
@"a4.jpeg",nil];
double bannerViewHeight = _customBanner.frame.size.height;
NSMutableArray *imageViewArray = [NSMutableArray new];
CGSize destSize = CGSizeMake(currentSize.width, 200);
CGFloat scale = 1;
if ([[UIScreen mainScreen] scale] > 1.0) {
scale = 2;
destSize.width *= 2;
destSize.height *= 2;
}
NSString *bundlePath = [[NSBundle mainBundle] pathForResource:@"banner" ofType:@"bundle"];
for(int index = 0;index < imagesUrl.count;index++){
NSString *imgPath= [bundlePath stringByAppendingPathComponent:[imagesUrl objectAtIndex:index]];
NSLog(@"imgPath is %@",imgPath);
NSData *data = [[NSFileManager defaultManager] contentsAtPath:imgPath];
UIImage *image = [self downsample:data pointSize:destSize scale:scale];
UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, currentSize.width, bannerViewHeight)];
imageView.image = image;
[imageViewArray addObject:imageView];
}
CGPoint bannerPoint = CGPointMake(_customBanner.frame.origin.x, _customBanner.frame.origin.y);
[_customBanner setFrame:CGRectMake(bannerPoint.x, bannerPoint.y, currentSize.width, _customBanner.frame.size.height)];
[_customBanner start:imageViewArray];
}
-
在customBanner调用start方法之前,先设置一下frame,目的是设置自身的宽度,避免其宽度与父视图的宽度不一致。
-
当banner轮播图片的分辨率远远小于banner本身的尺寸时,要对图片做缩放处理,以当前测试的四张高分辨率的图片来说,就消耗100M以上的内存。因为图片在被加载之前是压缩格式,正式加载之前还要解压缩,再渲染到控件上,对CPU和内存都是不小的考验。这里使用了苹果推荐的downsampling方法,如下:
-(UIImage*)downsample:(NSObject *)imageSource pointSize:(CGSize)pointSize scale:(CGFloat)scale{
CGFloat maxw = pointSize.width;
CGFloat maxh = pointSize.height;
CGImageSourceRef src = NULL;
if ([imageSource isKindOfClass:[NSURL class]])
src = CGImageSourceCreateWithURL((__bridge CFURLRef)imageSource, nil);
else if ([imageSource isKindOfClass:[NSData class]])
src = CGImageSourceCreateWithData((__bridge CFDataRef)imageSource, nil);
// load the image at the desired size
NSDictionary* d = @{
(id)kCGImageSourceShouldAllowFloat: (id)kCFBooleanTrue,
(id)kCGImageSourceCreateThumbnailWithTransform: (id)kCFBooleanTrue,
(id)kCGImageSourceCreateThumbnailFromImageAlways: (id)kCFBooleanTrue,
(id)kCGImageSourceThumbnailMaxPixelSize: @((int)(maxw > maxh ? maxw : maxh))
};
CGImageRef imref = CGImageSourceCreateThumbnailAtIndex(src, 0, (__bridge CFDictionaryRef)d);
if (NULL != src)
CFRelease(src);
UIImage* im = [UIImage imageWithCGImage:imref scale:scale orientation:UIImageOrientationUp];
if (NULL != imref)
CFRelease(imref);
return im;
}
这样处理图片之后,内存消耗明显降低。
上面的四张图片是放到自定义的bundle里面引用的。因为是自定义的bundle,这里需要注意的是,自定义bundle build之后,需要在主工程中将这个bundle添加到主bundle里去,否则调用[NSBundle mainBundle]
方法的时候返回nil。添加路径是:选中主工程/build phases/copy bundle resources,点击+号添加。
六、最后一公里
CustomBanner.h
#import <UIKit/UIKit.h>
@protocol UICustomBannerDelegate <NSObject>
@optional
-(void)pageSelected:(int)index;
@end
@interface CustomBanner : UIView
@property bool showIndicator;//whether show bottom page control view
@property UIColor *indicatorColor;//default page indicator
@property UIColor *currentIndicatorColor;//selected page indicator color
@property float playIntervalTime;//images or some other uiviews interval play time
@property bool showScrollIndicator;//whether show scroll view indicator,including horizontal and vertical
@property (nonatomic, weak) id<UICustomBannerDelegate> delegate;
-(void)start:(NSArray *)views;
-(void)stop;
@end
CustomBanner.m
#import "CustomBanner.h"
@interface CustomBanner () <UIScrollViewDelegate>
//@property NSTimer *timer;
@property dispatch_source_t timer;
@property CGSize currentViewSize;
@property UIScrollView *contentScrollView;
@property UIPageControl *pageControlView;
@property CGSize scrollViewSize;
@end
@implementation CustomBanner
NSInteger CHILD_ITEM_COUNT = 0;
const CGFloat FIX_PAGE_CONTROL_VIEW_HEIGHT = 37;
int default_init_index = 0;
-(instancetype)initWithCoder:(NSCoder *)aDecoder{
self = [super initWithCoder:aDecoder];
if(self){
[self initView:self.frame];
}
return self;
}
-(instancetype)initWithFrame:(CGRect)frame{
self = [super initWithFrame:frame];
if(self){
[self initView:frame];
}
return self;
}
-(void)initView:(CGRect)frame{
_contentScrollView = [[UIScrollView alloc] init];
_contentScrollView.delegate = self;
[_contentScrollView setPagingEnabled:true];
_pageControlView = [[UIPageControl alloc] init];
_pageControlView.currentPage = 0;
_pageControlView.hidesForSinglePage = true;
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
_timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
}
-(void)customInit{
_currentViewSize = self.frame.size;
NSLog(@"size width height %f %f ",_currentViewSize.width,_currentViewSize.height);
CGRect scrollViewRect = CGRectMake(0, 0, _currentViewSize.width, _showIndicator?_currentViewSize.height - FIX_PAGE_CONTROL_VIEW_HEIGHT:_currentViewSize.height);
[_contentScrollView setFrame:scrollViewRect];
_scrollViewSize = scrollViewRect.size;
if(!_showScrollIndicator){
_contentScrollView.showsHorizontalScrollIndicator = false;
_contentScrollView.showsVerticalScrollIndicator = false;
}
if(_showIndicator){
_pageControlView.pageIndicatorTintColor = _indicatorColor?_indicatorColor:[UIColor colorWithRed:190.0/255 green:190.0/255 blue:190.0/255 alpha:1];
_pageControlView.currentPageIndicatorTintColor = _currentIndicatorColor?_indicatorColor:[UIColor colorWithRed:0 green:0 blue:0 alpha:1];
_pageControlView.currentPage = 0;
_pageControlView.hidesForSinglePage = true;
_pageControlView.center = _contentScrollView.center;
[_pageControlView setFrame:CGRectMake(_contentScrollView.center.x, _scrollViewSize.height, 39, FIX_PAGE_CONTROL_VIEW_HEIGHT)];
}
[self addSubview:_contentScrollView];
if(_showIndicator){
[self addSubview:_pageControlView];
}
}
-(void)start:(NSArray *)views{
if(!views || views.count == 0){
return;
}
[self customInit];
NSInteger count = views.count;
CHILD_ITEM_COUNT = count;
_pageControlView.numberOfPages = count;
CGRect pageScrollRect = _pageControlView.frame;
[_pageControlView setFrame:CGRectMake(pageScrollRect.origin.x - pageScrollRect.size.width / 2, _scrollViewSize.height, 39, FIX_PAGE_CONTROL_VIEW_HEIGHT)];
for(int index = 0; index < count;index++){
UIView *childView = [views objectAtIndex:index];
float childViewPointX = index * _scrollViewSize.width;
[childView setFrame:CGRectMake(childViewPointX, 0, _scrollViewSize.width, _scrollViewSize.height)];
[_contentScrollView addSubview:childView];
}
[_contentScrollView setContentSize:CGSizeMake(_scrollViewSize.width * count, _scrollViewSize.height)];
dispatch_source_set_timer(_timer, dispatch_walltime(NULL, 0), (_playIntervalTime == 0?2:_playIntervalTime) * NSEC_PER_SEC, 0);
dispatch_source_set_event_handler(_timer, ^{
dispatch_async(dispatch_get_main_queue(), ^{
// 在主线程中实现需要的功能
[self updateTimer];
});
});
[self startTimer];
}
-(void)startTimer{
dispatch_resume(_timer);
// _timer = [NSTimer timerWithTimeInterval:_playIntervalTime == 0?2:_playIntervalTime
// target:self
// selector:@selector(updateTimer:)
// userInfo:nil
// repeats:YES];
// [[NSRunLoop currentRunLoop] addTimer:_timer forMode:NSDefaultRunLoopMode];
}
- (void)updateTimer {
// NSLog(@"updateTimer");
++default_init_index;
if(default_init_index == CHILD_ITEM_COUNT){
default_init_index = 0;
}
[self startScroll];
}
-(void)startScroll{
if ([self.delegate respondsToSelector:@selector(pageSelected:)]) {
[self.delegate pageSelected:default_init_index];
}
_pageControlView.currentPage = default_init_index;
int offsetIndex = default_init_index % CHILD_ITEM_COUNT;
float currentPointX = offsetIndex * _currentViewSize.width;
[_contentScrollView setContentOffset:CGPointMake(currentPointX, 0) animated:true];
}
-(void)stop{
if(_timer){
NSLog(@"stop Timer");
dispatch_source_cancel(_timer);
_timer = nil;
}
}
#pragma scrollview delegate
-(void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView{
float currentOffsetX = _contentScrollView.contentOffset.x;
default_init_index = (int)(currentOffsetX / _currentViewSize.width);
NSLog(@"scrollViewDidEndDecelerating currentOffsetX = %f,default_init_index:%d",currentOffsetX,default_init_index);
_pageControlView.currentPage = default_init_index;
if ([self.delegate respondsToSelector:@selector(pageSelected:)]) {
[self.delegate pageSelected:default_init_index];
}
[self startTimer];
}
-(void)scrollViewWillBeginDragging:(UIScrollView *)scrollView{
NSLog(@"scrollViewWillBeginDragging");
dispatch_suspend(_timer);
}
#pragma scrollview delegate
@end
微信公众号: