开源项目:YAPageView
轻量级、新样式轮播视图。
一、前言
在拜读里脊串的开发随笔大神的博客时,发现个好玩的东西:《开源项目:XXPagingScrollView》。虽然是很常见的轮播视图,但是这种新样式的实现思路挺有意思。记得两年前在写某个项目时也遇到过这个,但是当然确实没有想出来咋实现这种非全屏有间隙的轮播控件。
读完大神的文章,很是兴奋,一般是不爱造重复的轮子,但是作者这个是Swift版本的,而且是基础组件,少了很多功能,所以心血来潮,在大神基础上再进一步。
相比较来说更加完善了:
- 支持设置pagingWidth
- 支持设置pageInset
- 支持无限循环轮播
- 支持本地图片
- 支持网络图片
- 支持自动轮播、设置轮播时间间隔
- 支持点击回调
整体代码约150行左右,更加精简。
项目地址:https://github.com/ChenYalun/YAPageView
二、思路
实际上轮播视图是烂大街的东西了,网上现成代码特别多。一般而言,假定需要n个页面,实现方式有:
- 使用
UIScrollView
,添加 n 个UIImageView
。 - 使用
UIScrollView
,添加两个UIImageView
,动态循环调整。 - 使用
UICollectionView
。
方法1比较基础,更适合非循环;方法3比较简单,但是UICollectionView
过于heavily。
方法2,很精简轻量,就是处理起来有点绕。然而,绕一次,换来永久的舒适,很值得。
如何实现自定义Page width
的视图,作者给出的方案是:
方案1: 不使用
pagingEnabled
属性 而是手动的计算并设置滑动偏移
方案2: 使用pagingEnabled
属性 扩大UIScrollview
的显示范围即可
很明显,方案2更好。思路是,扩大UIScrollView
的可显示范围并让UIScrollView响应超出其本身范围的触摸事件。思路知道了,实现起来很简单,就是clipsToBounds
属性和pointInside
方法。
间距处理
核心有三条:
UIScrollView
的宽度就是pageWidth + pageInset
。- 为了保持左右显示区域的对称性,
UIScrollView
的x
是(superView.width - pageWidth) * 0.5
。 - 子视图的frame是
CGRectMake(idx * (pageWidth + pageInset), 0, pageWidth, height)
,也即,子视图的宽度与UIScrollView
的宽度保持一致。
无限循环处理
- 视图循环利用
使用三个UIImageView
,记为左、中、右,重复利用。默认显示中间的imageView。 - 刷新逻辑
在scrollViewDidScroll
回调中,当UIScrollView
即将显示出下一个(可能是左、也可能是右)视图时,立即调用刷新方法。刷新的逻辑是:
重新计算left、currentIndex、right三个索引
1
2对左、中、右三个
UIImageView
重新设置配图1
2
3self.pageArray[0].image = self.imageArray[kLeft];
self.pageArray[1].image = self.imageArray[_currentIndex];
self.pageArray[2].image = self.imageArray[kRight];以非动画方式设置
UIScrollView
的偏移量1
2
3
4
5
6
7
8CGFloat x = self.scrollView.contentOffset.x;
CGFloat width = self.pageWidth + self.pageInset;
if (x == 0) {
x = width;
} else {
x += x > width ? -width : width;
}
[self.scrollView setContentOffset:CGPointMake(x, 0)];这样便实现了无限循环。
刷新时机处理
scrollViewDidScroll
会回调很多次,本来以为使用一个标志位就可以控制只刷新一次,但是并没有实现😂 所以,退一步,用了两个标志位:1
2@property (nonatomic, assign) BOOL leftLock;
@property (nonatomic, assign) BOOL rightLock;逻辑是这样的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14// Turn left.
if (offsetX > 2 * (width - self.pageInset)) self.rightLock = NO;
if (!self.leftLock && offsetX < width - 2 * self.pageInset) {
self.leftLock = YES;
_currentIndex = kLeft;
[self refresh];
}
// Turn right.
if (offsetX < 2 * self.pageInset) self.leftLock = NO;
if (!self.rightLock && offsetX > width + 2 * self.pageInset) {
self.rightLock = YES;
_currentIndex = kRight;
[self refresh];
}请牢记
UIScrollView
的宽度width是width=pageWidth+pageInset
,这里以即将显示右边视图为例:
scrollView的初始偏移量是width
,逐渐偏移,直到越过空白间距(pageInset)要显示下一个视图时,此时offsetX为width + (pageWidth - pageInset)
,也即代码中的2 * (width - pageInset)
,立即将offsetX还原为2 * (width - pageInset) - (width)
也就是pageWidth-pageInset
,对leftLock加锁,更新当前索引。
点击事件处理
对UIScrollView
添加点击手势,计算出当前点击位置在图片数组中的索引即可:1
2
3
4
5
6
7CGFloat pointX = [tap locationInView:tap.view].x;
NSUInteger idx = _currentIndex;
if (pointX < self.pageWidth + self.pageInset) {
idx = kLeft;
} else if (pointX > 2 * self.pageWidth + self.pageInset) {
idx = kRight;
}
自动轮播处理
当设置timeInterval
属性时,说明需要自动轮播,懒加载创建定时器:
1 | _timer = [NSTimer timerWithTimeInterval:_timeInterval repeats:YES block:^(NSTimer *timer) { |
并把定时器放到currentRunLoop
中,设置NSRunLoopCommonModes
。当然,需要在ScrollView的一些代理中处理用户手动滑动与定时器设置的滑动的冲突。
定时器循环引用处理
解决方式是,当PageView从父视图上移除时,手动销毁定时器:1
2
3
4
5
6
7- (void)willMoveToSuperview:(UIView *)newSuperview {
[super willMoveToSuperview:newSuperview];
if (newSuperview == nil) { // 视图从父视图移除时, 销毁定时器
[_timer invalidate];
_timer = nil;
}
}
点击回调循环引用处理
点击事件给出的接口我选择使用block,(使用弱引用的代理就不会有这个问题了,但是代理会增加调用的复杂性,设置代理、遵守协议、实现方法巴拉巴拉。。。)
然而,同许多视图的block回调一样,会有循环引用的问题,只能这样使用:
1 | __weak typeof(self) weakSelf = self; |
万一调用方忘了呢,那就内存泄漏了。
大神玉令天下的方式是使用弱引用的变量持有外界的调用者,然后在适当时机将组件“自己”置为空。不过需要给调用者写个分类(属性)持有组件,还需要明确切断循环引用的时机,对于我的这个100来行的PageView,可能有点不太合适。
突然脑洞一开,想到一个方法,显式让使用方传进来调用者,重新定义外界的self
,将其作为一个回调参数传递给使用方(PageView内部使用weak持有外界的self
)。
1 | @property (nonatomic, copy) void (^tapHandler)(NSUInteger idx, UIImage *img, NSURL *url, id self); |
外界使用的时候像这样:
1 | pageView.tapHandler = ^(NSUInteger idx, UIImage *img, NSURL *url, UIViewController *self) { |
确实能解决循环引用,而且不是很费事。但是,实际使用的时候是这样的:
1 | pageView.tapHandler = ^(NSUInteger idx, UIImage *img, NSURL *url, id controller) { |
并不知道外界的self
是啥类型,只能用id
。我感觉也不是很巧妙。所以,还是使用苹果推荐的主流的weak-strong吧。
三、使用
本地图片
本地图片直接传入UIImage数组即可。
1 | // 指定构造器,设定pageWidth、pageInset等 |
网络图片
网络图片传入图片URL数组,并设置处理图片的block。这个block指的是给UIImageView设置图片URL的方式。
1 | // 如果使用SDWebImage,可以这么设置 |
设置自动轮播时间
1 | pageView.timeInterval = 3.f; |
设置点击回调
别忘了循环引用的问题。
1 | __weak typeof(self) weakSelf = self; |
四、总结
整体而言,没有冗余逻辑,能优化的也优化了(比如索引计算对取模的优化、定时器懒加载、刷新时机次数等等),应该算是比较轻量了吧哈哈哈哈。
具体应用方面,比如腾讯视频App中的首页Tab、会员Tab都有这种custom width的轮播图。
QQ音乐中的发现Tab也是这种轮播图,不过不能无限循环(不能无限循环岂不是更简单。。)。
好了,把两年前的坑填上了。
后记
有因必有果。
2019年11月,图搜进行大改版,pm有个新特性引导的需求。于是我在这个代码的基础上修修改改,很快就开发完毕了,大幅缩短了这块需求的开发时间😁。