简介:这是一份面向iOS开发者的图片轮播组件源码,基于UIScrollView实现无限循环滚动效果,适合需要快速集成轮播图的中初级开发者参考学习。源码支持加载网络图片、图片点击响应、pageControl显示开关、自动滚动开关以及滚动时间间隔设置,覆盖了常见App首页Banner的典型需求。资源包共106个文件,包含50个.h头文件与22个.m实现文件,另有plist配置、xcconfig构建配置、xcscheme方案、xib界面文件及Podfile依赖描述等,整体约659KB,结构完整可直接运行调试。其中集成了SDWebImage相关分类与缓存管理模块,便于理解网络图片加载与缓存机制。目前已有416人学习下载,读者可从中掌握UIScrollView循环滚动的实现思路、定时器控制与图片点击事件处理等实用技巧,并可直接将组件移植到自己的项目中。
1. 图片轮播UIScrollView循环滚动:为什么你的首尾总是闪一下
图片轮播UIScrollView循环滚动,几乎是每个 iOS 开发者都会撞上的第一道 UI 坎。表面需求很简单:几张图横向滑动,滑到最后一张能无缝接回第一张,最好再带个自动播放和页码指示器。但真动手写,十有八九会在首尾衔接处翻车——要么滑到头卡住不动,要么从最后一张跳回第一张时画面闪一下,要么自动播放几轮之后页码和图片对不上。这些问题的根子不在 UIScrollView 本身,而在于循环滚动的实现模型选错了。
这篇笔记面向已经会写基础 UIScrollView、但被循环逻辑绕晕的 iOS 开发者。我会把三种主流循环方案拆开讲清楚:双倍/三倍内容法、首尾克隆法、以及基于 contentOffset 重置的伪无限滚动。每种方案给出可复现的代码骨架、关键参数怎么设、什么场景该选哪个,最后集中讲几个我踩过的坑。读完你应该能直接照着搭出一个不闪、不卡、页码正确的轮播组件。
2. 三种循环滚动方案:先选对模型再写代码
循环滚动的本质是「让用户感觉可以无限滑,但实际内容只有 N 张」。所有方案都在解决同一个矛盾:UIScrollView 的内容宽度是有限的,而用户期望的滑动是无限的。区别只在于「在哪一刻偷偷把位置挪回去」。
2.1 双倍内容法:把图片数组复制一份接在后面
最常见的做法是把原始图片数组复制一份,拼成[A, B, C, A, B, C],contentSize 设为 6 张图的宽度。用户从 C 继续右滑到第二组的 A 时,视觉上就是无缝的。当滚动停止且当前页落在第二组时,把 contentOffset 瞬间重置回第一组的对应位置。
// 双倍内容法核心逻辑 let originalImages = ["img0", "img1", "img2"] // 拼接成双倍数组 let loopImages = originalImages + originalImages func setupScrollView() { let screenW = UIScreen.main.bounds.width scrollView.contentSize = CGSize(width: screenW * CGFloat(loopImages.count), height: 0) scrollView.isPagingEnabled = true scrollView.delegate = self // 初始定位到第一组的第一张 scrollView.contentOffset = CGPoint(x: 0, y: 0) } // 滚动停止后判断是否需要重置 func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { let screenW = UIScreen.main.bounds.width let page = Int(scrollView.contentOffset.x / screenW) // 落在第二组(索引 >= 原数组数量)时重置回第一组 if page >= originalImages.count { let targetPage = page - originalImages.count scrollView.setContentOffset(CGPoint(x: screenW * CGFloat(targetPage), y: 0), animated: false) } }这里的关键参数是isPagingEnabled,它让每次滑动正好停在一屏宽度。重置动作必须用animated: false,否则用户会看到一段回滚动画,那就穿帮了。scrollViewDidEndDecelerating只在惯性滑动结束时触发,如果是用户慢慢拖拽停下的,还得在scrollViewDidEndDragging(_:willDecelerate:)里补一次判断,否则重置会漏掉。
双倍内容法的优点是逻辑直观、页码计算简单。缺点是当图片数量多、单图体积大时,内存里同时存在两份视图,对内存敏感的场景不友好。另外它只能向右循环,想支持向左循环得用三倍内容法,把当前组放在中间。
2.2 首尾克隆法:只在两端各加一张克隆图
如果不想复制整个数组,可以只在首尾各插入一张克隆图,变成[C克隆, A, B, C, A克隆]。用户滑到最左的 C 克隆时,无动画跳到真正的 C;滑到最右的 A 克隆时,跳到真正的 A。这样内容只多了两张,内存占用小。
// 首尾克隆法:构造 [尾克隆, 原数组..., 首克隆] let originalImages = ["img0", "img1", "img2"] var loopImages: [String] = [] loopImages.append(originalImages.last!) // 尾部克隆放最前 loopImages.append(contentsOf: originalImages) loopImages.append(originalImages.first!) // 首部克隆放最后 func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { let screenW = UIScreen.main.bounds.width let page = Int(scrollView.contentOffset.x / screenW) let realCount = originalImages.count if page == 0 { // 滑到了最左的尾克隆,跳到真正的最后一张 scrollView.setContentOffset(CGPoint(x: screenW * CGFloat(realCount), y: 0), animated: false) } else if page == realCount + 1 { // 滑到了最右的首克隆,跳到真正的第一张 scrollView.setContentOffset(CGPoint(x: screenW, y: 0), animated: false) } }注意这里的页码映射和双倍法不同:真实图片的索引是page - 1,因为第 0 位是克隆。页码指示器更新时要做这个偏移,否则用户看到的页码会整体错一位。这个偏移量是最容易写错的地方,我建议把它抽成一个单独的函数,别散落在各处。
首尾克隆法适合图片数量中等(5 到 20 张)、需要双向循环的场景。它的边界判断比双倍法多一个分支,但内存更省。缺点是当图片只有 1 张或 2 张时,克隆逻辑会退化,需要单独处理——1 张图根本不需要循环,2 张图用克隆法反而会出现连续两张相同图的情况。
2.3 contentOffset 重置法:不复制内容,靠时机偷换位置
第三种思路是不复制任何内容,保持 N 张图不变,在用户滑到边界的那一刻,用无动画的方式把 contentOffset 从一端挪到另一端。比如滑到最后一张继续右滑,检测到偏移量超过阈值,立刻把偏移量设回第一张的位置。
// contentOffset 重置法:监听滚动过程而非结束 func scrollViewDidScroll(_ scrollView: UIScrollView) { let screenW = UIScreen.main.bounds.width let totalW = screenW * CGFloat(originalImages.count) let offsetX = scrollView.contentOffset.x // 越过右边界,跳回开头 if offsetX >= totalW { scrollView.setContentOffset(CGPoint(x: offsetX - totalW, y: 0), animated: false) } // 越过左边界,跳到末尾 if offsetX < 0 { scrollView.setContentOffset(CGPoint(x: offsetX + totalW, y: 0), animated: false) } }这个方案在scrollViewDidScroll里做判断,触发频率很高,所以判断逻辑必须极简,不能在里面做布局或网络请求。它的优点是内容视图只有 N 份,内存最省,适合图片多且大的场景。缺点是重置发生在滚动过程中,如果阈值设得不好,用户会看到画面「跳」一下。阈值一般取半屏宽度,也就是用户滑过一半时才触发重置,这样视觉上最自然。
三种方案的对比可以看这张表:
| 方案 | 内存占用 | 支持方向 | 页码计算 | 适用图片数 |
|---|---|---|---|---|
| 双倍内容法 | 高(2N) | 单向 | 简单取模 | 3~10 张 |
| 首尾克隆法 | 低(N+2) | 双向 | 需偏移 1 | 5~20 张 |
| contentOffset 重置法 | 最低(N) | 双向 | 需处理边界 | 10 张以上 |
选型建议:图片少、想快速出效果,用双倍内容法;图片中等、要双向滑,用首尾克隆法;图片多、内存吃紧,用 contentOffset 重置法。别一上来就追求最省内存的方案,先把逻辑跑通更重要。
3. 自动播放与页码联动:定时器和偏移量的配合
循环滚动做完,下一步就是自动播放。自动播放的坑比循环本身还多,因为它引入了时间维度,定时器和用户手动滑动会互相干扰。
3.1 用 Timer 驱动自动翻页的正确姿势
自动播放的本质是每隔几秒把 contentOffset 往右推一屏。用Timer.scheduledTimer是最直接的做法,但有几个细节必须处理。
var autoTimer: Timer? func startAutoScroll() { // 间隔 3 秒,重复执行 autoTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in guard let self = self else { return } self.scrollToNextPage() } } func scrollToNextPage() { let screenW = UIScreen.main.bounds.width let nextOffsetX = scrollView.contentOffset.x + screenW // 用动画滚动,动画时长略小于定时器间隔 UIView.animate(withDuration: 0.3) { self.scrollView.contentOffset = CGPoint(x: nextOffsetX, y: 0) } } // 用户开始拖拽时暂停定时器 func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { autoTimer?.invalidate() autoTimer = nil } // 用户停止拖拽后重新启动 func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { startAutoScroll() } } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { startAutoScroll() }这里有几个参数要调:定时器间隔一般取 2 到 5 秒,太短用户看不清,太长显得呆滞;动画时长取 0.3 到 0.5 秒,必须小于定时器间隔,否则动画还没结束下一次翻页就来了,会叠加出乱子。[weak self]是必须的,否则定时器持有 self 造成循环引用,页面销毁后定时器还在跑,这是内存泄漏的经典来源。
用户手动拖拽时必须暂停定时器,否则用户正在看某张图,定时器突然把它翻走,体验很差。暂停和恢复的时机要覆盖三种情况:开始拖拽时暂停、拖拽结束无惯性时恢复、惯性滑动结束时恢复。漏掉任何一种,自动播放就会在某个操作后彻底停摆。
3.2 页码指示器的同步更新
页码指示器(UIPageControl)要和当前页保持同步。同步的时机是在scrollViewDidScroll里根据 contentOffset 实时计算,而不是在滚动结束时才算,否则页码会滞后。
func scrollViewDidScroll(_ scrollView: UIScrollView) { let screenW = UIScreen.main.bounds.width let page = Int(round(scrollView.contentOffset.x / screenW)) // 根据方案不同,这里要做索引映射 let realPage = mapToRealPage(page) pageControl.currentPage = realPage } // 双倍内容法的映射 func mapToRealPage(_ page: Int) -> Int { return page % originalImages.count }用round而不是直接取整,是因为滚动过程中偏移量是连续变化的,取整会让页码在两张图中间就跳变,用四舍五入更符合视觉感受。mapToRealPage这个映射函数是三种方案差异最大的地方:双倍法用取模,克隆法要减 1 再处理边界,重置法要处理负数和溢出。把这个函数单独抽出来,换方案时只改这一处。
页码指示器的圆点数量设为真实图片数量,不是 loopImages 的数量。这个错误很隐蔽,因为圆点多了几个用户不一定立刻发现,但一旦发现就会觉得组件很粗糙。
4. 避坑指南:循环轮播最常见的五个翻车现场
这一章集中讲我在实际项目里踩过的坑,每条按「现象 → 原因 → 解决」写。这些坑不分方案,几乎每个做轮播的人都会遇到至少两三个。
4.1 首尾衔接处闪一下白屏
现象:从最后一张滑回第一张时,画面短暂闪白或闪黑,然后才显示第一张图。
原因:重置 contentOffset 时用了animated: true,或者重置发生在图片还没渲染完成的时候。另一个常见原因是图片是异步加载的,克隆位置的图片还没加载出来就被滑到了。
解决:重置必须用animated: false,这是铁律。如果是异步图片,在重置前确保目标位置的图片已经缓存或预加载。预加载的做法是在设置 contentSize 之后,提前把首尾两张图的 image 赋给对应的 imageView,别等滑到了才加载。
4.2 自动播放几轮后页码和图片错位
现象:自动播放跑了三四轮之后,页码指示器显示的数字和当前图片对不上,越跑越偏。
原因:页码映射函数在边界情况下算错了。比如双倍内容法里,当 page 正好等于原数组数量时,取模结果是 0,但此时显示的其实是第二组的第一张,视觉上确实是第一张,页码显示 0 是对的。但如果重置逻辑没执行,page 会继续增大,取模虽然还对,但 contentOffset 已经越界了。
解决:在scrollViewDidEndDecelerating里强制做一次重置,保证 contentOffset 始终落在第一组范围内。页码映射函数要写单元测试,把 page 从 0 遍历到 2N,逐个验证映射结果。
4.3 快速连续滑动导致重置逻辑失效
现象:用户快速连续滑动,一次滑过好几张图,重置逻辑没触发,滑到最后卡住不动了。
原因:scrollViewDidEndDecelerating在快速滑动时可能只触发一次,但此时 page 已经越过了需要重置的范围,单次重置不够。或者重置的判断条件用的是==而不是>=,page 跳过了目标值就匹配不上。
解决:重置判断一律用>=和<=,不要用==。重置时用while循环或取模运算把偏移量拉回合法范围,而不是只减一次。比如page >= count时,用page % count算出目标页,而不是page - count。
4.4 定时器和手动滑动打架
现象:用户正在手动滑动,定时器突然触发自动翻页,画面猛地跳一下。
原因:定时器没有在用户开始拖拽时暂停,或者暂停了但恢复时机不对,导致定时器在用户操作期间仍然存活。
解决:在scrollViewWillBeginDragging里 invalidate 定时器并置 nil,在scrollViewDidEndDragging和scrollViewDidEndDecelerating里重新创建。注意重新创建前要先判断定时器是否为 nil,避免重复创建多个定时器。多个定时器同时跑,翻页速度会变成两倍甚至更快。
4.5 页面销毁后定时器还在跑导致崩溃
现象:轮播所在的页面被 pop 或 dismiss 之后,过几秒 App 崩溃,日志指向定时器回调里的野指针。
原因:定时器持有 self,页面销毁时定时器没被 invalidate,回调触发时访问了已释放的对象。
解决:在viewWillDisappear或deinit里 invalidate 定时器。用[weak self]捕获,并在闭包开头guard let self = self else { return }。这两个措施要同时做,只做其中一个都不够保险。我一般会在deinit里加一行autoTimer?.invalidate(),作为最后的后悔药。
5. 进阶技巧:用预加载和复用把轮播做到丝滑
基础功能跑通之后,决定轮播体验上限的是两件事:图片加载速度和视图复用。这两点做不好,循环逻辑再完美,用户还是会觉得卡。
5.1 图片预加载:让下一张永远准备好
轮播的图片如果是网络图,滑到才加载必然有等待。预加载的思路是提前把当前页前后各一张的图片下载并缓存。实现上可以在scrollViewDidScroll里计算当前页,然后触发相邻页的加载。
var imageCache: [Int: UIImage] = [:] func preloadAdjacentImages(currentPage: Int) { let realCount = originalImages.count // 预加载前后各一张 let pagesToLoad = [(currentPage + 1) % realCount, (currentPage - 1 + realCount) % realCount] for page in pagesToLoad { if imageCache[page] == nil { loadImage(at: page) { [weak self] image in self?.imageCache[page] = image } } } }取模运算保证了首尾相邻页也能正确计算,比如当前是第 0 页,前一页是(0 - 1 + count) % count,结果是最后一张。这个+ count不能省,否则负数取模在部分语言里结果不符合预期。缓存用字典按索引存,简单直接,图片数量不多时够用。如果图片量大,换成 NSCache 并设置数量上限,避免内存无限增长。
预加载的触发时机放在scrollViewDidScroll里,但要做节流,不能每次滚动回调都触发。可以记录上一次预加载的页码,只有页码变化时才执行。
5.2 视图复用:图片多了也不怕
如果轮播有几十张图,给每张图都创建一个 UIImageView 会占用大量内存。这时需要视图复用,只创建三个 imageView,滚动时动态更新它们的内容。
复用的核心是维护一个 imageView 池,根据当前 contentOffset 计算哪三个索引可见,把池里的 imageView 重新定位并赋值。这个逻辑比前几种循环方案复杂一个量级,一般用 UICollectionView 来做更省事——UICollectionView 自带复用机制,把 itemSize 设成一屏大小,配合循环的数据源就能实现。
用 UICollectionView 做循环轮播时,数据源返回loopImages.count个 item,在cellForItemAt里根据 indexPath 取模拿到真实图片。滚动结束后的重置逻辑和前面一样,只是操作对象从 UIScrollView 换成了 UICollectionView。好处是复用、布局、动画都是现成的,代码量反而更少。
5.3 用 CADisplayLink 替代 Timer 做平滑动画
Timer 的精度受 RunLoop 模式影响,在用户滚动时可能被延迟,导致自动播放的节奏不均匀。如果对平滑度要求高,可以用 CADisplayLink 驱动,它和屏幕刷新率同步,每帧回调一次。
var displayLink: CADisplayLink? var accumulatedTime: CFTimeInterval = 0 let pageInterval: CFTimeInterval = 3.0 func startDisplayLink() { displayLink = CADisplayLink(target: self, selector: #selector(handleTick)) displayLink?.add(to: .main, forMode: .common) } @objc func handleTick(_ link: CADisplayLink) { accumulatedTime += link.duration if accumulatedTime >= pageInterval { accumulatedTime = 0 scrollToNextPage() } }link.duration是每帧的时长,累加到pageInterval就触发翻页。forMode: .common很关键,它保证用户在滚动时 displayLink 仍然回调,不会被 RunLoop 的 tracking 模式阻塞。用 Timer 的话,默认模式在滚动时会暂停,这就是自动播放偶尔「卡一拍」的原因。
CADisplayLink 的代价是回调频率高(每秒 60 次),回调里的逻辑要极简,只做时间累加和判断,别放耗时操作。另外它同样需要在页面消失时 invalidate,否则会持续占用 CPU。
我自己的习惯是:图片少于 10 张、对内存不敏感,直接用双倍内容法加 Timer,半小时能写完;图片多或要求高,用 UICollectionView 加 CADisplayLink,多花点时间但后期省心。循环轮播这个组件,写一次能复用很久,前期把边界和预加载做扎实,后面接多少个页面都不慌。希望帮到你。
本文还有配套的精品资源,点击获取