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 避免循环引用。

posted @ 2015-10-20 12:39  Mr.陳  阅读(1321)  评论(0)    收藏  举报