简介这是一份面向iOS开发者的图片轮播组件源码基于UIScrollView实现无限循环滚动效果适合需要在应用中快速集成Banner轮播的初中级开发者参考使用。源码支持加载网络图片、图片点击响应、pageControl显示开关、自动滚动开关以及滚动时间间隔设置覆盖了轮播控件常见的可配置项。资源包共106个文件以50个.h头文件与22个.m实现文件为核心另含plist配置、xcconfig构建配置、xcscheme方案、xib界面文件及Podfile依赖描述等整体约659KB结构完整可直接编译运行。内容中集成了SDWebImage相关图片缓存与下载模块便于理解网络图片异步加载与缓存复用的实现思路。目前已有416人学习下载读者可从中掌握UIScrollView循环滚动的偏移计算、定时器控制与手势冲突处理等关键细节并据此快速改造出符合自身项目需求的轮播控件。1. 图片轮播UIScrollView循环滚动从“能滑”到“无限滑”的那道坎很多iOS开发者第一次做图片轮播都会经历同一个心理落差用UIScrollView加几个UIImageView分页控件一挂感觉十分钟就能收工。真上手才发现首尾衔接处会“跳帧”快速滑动时页码错乱内存随着图片数量线性上涨横竖屏切换后布局直接崩掉。图片轮播UIScrollView循环滚动这件事难点从来不在“滚动”而在“循环”两个字——如何让用户感觉永远滑不到头同时代码里又不真的创建无限个视图。这套方案适合两类人一类是刚接触UIScrollView、想把手写轮播做扎实的初中级开发者另一类是已经用了第三方库、但遇到性能或定制瓶颈、想自己掌控实现细节的熟手。核心思路是用“三视图复用”或“首尾冗余”制造视觉上的无限再配合定时器、手势冲突处理和内存回收把体验做稳。下面按选型、实现、参数、避坑、进阶的顺序把这条链路拆开讲。2. 循环滚动的三种实现路线为什么我最终选了首尾冗余加三视图复用2.1 三种常见循环方案的能力边界在动手写代码前先把可选路线摆清楚。循环轮播的本质是“让索引在边界处无缝跳转”围绕这个目标业内常见做法有三类。第一类是首尾冗余法在真实图片数组的头尾各插入一张镜像图比如图片是[A, B, C]实际数据源变成[C, A, B, C, A]。滑到最左的C时无动画跳到真实的C位置滑到最右的A时无动画跳回真实的A位置。优点是实现直观、页码计算简单缺点是数据源需要额外处理且首尾图片会被加载两次。第二类是三视图复用屏幕上永远只保留三个UIImageView根据当前偏移量动态更新这三个视图的image内容。滑到边界时把偏移量重置到中间视图同时刷新三张图。优点是内存恒定适合图片数量大的场景缺点是索引映射逻辑容易写错调试时像在黑匣子里摸路。第三类是UICollectionView横向布局利用cell复用机制配合contentOffset重置实现循环。优点是复用成熟、支持复杂cell缺点是对于纯图片轮播来说偏重且循环时的偏移重置同样需要小心处理。我一般会选首尾冗余加三视图复用的混合方案用三视图控制内存用首尾冗余简化边界判断。这样既不会因为图片多而爆内存也不用在索引映射上反复翻车。2.2 三视图复用的索引映射逻辑三视图复用的核心是维护一个currentIndex并让三个视图分别显示currentIndex - 1、currentIndex、currentIndex 1对应的图片。当用户滑动到相邻页时更新currentIndex然后重新计算三张图的内容并把contentOffset重置到中间视图的位置。// 三视图复用的核心索引映射 private var currentIndex 0 private let imageViews [UIImageView(), UIImageView(), UIImageView()] private func updateImageViews() { let count images.count // 三个视图分别对应前一张、当前、后一张 let prevIndex (currentIndex - 1 count) % count let nextIndex (currentIndex 1) % count imageViews[0].image images[prevIndex] imageViews[1].image images[currentIndex] imageViews[2].image images[nextIndex] // 将偏移量重置到中间视图保证左右都有可滑动空间 scrollView.setContentOffset(CGPoint(x: scrollView.bounds.width, y: 0), animated: false) }这段代码里% count保证了索引在0到count-1之间循环不会越界。setContentOffset把可视区域拉回中间视图这样用户无论往左还是往右滑都有相邻视图可以进入。参数上scrollView.bounds.width必须和每个视图的宽度一致否则偏移量会对不齐出现半张图卡在边缘的情况。2.3 首尾冗余法在页码计算上的优势如果图片数量很少比如只有3到5张首尾冗余法在页码计算上更省心。因为数据源本身就是“镜像后”的数组页码可以直接用contentOffset.x / width取整得到再对真实图片数量取模。// 首尾冗余法的页码计算 func scrollViewDidScroll(_ scrollView: UIScrollView) { let page Int(round(scrollView.contentOffset.x / scrollView.bounds.width)) // 镜像数组长度为 count 2真实页码需要减去头部冗余并取模 let realPage (page - 1 images.count) % images.count pageControl.currentPage realPage }这里page - 1是因为头部多插入了一张镜像图 images.count是为了处理负数取模的情况。Swift的%对负数返回负值所以要先加再取模。这个细节不写对滑到第一张时页码会变成-1分页控件直接错位。两种方案没有绝对优劣图片少用首尾冗余图片多用三视图复用是相对稳妥的取舍。3. 用UIScrollView搭出可复现的循环轮播从布局到定时器3.1 基础布局与分页配置先把UIScrollView的骨架搭起来。关键参数有三个isPagingEnabled开启分页、contentSize设置为三倍宽度、showsHorizontalScrollIndicator关掉横线。let scrollView UIScrollView() scrollView.isPagingEnabled true scrollView.showsHorizontalScrollIndicator false scrollView.delegate self scrollView.bounces false // 关闭回弹避免边界处露出空白 view.addSubview(scrollView) // 布局使用Auto Layout或frame均可这里用frame示意 scrollView.frame view.bounds scrollView.contentSize CGSize(width: view.bounds.width * 3, height: view.bounds.height) // 添加三个UIImageView分别放在0、1、2倍宽度处 for i in 0..3 { let imageView UIImageView() imageView.frame CGRect(x: CGFloat(i) * view.bounds.width, y: 0, width: view.bounds.width, height: view.bounds.height) imageView.contentMode .scaleAspectFill imageView.clipsToBounds true scrollView.addSubview(imageView) imageViews.append(imageView) }bounces false这一步很关键。如果开启回弹用户滑到边界时会把空白区域拉出来循环的“无缝感”立刻破功。contentSize设为三倍宽度是为了让三个视图都有合法的滚动区域。每个imageView的contentMode设为scaleAspectFill并开启clipsToBounds避免图片变形或溢出。3.2 滚动结束时的偏移重置循环轮播最容易出问题的地方是滚动结束后的偏移重置。必须在scrollViewDidEndDecelerating和scrollViewDidEndScrollingAnimation两个回调里都处理否则定时器自动滚动时不会触发重置。func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { handlePageChange(scrollView) } func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { handlePageChange(scrollView) } private func handlePageChange(_ scrollView: UIScrollView) { let width scrollView.bounds.width let page Int(scrollView.contentOffset.x / width) if page 0 { currentIndex (currentIndex - 1 images.count) % images.count } else if page 2 { currentIndex (currentIndex 1) % images.count } updateImageViews() pageControl.currentPage currentIndex }page 0表示用户滑到了左边那张当前索引减一page 2表示滑到了右边索引加一。page 1说明用户滑了一下又弹回中间索引不变。处理完后调用updateImageViews刷新三张图并重置偏移。这里如果只写scrollViewDidEndDecelerating定时器触发的setContentOffset(animated: true)结束时不会走这个回调页码就会停住不动这是很常见的翻车点。3.3 定时器自动滚动的启停时机自动滚动用Timer实现但启停时机要卡准。用户手动拖动时暂停松手后恢复页面消失时销毁避免内存泄漏。private var timer: Timer? func startAutoScroll() { timer?.invalidate() timer Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in self?.scrollToNext() } } func stopAutoScroll() { timer?.invalidate() timer nil } private func scrollToNext() { let width scrollView.bounds.width scrollView.setContentOffset(CGPoint(x: width * 2, y: 0), animated: true) } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { stopAutoScroll() } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { startAutoScroll() } }withTimeInterval: 3.0是轮播间隔按产品需求调整一般2到5秒比较舒适。[weak self]防止Timer持有控制器导致无法释放。scrollToNext把偏移量设到最右侧触发scrollViewDidEndScrollingAnimation后再重置回中间形成循环。用户开始拖动时停掉定时器结束拖动且没有惯性时重新启动。如果decelerate为true说明还有惯性滚动要等scrollViewDidEndDecelerating里再启动否则定时器和惯性会打架。4. 循环轮播的避坑清单页码错乱、内存上涨、手势冲突4.1 页码在快速滑动时跳变现象用户快速连续滑动时分页控件的页码会突然跳到不相邻的数字甚至出现负数。原因scrollViewDidScroll里实时计算页码但快速滑动时contentOffset会经过中间态round取整可能落在错误的页上。另外如果在scrollViewDidScroll里做偏移重置会打断滚动动画导致偏移量计算混乱。解决页码只在scrollViewDidEndDecelerating和scrollViewDidEndScrollingAnimation里更新不要在scrollViewDidScroll里改currentIndex。如果确实需要实时反馈可以在scrollViewDidScroll里只更新分页控件的显示但不触发视图刷新和偏移重置。4.2 图片数量多时内存持续上涨现象轮播图片有几十张滑动几圈后内存占用明显上升甚至收到内存警告。原因每次updateImageViews都直接给UIImageView.image赋新图如果图片没有做缓存控制旧图不会立即释放。更严重的是如果用了首尾冗余法且数据源里存的是UIImage对象所有图片会常驻内存。解决三视图复用方案下只保留三个UIImageView实例图片通过UIImage(named:)或UIImage(contentsOfFile:)按需加载。如果图片来自网络用带缓存的图片加载库并设置合理的缓存上限。对于本地大图考虑用autoreleasepool包裹赋值逻辑让旧图及时释放。4.3 定时器与手动滑动的手势冲突现象用户正在手动滑动时定时器突然触发自动滚动画面猛地跳一下。原因定时器没有在scrollViewWillBeginDragging里停掉或者停掉后没有在正确的回调里恢复。解决在scrollViewWillBeginDragging里调用stopAutoScroll在scrollViewDidEndDragging里判断decelerate如果为false立即startAutoScroll如果为true则在scrollViewDidEndDecelerating里启动。同时scrollToNext里用animated: true让自动滚动也有平滑动画避免生硬跳转。4.4 横竖屏切换后布局错位现象旋转屏幕后轮播图宽度变了但contentSize和contentOffset没有同步更新图片显示不全或偏移错乱。原因viewDidLayoutSubviews里没有重新计算contentSize和三个imageView的frame。解决在viewDidLayoutSubviews里根据新的bounds更新scrollView.frame、contentSize和三个imageView的frame然后调用updateImageViews把偏移量重置到中间。注意不要在viewDidLayoutSubviews里调用setContentOffset(animated: true)否则会触发额外的滚动回调用animated: false即可。4.5 分页控件与循环索引不同步现象分页控件显示的是第1页但实际显示的图片是最后一张。原因currentIndex更新了但pageControl.currentPage没有同步更新或者更新时机不对。解决把pageControl.currentPage currentIndex放在updateImageViews之后统一执行确保索引和视图刷新是原子的。如果用了首尾冗余法页码计算要单独处理镜像偏移不能直接用currentIndex。5. 进阶技巧用CADisplayLink替代Timer做帧级平滑滚动Timer的精度受RunLoop模式影响在用户拖动时如果RunLoop切换到UITrackingRunLoopMode定时器可能被暂停或延迟。对于要求更高的轮播场景可以用CADisplayLink替代Timer把自动滚动绑定到屏幕刷新率上。private var displayLink: CADisplayLink? private var accumulatedTime: CFTimeInterval 0 private let scrollInterval: CFTimeInterval 3.0 func startDisplayLink() { displayLink CADisplayLink(target: self, selector: #selector(handleDisplayLink)) displayLink?.add(to: .main, forMode: .common) } objc private func handleDisplayLink(_ link: CADisplayLink) { accumulatedTime link.duration if accumulatedTime scrollInterval { accumulatedTime 0 scrollToNext() } } func stopDisplayLink() { displayLink?.invalidate() displayLink nil }CADisplayLink的duration是每帧的时间间隔累加到scrollInterval后触发滚动。add(to: .main, forMode: .common)让它在所有RunLoop模式下都运行包括拖动时的UITrackingRunLoopMode这样即使用户在拖动计时也不会断。accumulatedTime在每次触发后归零避免时间累积误差。验证循环是否真的无缝有个简单方法在updateImageViews里打印currentIndex和scrollView.contentOffset.x然后快速滑动几十圈观察索引是否始终在0到count-1之间循环偏移量是否每次都回到中间值。如果发现某次偏移量没有重置说明回调漏了。我自己的习惯是任何循环轮播上线前都会用 Instruments 的 Allocations 模板跑五分钟自动滚动确认内存曲线是平的而不是阶梯上升。这个动作帮我拦下过好几次图片缓存没设上限的问题。希望帮到你。本文还有配套的精品资源点击获取