轻量级、新样式轮播视图。


一、前言

在拜读里脊串的开发随笔大神的博客时,发现个好玩的东西:《开源项目:XXPagingScrollView》。虽然是很常见的轮播视图,但是这种新样式的实现思路挺有意思。记得两年前在写某个项目时也遇到过这个,但是当然确实没有想出来咋实现这种非全屏有间隙的轮播控件。

读完大神的文章,很是兴奋,一般是不爱造重复的轮子,但是作者这个是Swift版本的,而且是基础组件,少了很多功能,所以心血来潮,在大神基础上再进一步。

相比较来说更加完善了:

  • 支持设置pagingWidth
  • 支持设置pageInset
  • 支持无限循环轮播
  • 支持本地图片
  • 支持网络图片
  • 支持自动轮播、设置轮播时间间隔
  • 支持点击回调

整体代码约150行左右,更加精简。

项目地址:https://github.com/ChenYalun/YAPageView

二、思路

实际上轮播视图是烂大街的东西了,网上现成代码特别多。一般而言,假定需要n个页面,实现方式有:

  1. 使用UIScrollView,添加 n 个UIImageView
  2. 使用UIScrollView,添加两个UIImageView,动态循环调整。
  3. 使用UICollectionView

方法1比较基础,更适合非循环;方法3比较简单,但是UICollectionView过于heavily。
方法2,很精简轻量,就是处理起来有点绕。然而,绕一次,换来永久的舒适,很值得。

如何实现自定义Page width的视图,作者给出的方案是:

方案1: 不使用pagingEnabled属性 而是手动的计算并设置滑动偏移
方案2: 使用pagingEnabled属性 扩大UIScrollview的显示范围即可

很明显,方案2更好。思路是,扩大UIScrollView的可显示范围并让UIScrollView响应超出其本身范围的触摸事件。思路知道了,实现起来很简单,就是clipsToBounds属性和pointInside方法。

间距处理

核心有三条:

  1. UIScrollView的宽度就是pageWidth + pageInset
  2. 为了保持左右显示区域的对称性,UIScrollViewx(superView.width - pageWidth) * 0.5
  3. 子视图的frame是CGRectMake(idx * (pageWidth + pageInset), 0, pageWidth, height),也即,子视图的宽度与UIScrollView的宽度保持一致。

无限循环处理

  1. 视图循环利用
    使用三个UIImageView,记为左、中、右,重复利用。默认显示中间的imageView。
  2. 刷新逻辑
    scrollViewDidScroll回调中,当UIScrollView即将显示出下一个(可能是左、也可能是右)视图时,立即调用刷新方法。刷新的逻辑是:
  • 重新计算left、currentIndex、right三个索引

    1
    2
    #define kLeft (_currentIndex == 0 ? kCount - 1 : _currentIndex - 1)
    #define kRight (_currentIndex == kCount - 1 ? 0 : _currentIndex + 1)
  • 对左、中、右三个UIImageView重新设置配图

    1
    2
    3
    self.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
    8
    CGFloat 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)];

    这样便实现了无限循环。

  1. 刷新时机处理
    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
7
CGFloat 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
2
3
_timer = [NSTimer timerWithTimeInterval:_timeInterval repeats:YES block:^(NSTimer *timer) {
[self.scrollView setContentOffset:CGPointMake(CGRectGetWidth(self.scrollView.frame) * 2, 0) animated:YES];
}];

并把定时器放到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
2
3
4
5
6
__weak typeof(self) weakSelf = self;
pageView.tapHandler = ^(NSUInteger idx, UIImage *img, NSURL *url) {
__strong typeof(weakSelf) self = weakSelf;
NSLog(@"self = %@, index = %lu, url = %@", self, (unsigned long)idx, url);
/// ....
};

万一调用方忘了呢,那就内存泄漏了。

大神玉令天下的方式是使用弱引用的变量持有外界的调用者,然后在适当时机将组件“自己”置为空。不过需要给调用者写个分类(属性)持有组件,还需要明确切断循环引用的时机,对于我的这个100来行的PageView,可能有点不太合适。

突然脑洞一开,想到一个方法,显式让使用方传进来调用者,重新定义外界的self,将其作为一个回调参数传递给使用方(PageView内部使用weak持有外界的self)。

1
@property (nonatomic, copy) void (^tapHandler)(NSUInteger idx, UIImage *img, NSURL *url, id self);

外界使用的时候像这样:

1
2
3
pageView.tapHandler = ^(NSUInteger idx, UIImage *img, NSURL *url, UIViewController *self) {
[self.navigationController popViewControllerAnimated:YES];
};

确实能解决循环引用,而且不是很费事。但是,实际使用的时候是这样的:

1
2
3
pageView.tapHandler = ^(NSUInteger idx, UIImage *img, NSURL *url, id controller) {
// 需要手动把id类型改成实际的类型,如UIViewController、UIView等等
};

并不知道外界的self是啥类型,只能用id。我感觉也不是很巧妙。所以,还是使用苹果推荐的主流的weak-strong吧。

三、使用

本地图片

本地图片直接传入UIImage数组即可。

1
2
3
4
5
6
7
8
9
10
// 指定构造器,设定pageWidth、pageInset等
YAPageView *pageView = [[YAPageView alloc] initWithFrame:CGRectMake(0, 200, kScreenWidth, 200) controller:self pageWidth:300 pageInset:20];
pageView.imageArray = @[
[UIImage imageNamed:@"1"],
[UIImage imageNamed:@"2"],
[UIImage imageNamed:@"3"],
[UIImage imageNamed:@"4"],
[UIImage imageNamed:@"5"],
[UIImage imageNamed:@"6"],
];

网络图片

网络图片传入图片URL数组,并设置处理图片的block。这个block指的是给UIImageView设置图片URL的方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 如果使用SDWebImage,可以这么设置
pageView.configImageHandler = ^(UIImageView *imageView, NSURL *url) {
[imageView sd_setImageWithURL:url];
};

pageView.imageURLArray = @[
[NSURL URLWithString:@"https://picsum.photos/id/230/350/200"],
[NSURL URLWithString:@"https://picsum.photos/id/231/350/200"],
[NSURL URLWithString:@"https://picsum.photos/id/232/350/200"],
[NSURL URLWithString:@"https://picsum.photos/id/233/350/200"],
[NSURL URLWithString:@"https://picsum.photos/id/234/350/200"],
[NSURL URLWithString:@"https://picsum.photos/id/235/350/200"]
];

设置自动轮播时间

1
pageView.timeInterval = 3.f;

设置点击回调

别忘了循环引用的问题。

1
2
3
4
5
6
__weak typeof(self) weakSelf = self;
pageView.tapHandler = ^(NSUInteger idx, UIImage *img, NSURL *url) {
__strong typeof(weakSelf) self = weakSelf;
NSLog(@"self = %@, index = %lu, url = %@", self, (unsigned long)idx, url);
[self.navigationController popViewControllerAnimated:YES];
};

四、总结

整体而言,没有冗余逻辑,能优化的也优化了(比如索引计算对取模的优化、定时器懒加载、刷新时机次数等等),应该算是比较轻量了吧哈哈哈哈。

具体应用方面,比如腾讯视频App中的首页Tab、会员Tab都有这种custom width的轮播图。

QQ音乐中的发现Tab也是这种轮播图,不过不能无限循环(不能无限循环岂不是更简单。。)。

好了,把两年前的坑填上了。


后记
有因必有果。
2019年11月,图搜进行大改版,pm有个新特性引导的需求。于是我在这个代码的基础上修修改改,很快就开发完毕了,大幅缩短了这块需求的开发时间😁。