iOS开发基础8-UIScrollView 深度解析:滚动、缩放与图片轮播
UIScrollView 深度解析:滚动、缩放与图片轮播
UIScrollView 是 iOS 中最核心的滚动控件,UITableView、UICollectionView、UITextView 等都继承自它。掌握其基本用法、代理回调、缩放机制及轮播实现,是构建复杂交互界面的基础。
一、基本用法
使用步骤
- 创建
UIScrollView并添加到父视图。 - 将需要展示的内容作为子视图添加到
UIScrollView。 - 设置
contentSize(滚动范围)——这是能否滚动的关键。
示例代码
Objective-C
@interface ViewController ()
@property (weak, nonatomic) IBOutlet UIScrollView *scrollView;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
// 添加子控件
UIButton *btn = [UIButton buttonWithType:UIButtonTypeContactAdd];
[self.scrollView addSubview:btn];
UISwitch *sw = [[UISwitch alloc] init];
CGRect frame = sw.frame;
frame.origin.y = 150;
sw.frame = frame;
[self.scrollView addSubview:sw];
UIButton *customBtn = [[UIButton alloc] init];
customBtn.frame = CGRectMake(0, 0, 100, 100);
customBtn.backgroundColor = [UIColor redColor];
[customBtn setTitle:@"我是按钮" forState:UIControlStateNormal];
[self.scrollView addSubview:customBtn];
}
// contentSize 应在视图布局完成后设置,确保 frame 尺寸准确
- (void)viewDidLayoutSubviews {
[super viewDidLayoutSubviews];
self.scrollView.contentSize = CGSizeMake(self.scrollView.bounds.size.width + 100,
self.scrollView.bounds.size.height + 100);
}
@end
Swift
class ViewController: UIViewController {
@IBOutlet weak var scrollView: UIScrollView!
override func viewDidLoad() {
super.viewDidLoad()
let btn = UIButton(type: .contactAdd)
scrollView.addSubview(btn)
let sw = UISwitch()
var frame = sw.frame
frame.origin.y = 150
sw.frame = frame
scrollView.addSubview(sw)
let customBtn = UIButton()
customBtn.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
customBtn.backgroundColor = .red
customBtn.setTitle("我是按钮", for: .normal)
scrollView.addSubview(customBtn)
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
scrollView.contentSize = CGSize(width: scrollView.bounds.width + 100,
height: scrollView.bounds.height + 100)
}
}
contentSize 设置时机:在
viewDidLoad中,视图的 frame 可能还未完成布局(Auto Layout 约束尚未生效),此时读取frame.size可能得到不准确的值。应在viewDidLayoutSubviews中设置contentSize,或使用约束自动撑开内容。
底层逻辑
- 滚动原理:
UIScrollView通过调整bounds.origin(即contentOffset)来实现滚动效果。子视图的 frame 是相对于contentView的,当bounds.origin变化时,可见区域随之移动。 - contentSize 是前提:
contentSize决定了可滚动的最大范围。如果contentSize小于等于视图自身尺寸,则无法滚动(水平或垂直方向)。 - UIScrollView 不会自动根据子视图计算 contentSize,必须手动设置或通过 Auto Layout 约束让子视图撑开内容。
二、基本属性
| 属性 | 类型 | 说明 |
|---|---|---|
contentSize |
CGSize |
滚动范围(可滚动的内容尺寸) |
contentOffset |
CGPoint |
当前滚动偏移量,即 bounds.origin |
contentInset |
UIEdgeInsets |
内容内边距,在内容四周增加额外滚动区域 |
scrollEnabled |
BOOL |
是否允许滚动,默认 YES |
userInteractionEnabled |
BOOL |
是否允许用户交互(含滚动和点击),默认 YES |
pagingEnabled |
BOOL |
是否按页滚动,默认 NO |
bounces |
BOOL |
是否回弹效果,默认 YES |
alwaysBounceVertical |
BOOL |
垂直方向是否始终允许回弹(即使 contentSize 不够大) |
alwaysBounceHorizontal |
BOOL |
水平方向是否始终允许回弹 |
showsHorizontalScrollIndicator |
BOOL |
是否显示水平滚动条,默认 YES |
showsVerticalScrollIndicator |
BOOL |
是否显示垂直滚动条,默认 YES |
indicatorStyle |
UIScrollViewIndicatorStyle |
滚动条样式(默认/黑色/白色) |
decelerationRate |
UIScrollViewDecelerationRate |
手指松开后的减速速率 |
scrollIndicatorInsets |
UIEdgeInsets |
滚动条的内边距 |
示例
Objective-C
self.scrollView.scrollEnabled = YES;
self.scrollView.pagingEnabled = YES; // 分页滚动
self.scrollView.bounces = YES; // 回弹效果
self.scrollView.alwaysBounceVertical = YES; // 始终垂直回弹
self.scrollView.showsHorizontalScrollIndicator = NO;
self.scrollView.showsVerticalScrollIndicator = NO;
self.scrollView.indicatorStyle = UIScrollViewIndicatorStyleWhite;
self.scrollView.contentInset = UIEdgeInsetsMake(20, 0, 20, 0); // 上下各增加 20pt 滚动区域
Swift
scrollView.isScrollEnabled = true
scrollView.isPagingEnabled = true
scrollView.bounces = true
scrollView.alwaysBounceVertical = true
scrollView.showsHorizontalScrollIndicator = false
scrollView.showsVerticalScrollIndicator = false
scrollView.indicatorStyle = .white
scrollView.contentInset = UIEdgeInsets(top: 20, left: 0, bottom: 20, right: 0)
底层逻辑
scrollEnabled = NO时,UIScrollView不再响应滑动手势,但仍可通过代码修改contentOffset。userInteractionEnabled = NO会关闭所有用户交互,包括滚动和子视图的点击事件。pagingEnabled = YES时,滚动会自动吸附到视图宽度的整数倍位置,实现分页效果。bounces提供弹性回弹体验,即使内容未超出视图范围,alwaysBounceVertical/Horizontal也能强制开启回弹。
三、事件监听(代理方法)
UIScrollView 通过 delegate 属性将滚动状态变化通知给代理对象。控制器需遵守 UIScrollViewDelegate 协议。
常见代理方法
| 方法 | 调用时机 |
|---|---|
scrollViewDidScroll: |
滚动过程中持续调用(contentOffset 变化) |
scrollViewWillBeginDragging: |
用户开始拖拽时调用 |
scrollViewDidEndDragging:willDecelerate: |
用户结束拖拽时调用,decelerate 表示是否会继续减速 |
scrollViewWillBeginDecelerating: |
开始减速时调用 |
scrollViewDidEndDecelerating: |
减速停止时调用 |
scrollViewWillEndDragging:withVelocity:targetContentOffset: |
即将结束拖拽,可修改目标偏移量 |
scrollViewDidEndScrollingAnimation: |
代码调用 setContentOffset:animated: 动画结束时调用 |
scrollViewShouldScrollToTop: |
是否允许点击状态栏滚动到顶部(返回 BOOL) |
scrollViewDidScrollToTop: |
滚动到顶部动画完成时调用 |
示例代码
Objective-C
@interface ViewController () <UIScrollViewDelegate>
@property (weak, nonatomic) IBOutlet UIScrollView *scrollView;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.scrollView.delegate = self;
}
#pragma mark - UIScrollViewDelegate
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
// 滚动过程中持续调用,可用于做视差效果等
NSLog(@"偏移量: %@", NSStringFromCGPoint(scrollView.contentOffset));
}
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
NSLog(@"开始拖拽");
}
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate {
NSLog(@"结束拖拽,是否减速: %@", decelerate ? @"是" : @"否");
}
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
NSLog(@"减速停止,滚动结束");
}
- (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView {
NSLog(@"代码控制的滚动动画结束");
}
@end
Swift
class ViewController: UIViewController, UIScrollViewDelegate {
@IBOutlet weak var scrollView: UIScrollView!
override func viewDidLoad() {
super.viewDidLoad()
scrollView.delegate = self
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
print("偏移量: \(scrollView.contentOffset)")
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
print("开始拖拽")
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
print("结束拖拽,是否减速: \(decelerate)")
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
print("减速停止,滚动结束")
}
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
print("代码控制的滚动动画结束")
}
}
底层逻辑
- 代理是 weak 引用:
UIScrollView.delegate用weak修饰,避免循环引用。 - 滚动状态机:
UIScrollView内部维护滚动状态(Idle / Dragging / Decelerating / Tracking),状态切换时触发对应的代理方法。 - 高频回调:
scrollViewDidScroll:在滚动过程中调用非常频繁(每次屏幕刷新都可能调用),应避免在其中做耗时操作。
四、图片缩放
UIScrollView 内置捏合手势缩放功能,只需配置缩放比例并通过代理指定缩放对象。
配置步骤
- 设置
minimumZoomScale(最小缩放比例)和maximumZoomScale(最大缩放比例)。 - 实现代理方法
viewForZoomingInScrollView:,返回需要缩放的子视图。 - 可选:实现缩放过程和结束的回调。
示例代码
Objective-C
@interface ViewController () <UIScrollViewDelegate>
@property (weak, nonatomic) IBOutlet UIScrollView *scrollView;
@property (weak, nonatomic) IBOutlet UIImageView *imageView;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.scrollView.delegate = self;
self.scrollView.minimumZoomScale = 0.5; // 最小缩小到 50%
self.scrollView.maximumZoomScale = 2.0; // 最大放大到 200%
}
#pragma mark - UIScrollViewDelegate
// 返回需要缩放的视图
- (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView {
return self.imageView;
}
- (void)scrollViewDidZoom:(UIScrollView *)scrollView {
// 缩放过程中持续调用
}
- (void)scrollViewDidEndZooming:(UIScrollView *)scrollView withView:(UIView *)view atScale:(CGFloat)scale {
NSLog(@"缩放结束,当前比例: %.2f", scale);
}
@end
Swift
class ViewController: UIViewController, UIScrollViewDelegate {
@IBOutlet weak var scrollView: UIScrollView!
@IBOutlet weak var imageView: UIImageView!
override func viewDidLoad() {
super.viewDidLoad()
scrollView.delegate = self
scrollView.minimumZoomScale = 0.5
scrollView.maximumZoomScale = 2.0
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return imageView
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
// 缩放过程中
}
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
print("缩放结束,当前比例: \(scale)")
}
}
双击缩放(常见扩展)
双击放大/缩小是图片查看器的标准交互,可通过 UITapGestureRecognizer 实现:
Objective-C
- (void)viewDidLoad {
[super viewDidLoad];
UITapGestureRecognizer *doubleTap = [[UITapGestureRecognizer alloc]
initWithTarget:self action:@selector(handleDoubleTap:)];
doubleTap.numberOfTapsRequired = 2;
[self.scrollView addGestureRecognizer:doubleTap];
}
- (void)handleDoubleTap:(UITapGestureRecognizer *)gesture {
if (self.scrollView.zoomScale > self.scrollView.minimumZoomScale) {
// 当前已放大,双击缩小
[self.scrollView setZoomScale:self.scrollView.minimumZoomScale animated:YES];
} else {
// 当前为原始大小,双击放大到点击位置
CGPoint point = [gesture locationInView:gesture.view];
CGFloat newScale = self.scrollView.maximumZoomScale;
CGRect zoomRect = [self zoomRectForScale:newScale center:point];
[self.scrollView zoomToRect:zoomRect animated:YES];
}
}
底层逻辑
- 缩放本质:缩放时,
UIScrollView对viewForZoomingInScrollView:返回的视图应用CGAffineTransform缩放变换,该视图的frame会变化,但bounds不变。 - 缩放后可滚动:缩放后的视图尺寸可能超过
UIScrollView可视区域,此时UIScrollView自动允许滚动查看缩放后的内容。 - 只能缩放一个子视图:
viewForZoomingInScrollView:只能返回一个视图,如需缩放多个内容,应将它们放入一个容器视图中,返回该容器。
五、图片轮播器
通过 UIScrollView + UIPageControl + NSTimer 实现自动轮播图是常见需求。以下是完整、规范的实现。
基本思路
UIScrollView开启分页,横向排列多个UIImageView。NSTimer定时切换到下一页。UIPageControl指示当前页码,点击可跳转。- 用户拖拽时暂停定时器,拖拽结束后恢复。
完整实现
Objective-C
// XMGPageView.h
#import <UIKit/UIKit.h>
@interface XMGPageView : UIView
@property (nonatomic, copy) NSArray<NSString *> *imageNames;
+ (instancetype)pageView;
@end
// XMGPageView.m
#import "XMGPageView.h"
@interface XMGPageView () <UIScrollViewDelegate>
@property (weak, nonatomic) IBOutlet UIScrollView *scrollView;
@property (weak, nonatomic) IBOutlet UIPageControl *pageControl;
@property (nonatomic, strong) NSTimer *timer;
@end
@implementation XMGPageView
+ (instancetype)pageView {
return [[[NSBundle mainBundle] loadNibNamed:@"XMGPageView" owner:nil options:nil] firstObject];
}
- (void)awakeFromNib {
[super awakeFromNib];
self.scrollView.delegate = self;
self.scrollView.pagingEnabled = YES;
self.scrollView.bounces = NO;
self.scrollView.showsHorizontalScrollIndicator = NO;
self.scrollView.showsVerticalScrollIndicator = NO;
[self.pageControl addTarget:self
action:@selector(pageControlClick:)
forControlEvents:UIControlEventValueChanged];
[self startTimer];
}
#pragma mark - 定时器
- (void)startTimer {
// 使用 timerWithTimeInterval 创建(不自动加入 runloop),手动加入 common modes
// 这样在滚动时(UITrackingRunLoopMode)定时器仍能触发
self.timer = [NSTimer timerWithTimeInterval:2.0
target:self
selector:@selector(nextPage)
userInfo:nil
repeats:YES];
[[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
}
- (void)stopTimer {
[self.timer invalidate];
self.timer = nil;
}
- (void)nextPage {
NSInteger page = self.pageControl.currentPage + 1;
if (page >= self.imageNames.count) {
page = 0;
}
[self.scrollView setContentOffset:CGPointMake(page * self.scrollView.bounds.size.width, 0)
animated:YES];
}
#pragma mark - UIPageControl 点击
- (void)pageControlClick:(UIPageControl *)sender {
[self.scrollView setContentOffset:CGPointMake(sender.currentPage * self.scrollView.bounds.size.width, 0)
animated:YES];
}
#pragma mark - 设置数据
- (void)setImageNames:(NSArray<NSString *> *)imageNames {
_imageNames = [imageNames copy];
// 移除之前添加的 UIImageView(不影响滚动条等系统子视图)
for (UIView *subview in self.scrollView.subviews) {
if ([subview isKindOfClass:[UIImageView class]]) {
[subview removeFromSuperview];
}
}
for (NSInteger i = 0; i < imageNames.count; i++) {
UIImageView *iv = [[UIImageView alloc] init];
iv.image = [UIImage imageNamed:imageNames[i]];
iv.tag = 1000 + i; // 通过 tag 标识,便于 layoutSubviews 中定位
[self.scrollView addSubview:iv];
}
self.pageControl.numberOfPages = imageNames.count;
[self setNeedsLayout];
}
#pragma mark - 布局
- (void)layoutSubviews {
[super layoutSubviews];
CGFloat width = self.scrollView.bounds.size.width;
CGFloat height = self.scrollView.bounds.size.height;
for (NSInteger i = 0; i < self.imageNames.count; i++) {
UIImageView *iv = [self.scrollView viewWithTag:1000 + i];
iv.frame = CGRectMake(i * width, 0, width, height);
}
self.scrollView.contentSize = CGSizeMake(self.imageNames.count * width, height);
}
#pragma mark - dealloc
- (void)dealloc {
[self stopTimer]; // 必须在 dealloc 中停止定时器,否则循环引用导致无法释放
}
#pragma mark - UIScrollViewDelegate
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
// 四舍五入计算当前页码
NSInteger currentPage = (NSInteger)(scrollView.contentOffset.x / scrollView.bounds.size.width + 0.5);
self.pageControl.currentPage = currentPage;
}
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
[self stopTimer]; // 用户开始拖拽,暂停自动轮播
}
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
[self startTimer]; // 减速停止后恢复自动轮播
}
- (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView {
// 代码控制的滚动动画结束(定时器翻页或点击 PageControl)
}
@end
Swift
class PageView: UIView {
@IBOutlet weak var scrollView: UIScrollView!
@IBOutlet weak var pageControl: UIPageControl!
var imageNames: [String] = [] {
didSet {
// 移除旧的 UIImageView
scrollView.subviews
.filter { $0 is UIImageView }
.forEach { $0.removeFromSuperview() }
for (index, name) in imageNames.enumerated() {
let iv = UIImageView()
iv.image = UIImage(named: name)
iv.tag = 1000 + index
scrollView.addSubview(iv)
}
pageControl.numberOfPages = imageNames.count
setNeedsLayout()
}
}
private var timer: Timer?
static func pageView() -> PageView {
return Bundle.main.loadNibNamed("PageView", owner: nil)?.first as! PageView
}
override func awakeFromNib() {
super.awakeFromNib()
scrollView.delegate = self
scrollView.isPagingEnabled = true
scrollView.bounces = false
scrollView.showsHorizontalScrollIndicator = false
scrollView.showsVerticalScrollIndicator = false
pageControl.addTarget(self, action: #selector(pageControlClick(_:)), for: .valueChanged)
startTimer()
}
deinit {
stopTimer()
}
// MARK: - Timer
private func startTimer() {
timer = Timer(timeInterval: 2.0, target: self, selector: #selector(nextPage), userInfo: nil, repeats: true)
RunLoop.main.add(timer!, forMode: .common)
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
@objc private func nextPage() {
var page = pageControl.currentPage + 1
if page >= imageNames.count { page = 0 }
scrollView.setContentOffset(CGPoint(x: CGFloat(page) * scrollView.bounds.width, y: 0), animated: true)
}
// MARK: - PageControl
@objc private func pageControlClick(_ sender: UIPageControl) {
scrollView.setContentOffset(CGPoint(x: CGFloat(sender.currentPage) * scrollView.bounds.width, y: 0), animated: true)
}
// MARK: - Layout
override func layoutSubviews() {
super.layoutSubviews()
let width = scrollView.bounds.width
let height = scrollView.bounds.height
for (index, _) in imageNames.enumerated() {
if let iv = scrollView.viewWithTag(1000 + index) {
iv.frame = CGRect(x: CGFloat(index) * width, y: 0, width: width, height: height)
}
}
scrollView.contentSize = CGSize(width: CGFloat(imageNames.count) * width, height: height)
}
}
extension PageView: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let currentPage = Int(scrollView.contentOffset.x / scrollView.bounds.width + 0.5)
pageControl.currentPage = currentPage
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
stopTimer()
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
startTimer()
}
}
关键技术点
-
定时器与 RunLoop:
scheduledTimerWithTimeInterval:会自动将定时器加入当前 RunLoop 的default模式,滚动时 RunLoop 切换到tracking模式,定时器会暂停。- 如需滚动时定时器仍工作,应使用
timerWithTimeInterval:创建后手动加入NSRunLoopCommonModes。 - 不要同时使用两种方式,否则定时器会被重复注册。
-
定时器循环引用:
NSTimer会强引用target(self),而 self 强引用 timer,形成循环引用。- 必须在
dealloc中调用invalidate打破循环。但如果 timer 不 invalidate,dealloc 永远不会被调用——因此应在视图移除(willMoveToSuperview:nil)或控制器viewDidDisappear时主动停止定时器。 - 更优雅的方案是使用
YYWeakProxy等弱引用代理,避免 timer 强引用 self。
-
分页滚动:
pagingEnabled = YES让滚动自动吸附到页边界。 -
页码计算:
contentOffset.x / width + 0.5后取整,实现四舍五入,确保滚动超过一半时才切换页码。 -
拖拽时暂停定时器:在
scrollViewWillBeginDragging:中停止,在scrollViewDidEndDecelerating:中恢复(而非scrollViewDidEndDragging:,因为减速过程中不应开始计时)。 -
UIPageControl 自定义指示器:
- iOS 14+ 提供公共 API:
setIndicatorImage:forPage:和preferredIndicatorImage。 - 不建议通过 KVC 访问私有属性(如
_currentPageImage),可能导致审核被拒或系统升级后崩溃。
- iOS 14+ 提供公共 API:
六、总结
| 知识点 | 核心要点 |
|---|---|
| 基本用法 | 必须设置 contentSize 才能滚动;在 viewDidLayoutSubviews 中设置确保尺寸准确 |
| 常用属性 | contentOffset、contentInset、pagingEnabled、bounces、shows...Indicator |
| 代理方法 | 监听滚动、拖拽、减速、动画结束等状态;delegate 是 weak 引用 |
| 缩放 | 设置 minimum/maximumZoomScale,实现 viewForZoomingInScrollView: 返回缩放对象 |
| 轮播器 | UIScrollView 分页 + NSTimer 定时 + UIPageControl 指示;注意定时器 RunLoop 模式和循环引用 |
UIScrollView是 UIKit 中最复杂的控件之一,理解其bounds驱动滚动、transform驱动缩放的底层机制,有助于排查各种滚动和布局问题。- 实际开发中,列表场景优先使用
UITableView/UICollectionView(自带复用机制),自定义滚动场景才直接使用UIScrollView。

浙公网安备 33010602011771号