iOS开发基础68-图片轮播:从原理到完整实现(UIScrollView 与 UICollectionView 双方案)
iOS 图片轮播深度解析:从原理到完整实现(UIScrollView 与 UICollectionView 双方案)
图片轮播是 App 中最常见的 UI 组件之一,首页 Banner、商品详情图、引导页、广告位都离不开它。本文将从最基础的原理讲起,用通俗易懂的方式带你实现一个功能完整的图片轮播器,包含手动滑动、无限循环、自动滚动、页码指示器、点击事件等全部功能,并提供 UIScrollView 和 UICollectionView 两种实现方案。
一、图片轮播简介
1. 应用场景
- 首页 Banner 广告位
- 商品详情页多图浏览
- App 引导页
- 活动宣传轮播
- 新闻资讯头图
2. 核心功能点
一个完整的图片轮播器通常包含:
| 功能 | 说明 |
|---|---|
| 手动滑动 | 用户手指左右滑动切换图片 |
| 无限循环 | 滑到最后一张后继续滑回到第一张,反之亦然 |
| 自动滚动 | 每隔几秒自动切换到下一张 |
| 页码指示器 | 底部小圆点显示当前是第几张 |
| 点击事件 | 点击某张图片触发回调 |
| 拖拽暂停 | 用户拖拽时暂停自动滚动,松开后恢复 |
3. 两种实现方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| UIScrollView + UIImageView | 原理简单,容易理解,轻量 | 图片多时内存占用大,复用困难 | 图片数量少(<10张),学习理解原理 |
| UICollectionView | 单元格复用,内存友好,支持大量图片 | 原理稍复杂 | 图片数量多,生产环境推荐 |
二、核心原理
1. UIScrollView 方案原理
┌─────────────────────────────────────────────┐
│ UIScrollView(可视区域,宽度 = 屏幕宽) │
│ │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ 图1 │ │ 图2 │ │ 图3 │ │ 图4 │ │
│ └──────┘ └──────┘ └──────┘ └──────┘ │
│ contentSize.width = 4 * 屏幕宽 │
└─────────────────────────────────────────────┘
- UIScrollView 的宽度 = 屏幕宽度,每次只能看到一张图。
- 内部横向排列多个 UIImageView,每个宽度 = 屏幕宽度。
contentSize.width = 图片数量 × 屏幕宽度。- 通过
contentOffset.x控制当前显示哪一张。
2. 无限循环原理(数据复制)
直接用原始数据无法无限循环(滑到最后一张就不能再往右了)。经典解决方案是数据复制三份:
原始数据: [图1, 图2, 图3]
复制后: [图3, 图1, 图2, 图3, 图1]
↑ ↑ ↑
前置副本 原始数据 后置副本
初始显示中间的"图1"(index = 1)
- 初始显示中间那份的第一张。
- 向右滑到最后(后置副本的图1)时,无动画跳回中间那份的图1,用户感觉是无缝的。
- 向左滑到最前(前置副本的图3)时,无动画跳回中间那份的图3。
关键:跳转时用
setContentOffset:animated:NO,无动画,用户感知不到。
3. 自动滚动原理
用 NSTimer 每隔几秒改变一次 contentOffset:
// 每隔 3 秒,contentOffset.x 增加一个屏幕宽度
self.timer = [NSTimer scheduledTimerWithTimeInterval:3.0 target:self selector:@selector(autoScroll) userInfo:nil repeats:YES];
- (void)autoScroll {
CGFloat nextOffset = self.scrollView.contentOffset.x + self.scrollView.bounds.size.width;
[self.scrollView setContentOffset:CGPointMake(nextOffset, 0) animated:YES];
}
三、方案一:UIScrollView 实现(基础易懂版)
1. 接口定义
HWImageCarouselView.h
#import <UIKit/UIKit.h>
@class HWImageCarouselView;
@protocol HWImageCarouselViewDelegate <NSObject>
/// 点击了某张图片
- (void)carouselView:(HWImageCarouselView *)carouselView didSelectItemAtIndex:(NSInteger)index;
@end
@interface HWImageCarouselView : UIView
/// 图片数组(UIImage 或 NSString URL)
@property (nonatomic, strong) NSArray *images;
/// 自动滚动间隔(默认 3 秒,设为 0 关闭自动滚动)
@property (nonatomic, assign) NSTimeInterval autoScrollInterval;
/// 代理
@property (nonatomic, weak) id<HWImageCarouselViewDelegate> delegate;
/// 当前页码(真实数据中的索引)
@property (nonatomic, assign, readonly) NSInteger currentIndex;
/// 快速创建
+ (instancetype)carouselViewWithFrame:(CGRect)frame images:(NSArray *)images;
@end
2. 完整实现
HWImageCarouselView.m
#import "HWImageCarouselView.h"
@interface HWImageCarouselView () <UIScrollViewDelegate>
@property (nonatomic, strong) UIScrollView *scrollView;
@property (nonatomic, strong) UIPageControl *pageControl;
@property (nonatomic, strong) NSTimer *timer;
/// 用于无限循环的图片数组(原始数据前后各复制一份)
@property (nonatomic, strong) NSArray *loopImages;
/// 真实图片数量
@property (nonatomic, assign) NSInteger realCount;
@end
@implementation HWImageCarouselView
#pragma mark - 初始化
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
_autoScrollInterval = 3.0;
[self setupUI];
}
return self;
}
+ (instancetype)carouselViewWithFrame:(CGRect)frame images:(NSArray *)images {
HWImageCarouselView *carousel = [[HWImageCarouselView alloc] initWithFrame:frame];
carousel.images = images;
return carousel;
}
- (void)setupUI {
// UIScrollView
self.scrollView = [[UIScrollView alloc] initWithFrame:self.bounds];
self.scrollView.delegate = self;
self.scrollView.pagingEnabled = YES; // 整页翻动
self.scrollView.showsHorizontalScrollIndicator = NO;
self.scrollView.showsVerticalScrollIndicator = NO;
self.scrollView.bounces = NO;
[self addSubview:self.scrollView];
// 页码指示器
self.pageControl = [[UIPageControl alloc] init];
self.pageControl.pageIndicatorTintColor = [UIColor colorWithWhite:1.0 alpha:0.5];
self.pageControl.currentPageIndicatorTintColor = [UIColor whiteColor];
[self addSubview:self.pageControl];
}
- (void)layoutSubviews {
[super layoutSubviews];
self.scrollView.frame = self.bounds;
// 页码指示器位置(底部居中)
CGFloat pageW = 100;
CGFloat pageH = 20;
self.pageControl.frame = CGRectMake((self.bounds.size.width - pageW) / 2,
self.bounds.size.height - pageH - 8,
pageW, pageH);
// 重新布局图片
[self layoutImages];
}
#pragma mark - 设置图片
- (void)setImages:(NSArray *)images {
_images = images;
self.realCount = images.count;
if (images.count == 0) return;
// 数据复制三份:[最后一张] + 原始 + [第一张]
NSMutableArray *loop = [NSMutableArray array];
[loop addObject:images.lastObject]; // 前置副本
[loop addObjectsFromArray:images]; // 原始数据
[loop addObject:images.firstObject]; // 后置副本
self.loopImages = [loop copy];
// 设置页码指示器
self.pageControl.numberOfPages = self.realCount;
self.pageControl.currentPage = 0;
// 布局图片
[self layoutImages];
// 初始显示中间那份的第一张(index = 1)
[self.scrollView setContentOffset:CGPointMake(self.bounds.size.width, 0) animated:NO];
// 启动自动滚动
[self startAutoScroll];
}
- (void)layoutImages {
if (self.loopImages.count == 0) return;
// 移除旧的 imageView
[self.scrollView.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)];
CGFloat width = self.bounds.size.width;
CGFloat height = self.bounds.size.height;
for (NSInteger i = 0; i < self.loopImages.count; i++) {
UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(i * width, 0, width, height)];
imageView.contentMode = UIViewContentModeScaleAspectFill;
imageView.clipsToBounds = YES;
imageView.userInteractionEnabled = YES;
imageView.tag = i; // 用 tag 记录索引
// 设置图片(支持 UIImage 和 NSString URL)
id imageObj = self.loopImages[i];
if ([imageObj isKindOfClass:[UIImage class]]) {
imageView.image = imageObj;
} else if ([imageObj isKindOfClass:[NSString class]]) {
// 实际项目中用 SDWebImage 加载网络图片
// [imageView sd_setImageWithURL:[NSURL URLWithString:imageObj] placeholderImage:[UIImage imageNamed:@"placeholder"]];
imageView.image = [UIImage imageNamed:imageObj];
}
// 点击手势
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(imageTapped:)];
[imageView addGestureRecognizer:tap];
[self.scrollView addSubview:imageView];
}
// 设置 contentSize
self.scrollView.contentSize = CGSizeMake(self.loopImages.count * width, height);
}
#pragma mark - 点击事件
- (void)imageTapped:(UITapGestureRecognizer *)tap {
NSInteger loopIndex = tap.view.tag;
// 转换为真实索引(loopImages 中 index=0 是前置副本,index=1~realCount 是真实数据)
NSInteger realIndex = loopIndex - 1;
// 处理边界
if (realIndex < 0) realIndex = self.realCount - 1;
if (realIndex >= self.realCount) realIndex = 0;
if ([self.delegate respondsToSelector:@selector(carouselView:didSelectItemAtIndex:)]) {
[self.delegate carouselView:self didSelectItemAtIndex:realIndex];
}
}
#pragma mark - 当前页码
- (NSInteger)currentIndex {
CGFloat width = self.bounds.size.width;
NSInteger loopIndex = (self.scrollView.contentOffset.x + width * 0.5) / width;
NSInteger realIndex = loopIndex - 1;
if (realIndex < 0) realIndex = self.realCount - 1;
if (realIndex >= self.realCount) realIndex = 0;
return realIndex;
}
#pragma mark - UIScrollViewDelegate
// 滚动中:更新页码指示器
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
CGFloat width = self.bounds.size.width;
NSInteger loopIndex = (scrollView.contentOffset.x + width * 0.5) / width;
NSInteger realIndex = loopIndex - 1;
if (realIndex < 0) realIndex = self.realCount - 1;
if (realIndex >= self.realCount) realIndex = 0;
self.pageControl.currentPage = realIndex;
}
// 滚动结束(手动滑动或自动滚动动画结束):检查是否需要跳转实现无限循环
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
[self checkLoopAndJump];
}
- (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView {
[self checkLoopAndJump];
}
/// 检查是否滑到了边界副本,如果是则无动画跳回中间对应位置
- (void)checkLoopAndJump {
CGFloat width = self.bounds.size.width;
NSInteger loopIndex = self.scrollView.contentOffset.x / width;
if (loopIndex == 0) {
// 滑到了前置副本(最后一张的副本),跳转到中间那份的最后一张
[self.scrollView setContentOffset:CGPointMake(self.realCount * width, 0) animated:NO];
} else if (loopIndex == self.loopImages.count - 1) {
// 滑到了后置副本(第一张的副本),跳转到中间那份的第一张
[self.scrollView setContentOffset:CGPointMake(width, 0) animated:NO];
}
}
// 用户开始拖拽:暂停自动滚动
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
[self stopAutoScroll];
}
// 用户结束拖拽:恢复自动滚动
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate {
[self startAutoScroll];
}
#pragma mark - 自动滚动
- (void)startAutoScroll {
if (self.autoScrollInterval <= 0) return;
if (self.timer) return; // 已经在运行
self.timer = [NSTimer scheduledTimerWithTimeInterval:self.autoScrollInterval
target:self
selector:@selector(autoScrollNext)
userInfo:nil
repeats:YES];
// 加入 common 模式,避免拖拽时不触发
[[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
}
- (void)stopAutoScroll {
[self.timer invalidate];
self.timer = nil;
}
- (void)autoScrollNext {
CGFloat width = self.bounds.size.width;
CGFloat nextOffset = self.scrollView.contentOffset.x + width;
[self.scrollView setContentOffset:CGPointMake(nextOffset, 0) animated:YES];
}
- (void)dealloc {
[self stopAutoScroll];
}
@end
3. 使用示例
#import "HWImageCarouselView.h"
@interface ViewController () <HWImageCarouselViewDelegate>
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
// 本地图片
NSArray *images = @[@"banner1", @"banner2", @"banner3", @"banner4"];
// 创建轮播器
HWImageCarouselView *carousel = [HWImageCarouselView carouselViewWithFrame:CGRectMake(0, 80, self.view.bounds.size.width, 200) images:images];
carousel.autoScrollInterval = 3.0; // 3秒自动滚动
carousel.delegate = self;
[self.view addSubview:carousel];
}
#pragma mark - HWImageCarouselViewDelegate
- (void)carouselView:(HWImageCarouselView *)carouselView didSelectItemAtIndex:(NSInteger)index {
NSLog(@"点击了第 %ld 张图片", (long)index);
}
@end
四、方案二:UICollectionView 实现(生产推荐版)
UIScrollView 方案会一次性创建所有 UIImageView,图片多时内存占用大。UICollectionView 自带单元格复用,内存友好,是生产环境的推荐方案。
1. 核心差异
| 对比项 | UIScrollView 方案 | UICollectionView 方案 |
|---|---|---|
| 图片视图 | 全部创建,不复用 | cell 复用,内存友好 |
| 无限循环 | 数据复制三份 | 同样用数据复制三份 |
| 自动滚动 | Timer + setContentOffset | Timer + scrollToItemAtIndexPath |
| 适用图片数 | <10 张 | 任意数量 |
2. 完整实现
HWCollectionCarouselView.h
#import <UIKit/UIKit.h>
@class HWCollectionCarouselView;
@protocol HWCollectionCarouselViewDelegate <NSObject>
- (void)carouselView:(HWCollectionCarouselView *)carouselView didSelectItemAtIndex:(NSInteger)index;
@end
@interface HWCollectionCarouselView : UIView
@property (nonatomic, strong) NSArray *images;
@property (nonatomic, assign) NSTimeInterval autoScrollInterval;
@property (nonatomic, weak) id<HWCollectionCarouselViewDelegate> delegate;
+ (instancetype)carouselWithFrame:(CGRect)frame images:(NSArray *)images;
@end
HWCollectionCarouselView.m
#import "HWCollectionCarouselView.h"
@interface HWCarouselCell : UICollectionViewCell
@property (nonatomic, strong) UIImageView *imageView;
@end
@implementation HWCarouselCell
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
self.imageView = [[UIImageView alloc] initWithFrame:self.contentView.bounds];
self.imageView.contentMode = UIViewContentModeScaleAspectFill;
self.imageView.clipsToBounds = YES;
self.imageView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
[self.contentView addSubview:self.imageView];
}
return self;
}
@end
@interface HWCollectionCarouselView () <UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout>
@property (nonatomic, strong) UICollectionView *collectionView;
@property (nonatomic, strong) UIPageControl *pageControl;
@property (nonatomic, strong) NSTimer *timer;
@property (nonatomic, strong) NSArray *loopImages;
@property (nonatomic, assign) NSInteger realCount;
@end
@implementation HWCollectionCarouselView
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
_autoScrollInterval = 3.0;
[self setupUI];
}
return self;
}
+ (instancetype)carouselWithFrame:(CGRect)frame images:(NSArray *)images {
HWCollectionCarouselView *carousel = [[HWCollectionCarouselView alloc] initWithFrame:frame];
carousel.images = images;
return carousel;
}
- (void)setupUI {
UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc] init];
layout.scrollDirection = UICollectionViewScrollDirectionHorizontal;
layout.minimumLineSpacing = 0;
layout.minimumInteritemSpacing = 0;
self.collectionView = [[UICollectionView alloc] initWithFrame:self.bounds collectionViewLayout:layout];
self.collectionView.dataSource = self;
self.collectionView.delegate = self;
self.collectionView.pagingEnabled = YES;
self.collectionView.showsHorizontalScrollIndicator = NO;
self.collectionView.bounces = NO;
[self.collectionView registerClass:[HWCarouselCell class] forCellWithReuseIdentifier:@"CarouselCell"];
[self addSubview:self.collectionView];
self.pageControl = [[UIPageControl alloc] init];
self.pageControl.pageIndicatorTintColor = [UIColor colorWithWhite:1.0 alpha:0.5];
self.pageControl.currentPageIndicatorTintColor = [UIColor whiteColor];
[self addSubview:self.pageControl];
}
- (void)layoutSubviews {
[super layoutSubviews];
self.collectionView.frame = self.bounds;
self.pageControl.frame = CGRectMake(0, self.bounds.size.height - 24, self.bounds.size.width, 20);
}
- (void)setImages:(NSArray *)images {
_images = images;
self.realCount = images.count;
if (images.count == 0) return;
// 数据复制三份
NSMutableArray *loop = [NSMutableArray array];
[loop addObject:images.lastObject];
[loop addObjectsFromArray:images];
[loop addObject:images.firstObject];
self.loopImages = [loop copy];
self.pageControl.numberOfPages = self.realCount;
[self.collectionView reloadData];
// 初始显示中间那份的第一张
dispatch_async(dispatch_get_main_queue(), ^{
[self.collectionView scrollToItemAtIndexPath:[NSIndexPath indexPathForItem:1 inSection:0] atScrollPosition:UICollectionViewScrollPositionLeft animated:NO];
});
[self startAutoScroll];
}
#pragma mark - UICollectionViewDataSource
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
return self.loopImages.count;
}
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
HWCarouselCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"CarouselCell" forIndexPath:indexPath];
id imageObj = self.loopImages[indexPath.item];
if ([imageObj isKindOfClass:[UIImage class]]) {
cell.imageView.image = imageObj;
} else if ([imageObj isKindOfClass:[NSString class]]) {
cell.imageView.image = [UIImage imageNamed:imageObj];
// 实际项目:[cell.imageView sd_setImageWithURL:[NSURL URLWithString:imageObj] placeholderImage:[UIImage imageNamed:@"placeholder"]];
}
return cell;
}
#pragma mark - UICollectionViewDelegateFlowLayout
- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
return self.bounds.size;
}
#pragma mark - UICollectionViewDelegate
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
NSInteger realIndex = indexPath.item - 1;
if (realIndex < 0) realIndex = self.realCount - 1;
if (realIndex >= self.realCount) realIndex = 0;
if ([self.delegate respondsToSelector:@selector(carouselView:didSelectItemAtIndex:)]) {
[self.delegate carouselView:self didSelectItemAtIndex:realIndex];
}
}
#pragma mark - UIScrollViewDelegate(UICollectionView 继承自 UIScrollView)
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
CGFloat width = self.bounds.size.width;
NSInteger loopIndex = (scrollView.contentOffset.x + width * 0.5) / width;
NSInteger realIndex = loopIndex - 1;
if (realIndex < 0) realIndex = self.realCount - 1;
if (realIndex >= self.realCount) realIndex = 0;
self.pageControl.currentPage = realIndex;
}
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
[self checkLoop];
}
- (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView {
[self checkLoop];
}
- (void)checkLoop {
CGFloat width = self.bounds.size.width;
NSInteger loopIndex = self.collectionView.contentOffset.x / width;
if (loopIndex == 0) {
[self.collectionView scrollToItemAtIndexPath:[NSIndexPath indexPathForItem:self.realCount inSection:0] atScrollPosition:UICollectionViewScrollPositionLeft animated:NO];
} else if (loopIndex == self.loopImages.count - 1) {
[self.collectionView scrollToItemAtIndexPath:[NSIndexPath indexPathForItem:1 inSection:0] atScrollPosition:UICollectionViewScrollPositionLeft animated:NO];
}
}
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
[self stopAutoScroll];
}
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate {
[self startAutoScroll];
}
#pragma mark - 自动滚动
- (void)startAutoScroll {
if (self.autoScrollInterval <= 0 || self.timer) return;
self.timer = [NSTimer scheduledTimerWithTimeInterval:self.autoScrollInterval target:self selector:@selector(autoNext) userInfo:nil repeats:YES];
[[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
}
- (void)stopAutoScroll {
[self.timer invalidate];
self.timer = nil;
}
- (void)autoNext {
CGFloat width = self.bounds.size.width;
CGFloat nextOffset = self.collectionView.contentOffset.x + width;
[self.collectionView setContentOffset:CGPointMake(nextOffset, 0) animated:YES];
}
- (void)dealloc {
[self stopAutoScroll];
}
@end
五、常见问题与优化
1. NSTimer 循环引用
上面的代码中,NSTimer 会强引用 target(self),如果 self 也强引用 timer,就形成循环引用,导致 dealloc 不调用,timer 永远不释放。
解决方案:用 YYWeakProxy 或系统的 NSTimer + block 方式(iOS 10+):
// iOS 10+ 推荐:block 方式,用 weakSelf 避免循环引用
__weak typeof(self) weakSelf = self;
self.timer = [NSTimer scheduledTimerWithTimeInterval:3.0 repeats:YES block:^(NSTimer * _Nonnull timer) {
[weakSelf autoNext];
}];
2. 拖拽时 Timer 不触发的问题
如果 Timer 只加入默认 RunLoop Mode,用户拖拽 UIScrollView 时 RunLoop 会切换到 UITrackingRunLoopMode,Timer 就不触发了。解决方法是把 Timer 加入 NSRunLoopCommonModes:
[[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
但我们的设计是拖拽时主动暂停 Timer,所以这个问题在拖拽时不影响。不过如果需要拖拽时 Timer 继续运行(不推荐),就需要加入 CommonModes。
3. 网络图片加载
实际项目中图片通常来自网络,推荐用 SDWebImage 加载,自带缓存和占位图:
#import <UIImageView+WebCache.h>
[imageView sd_setImageWithURL:[NSURL URLWithString:urlString]
placeholderImage:[UIImage imageNamed:@"placeholder"]
options:SDWebImageRetryFailed | SDWebImageLowPriority];
4. 图片拉伸变形
用 UIViewContentModeScaleAspectFill + clipsToBounds = YES,保持比例裁剪填充,不会变形。
5. 轮播器尺寸变化(屏幕旋转)
在 layoutSubviews 中重新布局图片和 contentSize,并保持当前页码不变。上面的代码已经在 layoutSubviews 中调用了 layoutImages。
6. 只有一张图片时
只有一张图片时不需要无限循环和自动滚动,可以在 setImages: 中判断:
if (images.count <= 1) {
self.scrollView.scrollEnabled = NO;
self.pageControl.hidden = YES;
[self stopAutoScroll];
}
六、第三方库推荐
如果不想自己造轮子,以下是常用的优秀第三方轮播库:
| 库名 | 语言 | 特点 |
|---|---|---|
| SDCycleScrollView | OC | 最流行的 OC 轮播库,功能全面,支持网络图片、文字标题、无限循环 |
| FSPagerView | Swift | Swift 实现,样式丰富,支持横向/纵向、卡片效果 |
| JXCarouselView | Swift | 轻量,支持 Cell 复用,自定义 Cell |
七、Swift 版本对照(UIScrollView 方案核心代码)
import UIKit
class ImageCarouselView: UIView, UIScrollViewDelegate {
var images: [String] = [] {
didSet { setupImages() }
}
var autoScrollInterval: TimeInterval = 3.0
weak var delegate: ImageCarouselViewDelegate?
private var scrollView: UIScrollView!
private var pageControl: UIPageControl!
private var timer: Timer?
private var loopImages: [String] = []
private var realCount: Int { images.count }
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupUI()
}
private func setupUI() {
scrollView = UIScrollView(frame: bounds)
scrollView.delegate = self
scrollView.isPagingEnabled = true
scrollView.showsHorizontalScrollIndicator = false
scrollView.bounces = false
addSubview(scrollView)
pageControl = UIPageControl()
pageControl.pageIndicatorTintColor = UIColor.white.withAlphaComponent(0.5)
pageControl.currentPageIndicatorTintColor = .white
addSubview(pageControl)
}
override func layoutSubviews() {
super.layoutSubviews()
scrollView.frame = bounds
pageControl.frame = CGRect(x: 0, y: bounds.height - 24, width: bounds.width, height: 20)
layoutImages()
}
private func setupImages() {
guard !images.isEmpty else { return }
loopImages = [images.last!] + images + [images.first!]
pageControl.numberOfPages = realCount
layoutImages()
scrollView.setContentOffset(CGPoint(x: bounds.width, y: 0), animated: false)
startAutoScroll()
}
private func layoutImages() {
scrollView.subviews.forEach { $0.removeFromSuperview() }
let w = bounds.width, h = bounds.height
for (i, name) in loopImages.enumerated() {
let iv = UIImageView(frame: CGRect(x: CGFloat(i) * w, y: 0, width: w, height: h))
iv.contentMode = .scaleAspectFill
iv.clipsToBounds = true
iv.image = UIImage(named: name)
iv.tag = i
iv.isUserInteractionEnabled = true
iv.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(imageTapped(_:))))
scrollView.addSubview(iv)
}
scrollView.contentSize = CGSize(width: CGFloat(loopImages.count) * w, height: h)
}
@objc private func imageTapped(_ tap: UITapGestureRecognizer) {
guard let index = tap.view?.tag else { return }
var realIndex = index - 1
if realIndex < 0 { realIndex = realCount - 1 }
if realIndex >= realCount { realIndex = 0 }
delegate?.carouselView(self, didSelectItemAt: realIndex)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let w = bounds.width
let loopIndex = Int((scrollView.contentOffset.x + w * 0.5) / w)
var realIndex = loopIndex - 1
if realIndex < 0 { realIndex = realCount - 1 }
if realIndex >= realCount { realIndex = 0 }
pageControl.currentPage = realIndex
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { checkLoop() }
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { checkLoop() }
private func checkLoop() {
let w = bounds.width
let loopIndex = Int(scrollView.contentOffset.x / w)
if loopIndex == 0 {
scrollView.setContentOffset(CGPoint(x: CGFloat(realCount) * w, y: 0), animated: false)
} else if loopIndex == loopImages.count - 1 {
scrollView.setContentOffset(CGPoint(x: w, y: 0), animated: false)
}
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { stopAutoScroll() }
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { startAutoScroll() }
private func startAutoScroll() {
guard autoScrollInterval > 0, timer == nil else { return }
timer = Timer.scheduledTimer(withTimeInterval: autoScrollInterval, repeats: true) { [weak self] _ in
self?.autoNext()
}
RunLoop.main.add(timer!, forMode: .common)
}
private func stopAutoScroll() {
timer?.invalidate()
timer = nil
}
private func autoNext() {
let nextOffset = scrollView.contentOffset.x + bounds.width
scrollView.setContentOffset(CGPoint(x: nextOffset, y: 0), animated: true)
}
deinit { stopAutoScroll() }
}
protocol ImageCarouselViewDelegate: AnyObject {
func carouselView(_ carouselView: ImageCarouselView, didSelectItemAt index: Int)
}
八、总结
- 核心原理:UIScrollView 横向排列图片,
pagingEnabled = YES实现整页翻动,contentOffset.x控制当前页。 - 无限循环:数据复制三份([最后一张] + 原始 + [第一张]),初始显示中间份第一张,滑到边界副本时用
setContentOffset:animated:NO无动画跳回中间对应位置,用户感知为无缝循环。 - 自动滚动:NSTimer 每隔几秒增加一个屏幕宽度的
contentOffset,用animated:YES平滑滚动。iOS 10+ 推荐用 block 形式的 Timer 避免循环引用。 - 拖拽暂停:
scrollViewWillBeginDragging中停止 Timer,scrollViewDidEndDragging中恢复 Timer。 - 页码指示器:
UIPageControl,在scrollViewDidScroll中根据当前偏移量计算真实页码并更新。 - 点击事件:给每个 UIImageView 添加
UITapGestureRecognizer,将循环索引转换为真实索引后通过代理回调。 - 两种方案:UIScrollView + UIImageView 原理简单易懂,适合图片少和学习;UICollectionView 自带 cell 复用,内存友好,适合生产环境和大量图片。
- 优化要点:SDWebImage 加载网络图片、
ScaleAspectFill + clipsToBounds防变形、单张图片时关闭滚动和自动滚动、Timer 加入 CommonModes 或用 block 避免循环引用。

浙公网安备 33010602011771号