iOS开发基础8-UIScrollView 深度解析:滚动、缩放与图片轮播

UIScrollView 深度解析:滚动、缩放与图片轮播

UIScrollView 是 iOS 中最核心的滚动控件,UITableView、UICollectionView、UITextView 等都继承自它。掌握其基本用法、代理回调、缩放机制及轮播实现,是构建复杂交互界面的基础。


一、基本用法

使用步骤

  1. 创建 UIScrollView 并添加到父视图。
  2. 将需要展示的内容作为子视图添加到 UIScrollView。
  3. 设置 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 内置捏合手势缩放功能,只需配置缩放比例并通过代理指定缩放对象。

配置步骤

  1. 设置 minimumZoomScale(最小缩放比例)和 maximumZoomScale(最大缩放比例)。
  2. 实现代理方法 viewForZoomingInScrollView:,返回需要缩放的子视图。
  3. 可选:实现缩放过程和结束的回调。

示例代码

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 实现自动轮播图是常见需求。以下是完整、规范的实现。

基本思路

  1. UIScrollView 开启分页,横向排列多个 UIImageView。
  2. NSTimer 定时切换到下一页。
  3. UIPageControl 指示当前页码,点击可跳转。
  4. 用户拖拽时暂停定时器,拖拽结束后恢复。

完整实现

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()
    }
}

关键技术点

  1. 定时器与 RunLoop:

    • scheduledTimerWithTimeInterval: 会自动将定时器加入当前 RunLoop 的 default 模式,滚动时 RunLoop 切换到 tracking 模式,定时器会暂停。
    • 如需滚动时定时器仍工作,应使用 timerWithTimeInterval: 创建后手动加入 NSRunLoopCommonModes。
    • 不要同时使用两种方式,否则定时器会被重复注册。
  2. 定时器循环引用:

    • NSTimer 会强引用 target(self),而 self 强引用 timer,形成循环引用。
    • 必须在 dealloc 中调用 invalidate 打破循环。但如果 timer 不 invalidate,dealloc 永远不会被调用——因此应在视图移除(willMoveToSuperview:nil)或控制器 viewDidDisappear 时主动停止定时器。
    • 更优雅的方案是使用 YYWeakProxy 等弱引用代理,避免 timer 强引用 self。
  3. 分页滚动:pagingEnabled = YES 让滚动自动吸附到页边界。

  4. 页码计算:contentOffset.x / width + 0.5 后取整,实现四舍五入,确保滚动超过一半时才切换页码。

  5. 拖拽时暂停定时器:在 scrollViewWillBeginDragging: 中停止,在 scrollViewDidEndDecelerating: 中恢复(而非 scrollViewDidEndDragging:,因为减速过程中不应开始计时)。

  6. UIPageControl 自定义指示器:

    • iOS 14+ 提供公共 API:setIndicatorImage:forPage: 和 preferredIndicatorImage。
    • 不建议通过 KVC 访问私有属性(如 _currentPageImage),可能导致审核被拒或系统升级后崩溃。

六、总结

知识点 核心要点
基本用法 必须设置 contentSize 才能滚动;在 viewDidLayoutSubviews 中设置确保尺寸准确
常用属性 contentOffset、contentInset、pagingEnabled、bounces、shows...Indicator
代理方法 监听滚动、拖拽、减速、动画结束等状态;delegate 是 weak 引用
缩放 设置 minimum/maximumZoomScale,实现 viewForZoomingInScrollView: 返回缩放对象
轮播器 UIScrollView 分页 + NSTimer 定时 + UIPageControl 指示;注意定时器 RunLoop 模式和循环引用
  • UIScrollView 是 UIKit 中最复杂的控件之一,理解其 bounds 驱动滚动、transform 驱动缩放的底层机制,有助于排查各种滚动和布局问题。
  • 实际开发中,列表场景优先使用 UITableView / UICollectionView(自带复用机制),自定义滚动场景才直接使用 UIScrollView。
posted @ 2015-07-16 22:28  Mr.陳  阅读(1107)  评论(0)    收藏  举报